mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(core): Route runtime tools through bwrap (#12269)
* feat(core): Route runtime tools through bwrap Inject an immutable trusted-host sandbox policy into the runtime, route shell and file effects through the bwrap execution foundation, and reject unsupported effect paths without changing public CLI configuration. Co-authored-by: Qwen-Coder <[email protected]> * fix(core): preserve staged bwrap compatibility Keep the public whole-CLI bwrap path working until the CLI cutover lands, and bind runtime sandbox evidence to its exact source revision and input hashes. Co-authored-by: Qwen-Coder <[email protected]> * fix(core): close runtime sandbox review gaps Co-authored-by: Qwen-Coder <[email protected]> * fix(core): preserve requested sandbox masks Co-authored-by: Qwen-Coder <[email protected]> * fix(core): harden runtime bwrap launch Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
eceaede18e
commit
fb022d9f80
46 files changed
+4615
-208
No files matched your search
@@ -0,0 +1,49 @@
|
||||
# Runtime file tools in the execution sandbox
|
||||
|
||||
[English](2026-09-16-runtime-file-sandbox.md) | [简体中文](2026-09-16-runtime-file-sandbox.zh-CN.md)
|
||||
|
||||
## Status and problem
|
||||
|
||||
Implemented internal integration stage, 2026-09-16, following [runtime Shell integration](2026-09-16-runtime-shell-sandbox.md). Before this stage the registry exposed only Shell and task_stop. The prototype file worker accepted small UTF-8 JSON writes, replaced symlinks, and did not preserve existing modes. Production Write/Edit additionally created directories on the host. Simply registering those tools would both bypass confinement and leave existing files uneditable without Read's prior-read cache.
|
||||
|
||||
## Scope and policy
|
||||
|
||||
Keep the existing trusted-host-only bare noninteractive admission and fixed workspace ceiling. Register the existing Read, Write, Edit, Shell and task_stop tools. No settings, environment switch, public CLI flag, Landlock implementation, or legacy whole-CLI removal is included. Other tools, hooks, MCP, LSP, extensions and worktree effects retain their previous restrictions. Custom filesystem delegation is rejected in this mode; ordinary runtime delegation is unchanged.
|
||||
|
||||
The completed runtime-integration branch also prepares the next public CLI stage by admitting Monitor, Glob, LS and same-workspace in-process agents under the same immutable policy. Monitor uses the runtime shell executor; nested agents inherit the policy and cannot request worktrees, alternate working directories, teammates, custom executors, hooks or MCP servers. These additions do not create a public enablement path in this stage, and the unsupported variants fail before setup. The tracked verifier's registry assertions include this final internal runtime set.
|
||||
|
||||
The current filesystem policy allows host reads and does not promise read confidentiality. Read keeps its existing content processing and inode-based prior-read cache. Reject PDF processing before any metadata probe, extraction or rasterization can start a host subprocess; users may invoke suitable utilities through confined Shell. Ordinary image processing is in memory and host model requests retain their trusted-runtime status.
|
||||
|
||||
## Write execution
|
||||
|
||||
Add one internal runtime write helper at the final Write/Edit write sites. Without policy it delegates to the existing FileSystemService, including tool-write provenance. With policy it uses the existing text encoder to preserve charset, BOM and line endings, then sends bytes to the installed worker through the existing confined pipe launch. Directory creation moves into the worker; the tool's host mkdir is skipped. Read-only policy rejects file mutations, even with YOLO. No failure retries on the host.
|
||||
|
||||
Capture an expected regular-file version before reading/preparing the original content: device, inode, size, mtime and ctime, using bigint stat fields serialized as decimal strings; a missing file is null. Never refresh that version after reading. The worker checks the version before creating directories or temporary files and again through atomicWriteFile's assertCanCommit callback immediately before commit. A changed/replaced/disappeared/appeared target fails with a structured stale-file error that asks the caller to re-read. This narrows the added worker-launch race window; it is not atomic compare-and-swap and does not provide locking against arbitrary simultaneous writers.
|
||||
|
||||
Replace the prototype JSON-content transport with a bounded JSON header followed by newline and raw encoded bytes. The header is at most 16 KiB and validates operation, absolute destination and version shape. Content is not subject to the prototype's 1 MiB JSON limit; the tool's existing in-memory text preparation remains. The worker reuses atomicWriteFile for mode preservation, fsync, symlink-following behavior and its established ownership/cross-device fallbacks. Existing in-workspace and dangling symlinks retain their meaning; paths resolving outside writable grants are denied by the kernel. Special files are rejected even when the prior-read cache is disabled.
|
||||
|
||||
The worker returns structured success or filesystem error codes. The client accepts success only with a confirmed sandbox receipt, exit zero and a valid reply; uncertain completion is never replayed. Preserve ordinary tool errors, confirmation, replacement matching, diff display, artifact metadata and post-write cache updates. Existing attribution records only update host memory. File checkpointing is unavailable while this internal policy is active because its host-side backup and rewind lifecycle is outside the confined file path.
|
||||
|
||||
## Affected code and compatibility
|
||||
|
||||
Core Config changes only the restricted tool registry/allowlist and rejects custom filesystem services. Write/Edit capture versions and route the final mutation. The shared file-content pipeline rejects PDF host processing under policy. The worker/client transport and Linux prototype verifier change together; this API is internal and has no public compatibility promise. Encoding and atomic-write implementations are reused rather than duplicated.
|
||||
|
||||
Historical runtime Shell and API-stage evidence remains labeled by its frozen artifact. This stage rebuilds both worker assets and reruns the corresponding Linux acceptance after the protocol change. Remaining file-producing tools, full frontend/effect inventory, read confidentiality, inode-wide hard-link immutability and atomic CAS remain outside this stage.
|
||||
|
||||
## Validation and acceptance
|
||||
|
||||
Use the repository feat-dev and e2e-testing workflows. Before implementation, drive the global CLI with a deterministic local model and disposable workspace/HOME/state to establish native Read/Edit semantics and unconstrained outside writes. Keep the ordinary installed CLI baseline distinct from the internal policy launcher.
|
||||
|
||||
Unit coverage must check default delegation, policy routing without host mkdir/write, version drift and absent/appeared targets, regular-file enforcement, structured worker failures and custom-adapter rejection. Preserve the existing Write/Edit/Read suites. On real Linux drive production Read/Write/Edit turns, verify the restricted registry, Unicode and non-UTF-8 bytes, BOM/CRLF, existing modes, nested creation, allowed and denied symlink traversal, read-only denial, two runtime ceilings and cancellation without replay. Add deterministic worker-level stale-version tests and content larger than the old 1 MiB cap. PDF rejection must be proven before a helper process launches. Rerun existing Shell lifecycle and adapter tests.
|
||||
|
||||
Acceptance requires build, typecheck, bundle, focused tests, matching final source/artifact hashes, two clean self-audits and independent review. Record exact results and limitations in .qwen/e2e-tests/runtime-file-sandbox.md. No unresolved design choices remain for this internal stage.
|
||||
|
||||
## Verification results
|
||||
|
||||
On 2026-09-16 the global qwen 0.23.4 baseline completed seven actual Read/Edit calls before implementation. At recorded revision `661f5416f2b0e9c73a47582fac742ef78d777a3d`, the candidate passed 31 production headless runtime groups and 34 adapter/worker groups on Lima qwen-sbx, ARM64 Linux 7.0.0-31-generic, Node 22.22.1 and bubblewrap 0.11.1. These frozen measurements describe that revision and its recorded artifact; later review fixes require their own exact-head validation. The measurements include the existing Shell lifecycle, private PID namespace cleanup, file semantics, outside-write denial and failed setup without host replay. Raw binary payloads larger than 1 MiB, empty content, mode/symlink preservation, stale/replaced/removed/appeared targets and FIFO rejection passed in the worker harness.
|
||||
|
||||
Build, typecheck and bundle passed. Focused core and CLI tests passed 3,328 cases with one pre-existing skip. The runtime artifact has 4,791 matching source inputs; launcher SHA-256 is `18a7a557bf41e8ecc3baa3de1a6826fccc21a7fb93cbac3f49584d83a362b8e1`. The adapter artifact has 278 matching inputs; its worker SHA-256 `439e880137e0ef7ab383fb54f8d4f42804a406e363c6513468a102c7f90e3a18` matches the production bundle worker. Exact evidence and cleanup records are in `.qwen/e2e-tests/runtime-file-sandbox.md`.
|
||||
|
||||
The Linux evidence covers this ARM64 environment. Linux x64, other kernels, public frontend enablement and Landlock remain unvalidated or unimplemented. Cancellation and uncertain receipt handling use the existing real-Linux adapter lifecycle checks plus file-client unit rejection tests; the runtime file groups do not interrupt an in-progress file commit. No atomic compare-and-swap or general rollback guarantee is claimed.
|
||||
|
||||
For the recorded revision, package dry-run included both installed workers and the recorded source and worker hashes were rechecked afterward. These historical hashes are not evidence for later commits.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Runtime 文件工具接入执行沙箱
|
||||
|
||||
[English](2026-09-16-runtime-file-sandbox.md) | [简体中文](2026-09-16-runtime-file-sandbox.zh-CN.md)
|
||||
|
||||
## 状态与问题
|
||||
|
||||
2026-09-16,继 [Runtime Shell 接入](2026-09-16-runtime-shell-sandbox.zh-CN.md)之后已实现的内部集成阶段。此前注册表仅开放 Shell 和 task_stop。此前原型文件 worker 只接受小型 UTF-8 JSON 写入,会替换符号链接,也不保留已有文件权限。此前生产 Write/Edit 还会在宿主创建目录。直接注册这些工具既会绕过约束,又会因为缺少 Read 的先读缓存而无法编辑已有文件。
|
||||
|
||||
## 范围与策略
|
||||
|
||||
保留现有仅可信宿主可注入的 bare 非交互准入和固定工作区权限上限。注册已有 Read、Write、Edit、Shell 和 task_stop 工具。本阶段不增加设置、环境开关或公开 CLI 参数,不实现 Landlock,也不移除旧的整个 CLI 沙箱。其他工具、hook、MCP、LSP、扩展和 worktree 副作用继续受上一阶段限制。此模式拒绝自定义文件系统委托,普通 runtime 的委托行为保持不变。
|
||||
|
||||
已完成的 runtime 接入分支还为下一阶段公开 CLI 接入做准备,在同一不可变策略下准入 Monitor、Glob、LS 和同工作区进程内 agent。Monitor 使用 runtime shell executor;嵌套 agent 继承策略,并且不能请求 worktree、其他工作目录、teammate、自定义 executor、hook 或 MCP server。这些能力在本阶段不提供公开启用入口,不支持的变体会在设置前失败。受跟踪 verifier 的 registry 断言包含这组最终内部 runtime 工具。
|
||||
|
||||
当前文件系统策略允许宿主读取,不承诺读取机密性。Read 保留原内容处理流程与基于 inode 的先读缓存。在 PDF 元数据探测、文本提取或栅格化启动任何宿主子进程之前拒绝 PDF 处理;用户可通过受约束的 Shell 调用相应工具。普通图片处理在内存中进行,宿主模型请求继续属于可信 runtime 行为。
|
||||
|
||||
## 写入执行
|
||||
|
||||
在 Write/Edit 最终写入处增加一个内部 runtime 写入 helper。无策略时继续委托已有 FileSystemService,并保留工具写入来源。有策略时复用现有文本编码器保留字符集、BOM 和换行,再通过已有受约束的管道启动把字节发给安装的 worker。目录创建移入 worker,跳过工具层的宿主 mkdir。只读策略拒绝文件变更,YOLO 也不例外。任何失败都不在宿主重试。
|
||||
|
||||
在读取或准备原文之前捕获预期普通文件版本:device、inode、size、mtime 和 ctime,使用 bigint stat 字段并序列化为十进制字符串;不存在的文件为 null。读取后绝不重新获取版本来替代它。worker 在创建目录或临时文件之前检查版本,并通过 atomicWriteFile 的 assertCanCommit 回调在提交前再次检查。目标变化、替换、消失或新出现时返回结构化的文件过期错误,要求重新读取。这缩小了新增 worker 启动带来的竞争窗口,但不是原子 compare-and-swap,也不为任意并发写入方提供锁。
|
||||
|
||||
将原型的 JSON 内容传输改为有限长度 JSON header、换行和原始编码字节。header 最多 16 KiB,校验操作、绝对目标路径和版本结构。内容不再受原型 1 MiB JSON 限制;工具仍使用现有内存文本准备流程。worker 复用 atomicWriteFile,保留 mode、fsync、跟随符号链接的行为及已有所有权/跨设备回退。工作区内链接和悬空链接保留原有语义;解析到可写授权之外的路径由内核拒绝。即使禁用先读缓存,也拒绝特殊文件。
|
||||
|
||||
worker 返回结构化成功或文件系统错误码。client 只有在沙箱回执已确认、退出码为零且回复有效时才接受成功;无法确认的完成绝不重放。保留普通工具错误、确认、替换匹配、diff 展示、artifact 元数据和写后缓存更新。已有署名记录仅更新宿主内存。此内部策略启用时不提供文件 checkpoint,因为其宿主侧备份与回滚生命周期不在受约束的文件路径内。
|
||||
|
||||
## 影响代码与兼容性
|
||||
|
||||
Core Config 仅调整受限工具注册表/允许列表,并拒绝自定义文件系统服务。Write/Edit 捕获版本并路由最终变更。共享文件内容流程在策略下拒绝宿主 PDF 处理。worker/client 协议与 Linux 原型验证器同步修改;此 API 属于内部实现,没有公开兼容性承诺。复用编码与原子写实现,不重新复制。
|
||||
|
||||
历史 Runtime Shell 和 API 阶段证据继续以冻结 artifact 标识。本阶段重新构建两个 worker 资源,并在协议变化后重跑相应 Linux 验收。其他产出文件的工具、完整前端/副作用清单、读取机密性、hard link 的 inode 级不可变性和原子 CAS 不属于本阶段。
|
||||
|
||||
## 验证与验收
|
||||
|
||||
遵循仓库 feat-dev 和 e2e-testing 流程。实现前,用确定性的本地模型和临时 workspace/HOME/state 驱动全局 CLI,确认原有 Read/Edit 语义与未受约束的根外写入。区分普通安装 CLI 基线与内部策略 launcher。
|
||||
|
||||
单测覆盖默认委托、无宿主 mkdir/write 的策略路由、版本变化与目标新出现/消失、普通文件限制、worker 结构化失败和自定义 adapter 拒绝。保留已有 Write/Edit/Read 测试。在真实 Linux 驱动生产 Read/Write/Edit 回合,验证受限注册表、Unicode 与非 UTF-8 字节、BOM/CRLF、已有 mode、嵌套创建、允许和拒绝的链接遍历、只读拒绝、两个 runtime 上限,以及取消不重放。增加确定性的 worker 版本冲突测试和超过旧 1 MiB 上限的内容测试。证明 PDF 在辅助进程启动前被拒绝。重跑既有 Shell 生命周期与 adapter 测试。
|
||||
|
||||
验收要求 build、typecheck、bundle、定向测试、最终源码/artifact hash 一致、两轮干净自审及独立审查。精确结果与限制记录在 .qwen/e2e-tests/runtime-file-sandbox.md。本内部阶段没有未解决的设计选择。
|
||||
|
||||
## 验证结果
|
||||
|
||||
2026-09-16,实施前的全局 qwen 0.23.4 基线完成了七次真实 Read/Edit 调用。在记录的 revision `661f5416f2b0e9c73a47582fac742ef78d777a3d` 上,候选产物在 Lima qwen-sbx、ARM64 Linux 7.0.0-31-generic、Node 22.22.1、bubblewrap 0.11.1 上通过 31 组生产 headless runtime 检查和 34 组 adapter/worker 检查。这些冻结的测量只描述该 revision 及其记录的 artifact;后续审查修复需要在准确 head 上重新验证。测量覆盖原有 Shell 生命周期、私有 PID namespace 清理、文件语义、越界写拒绝和启动失败不在宿主重放。worker 套件通过了超过 1 MiB 的原始二进制内容、空内容、权限/符号链接保留、目标变化/替换/删除/新出现冲突和 FIFO 拒绝。
|
||||
|
||||
Build、typecheck 和 bundle 通过。定向 core 与 CLI 单测通过 3,328 项,另有一项原有跳过。runtime 产物的 4,791 个源码输入匹配,launcher SHA-256 为 `18a7a557bf41e8ecc3baa3de1a6826fccc21a7fb93cbac3f49584d83a362b8e1`。adapter 产物的 278 个输入匹配,其 worker SHA-256 `439e880137e0ef7ab383fb54f8d4f42804a406e363c6513468a102c7f90e3a18` 与生产 bundle worker 一致。精确证据与清理记录在 `.qwen/e2e-tests/runtime-file-sandbox.md`。
|
||||
|
||||
Linux 证据覆盖上述 ARM64 环境。Linux x64、其他内核、公开前端启用和 Landlock 尚未验证或实现。取消与不确定回执处理由现有真实 Linux adapter 生命周期检查和文件 client 单测拒绝路径共同覆盖;runtime 文件用例没有中断正在执行的文件提交。不承诺原子 compare-and-swap 或通用回滚。
|
||||
|
||||
对于记录的 revision,打包预检包含两个安装 worker,并在预检后再次确认了所记录源码与 worker hash。这些历史 hash 不作为后续提交的证据。
|
||||
@@ -0,0 +1,45 @@
|
||||
# Runtime shell sandbox integration
|
||||
|
||||
[English](2026-09-16-runtime-shell-sandbox.md) | [简体中文](2026-09-16-runtime-shell-sandbox.zh-CN.md)
|
||||
|
||||
## Status and problem
|
||||
|
||||
Implementation stage, 2026-09-16. The structured process API and bwrap adapter are implemented and have Linux kernel evidence. Previously, production ShellTool launched through the legacy service without receiving an operator policy. This stage connects the trusted runtime to foreground and background shell execution. It builds on the [bwrap execution foundation](2026-09-17-bwrap-execution-foundation.md) and is not the public sandbox release.
|
||||
|
||||
This document records the completed Shell-stage snapshot. The subsequent [runtime file-tool integration](2026-09-16-runtime-file-sandbox.md) expands the restricted registry to Read/Write/Edit and replaces the prototype worker protocol. Registry and artifact statements below describe this historical stage.
|
||||
|
||||
## Design
|
||||
|
||||
The existing runtime-only `loadCliConfig` host-policy argument accepts `shellExecutionSandbox`, which is copied into Config. There is no settings, environment, or public CLI switch. Config admits and freezes the original canonical workspace policy and protects both the runtime state and global configuration directories. Derived Configs inherit that ceiling; derivation or relocation outside it fails before mutation. The trusted host must construct a newly admitted Config to authorize another workspace.
|
||||
|
||||
This internal mode requires a bare, noninteractive runtime and rejects explicit extensions, MCP servers, tool discovery, LSP, legacy whole-CLI sandboxing, and provisional workspaces. Its registry contains only `run_shell_command` and `task_stop`. Bare startup suppresses hooks and ambient extensions/MCP. Unsupported model tool calls cannot reach file tools, subagents, worktrees, or Exec. This is a constrained headless integration; arbitrary embedders, slash commands, TUI, ACP, and other effect entrypoints remain release gates.
|
||||
|
||||
A runtime shell helper preserves the legacy path when no policy exists. With a policy, it builds an explicit `/bin/bash -c` launch, the existing sanitized shell environment and current session context, and invokes bwrap. The payload environment is handed to the relay through a mode-0600 control file and becomes bwrap's child environment instead of appearing in process arguments; the relay removes the file before spawning bwrap. Foreground, background, and promotion retain the existing registry, stream, cancellation, timeout, and host relay PID behavior. A failed admission or launch never falls back to an unconfined command.
|
||||
|
||||
Sandbox receipt validation applies to ordinary completion and promoted terminal callbacks. Confirmed nonzero exits remain command failures. Unconfirmed termination produces an error warning that the command may have run and must not be automatically replayed. Interrupted termination cannot become success. Promotion is only a running snapshot; terminal validation and resource ownership continue after it.
|
||||
|
||||
The sed preview/write optimization is disabled so the original command executes inside the sandbox. Git notes, PR binding metadata subprocesses, textual commit/PR attribution rewrites, and the system prompt's Git status snapshot are suspended in this internal mode; even `git status` can execute repository-configured clean filters on the host. Git status remains available through the confined Shell tool. Permission defaults conservatively ask instead of running host Git configuration probes, and confirmation does not perform those probes. Ordinary approval and YOLO never expand the OS policy. Background launch exceptions close their output stream. The review-worktree lease directory is hidden behind a private tmpfs after the workspace bind, so a confined process can neither read real leases nor forge records that later authorize host cleanup. The launcher creates an absent mount point only for a writable workspace; a read-only workspace skips an absent path because there is no lease to hide and the sandbox cannot create one.
|
||||
|
||||
Workspace-write intentionally allows repository metadata changes, including `.git` hooks and configuration. A later host process must therefore treat the entire writable workspace as sandbox output and must not execute repository-controlled hooks, filters, or configuration without a separate trust decision.
|
||||
|
||||
## Affected layers and boundaries
|
||||
|
||||
Core Config owns immutable admission and restricted registration. CLI config transports trusted policy. ShellTool delegates launches and disables uncovered shortcuts. The bwrap adapter transports validated terminal status. Tests cover policy ownership, shell routing, default behavior, and Linux production headless execution.
|
||||
|
||||
File-tool migration, complete effect inventory, attribution migration, retiring whole-CLI bwrap, Landlock, and automatic backend selection are later stages. Read confidentiality and inode-level immutability are not claimed. Host model/auth/session persistence remain outside the tool sandbox. No new public enabled mode is delivered here.
|
||||
|
||||
## Validation and acceptance
|
||||
|
||||
First run a global CLI baseline with an isolated HOME, disposable workspace/state, and a deterministic fake OpenAI server. It should demonstrate the existing unconstrained ShellTool behavior. Then build, typecheck, bundle, and run focused package tests, preserving the legacy shell suite.
|
||||
|
||||
On real Linux, a test-only launcher invokes production `loadCliConfig`, auth initialization, and `runNonInteractive`, injecting only the trusted policy. Verify model HTTP and host session persistence, workspace writes, outside-write and closed-network denial, real sed confinement, unsupported file-tool rejection, foreground/background lifecycle, and two independent workspace policies. Explicitly label this production headless pipeline rather than claiming that the ordinary public `node dist/cli.js` accepts the mode. Smoke-test the ordinary bundle separately. Reuse the process adapter's Linux lifecycle evidence and rerun it when changed.
|
||||
|
||||
Acceptance requires no unconfined fallback, no false terminal success, no implicit grants during derivation/relocation, and preserved default behavior. Record exact artifact and test evidence under `.qwen/e2e-tests/`. Complete two clean self-audit passes and independent code review. No open design questions remain for this bounded stage.
|
||||
|
||||
## Verification results (2026-09-16)
|
||||
|
||||
The implementation passed 2,769 focused tests (2,154 core and 615 CLI, with one existing skipped test), the full repository build, typecheck, bundle, targeted lint and formatting. The ordinary bundled CLI still reports `0.23.4`. The isolated global CLI baseline demonstrated the previous outside-write behavior before implementation.
|
||||
|
||||
On Lima `qwen-sbx` (Linux `7.0.0-31-generic`, ARM64, Node `v22.22.1`, bwrap `0.11.1`), the tracked `scripts/sandbox-runtime` harness passed 22 checks through the production headless pipeline and real ShellTool lifecycle. The existing adapter harness passed 31 checks; its source input hashes remain unchanged, so that evidence is still current. Evidence includes two Config instances in one process with distinct session IDs and denied cross-root writes, host model requests and session JSONL, kernel write/network denial, restricted startup/tool registration, Git clean filters executing only inside the sandbox, and direct/background/promoted receipt-loss failures. Namespace cleanup checks compare identities rather than numeric PIDs. Review-lease lifecycle unit tests exercise both ordinary turn finalization and the registered exit callback, proving that only the ordinary mode invokes host cleanup; no additional destructive lease scenario was constructed.
|
||||
|
||||
The final runtime v4 manifest has 4,787 source and test-script input hashes, all rechecked against the checkout after execution. Its launcher SHA-256 is `3be748159cd10d7b1f1054042061b3e6f6678aab707fabe995cb8e49267dd792`. The detailed baseline, intermediate LSP rejection-contract correction, Git snapshot and review-lease cleanup fixes, final reports and test limitations are in `.qwen/e2e-tests/runtime-shell-sandbox.md`. Linux x64, other kernels, macOS/Windows enforcement and a public CLI enablement path were not validated in this stage.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Runtime Shell 沙箱接入
|
||||
|
||||
[English](2026-09-16-runtime-shell-sandbox.md) | [简体中文](2026-09-16-runtime-shell-sandbox.zh-CN.md)
|
||||
|
||||
## 状态与问题
|
||||
|
||||
实现阶段,2026-09-16。结构化进程 API 与 bwrap adapter 已实现并获得 Linux 内核验证。此前生产 ShellTool 通过旧服务启动,未接收操作方策略。本阶段把可信 runtime 接入前台和后台 Shell 执行。它基于 [bwrap 执行底座](2026-09-17-bwrap-execution-foundation.zh-CN.md),不是面向用户的沙箱发布。
|
||||
|
||||
本文记录已完成的 Shell 阶段快照。后续 [runtime 文件工具接入](2026-09-16-runtime-file-sandbox.zh-CN.md)将受限 registry 扩展到 Read/Write/Edit,并替换原型 worker 协议。下文的 registry 和产物说明描述该历史阶段。
|
||||
|
||||
## 设计
|
||||
|
||||
现有 `loadCliConfig` 的 runtime-only host-policy 参数接收 `shellExecutionSandbox` 并传给 Config,不增加设置、环境变量或公开 CLI 开关。Config 准入并冻结原始规范化 workspace 策略,同时保护 runtime 状态和全局配置目录。派生 Config 继承该权限上限;超出上限的派生或目录切换在修改状态前失败。要授权另一个 workspace,可信宿主必须重新构造并准入 Config。
|
||||
|
||||
内部模式要求 bare、非交互 runtime,并拒绝显式扩展、MCP server、工具发现、LSP、旧的整个 CLI 沙箱和 provisional workspace。工具注册表仅包含 `run_shell_command` 与 `task_stop`。Bare 启动关闭 hook 和环境中的扩展/MCP。不支持的模型工具调用无法进入文件工具、子代理、worktree 或 Exec。这是受限的 headless 集成;任意嵌入方式、slash command、TUI、ACP 和其他副作用入口仍是发布前置条件。
|
||||
|
||||
Runtime shell helper 在没有策略时保留旧路径。有策略时构造显式 `/bin/bash -c` 启动描述,使用已有清洗后的 shell 环境和当前 session 上下文,再调用 bwrap。Payload 环境通过权限为 0600 的控制文件交给 relay,并作为 bwrap 子进程环境传递,不再出现在进程参数中;relay 在启动 bwrap 前删除该文件。前台、后台和转后台保留现有 registry、输出流、取消、超时及宿主 relay PID 行为。准入或启动失败绝不退回无约束命令。
|
||||
|
||||
沙箱回执校验同时覆盖普通完成和转后台后的终结回调。已确认的非零退出仍是命令失败。无法确认的终结产生错误,说明命令可能已经执行,不得自动重放。中断不能变成成功。转后台只是运行中快照;此后仍保留终结校验和资源所有权。
|
||||
|
||||
禁用 sed 预览/写入优化,让原始命令在沙箱内执行。内部模式暂停 Git notes、PR 绑定的元数据子进程、纯文本 commit/PR 署名改写和系统提示词的 Git 状态快照,因为即使 `git status` 也能在宿主执行仓库配置的 clean filter。仍可通过受约束的 Shell 工具查看 Git 状态。权限默认保守询问,不执行宿主 Git 配置探测,确认阶段也不进行这些探测。普通批准和 YOLO 都不会扩大 OS 策略。后台启动异常会关闭输出流。在工作区 bind 之后,review worktree lease 目录会被私有 tmpfs 遮蔽,因此受约束进程既不能读取真实 lease,也不能伪造随后可授权宿主清理的记录。仅在 workspace 可写时由启动方创建缺失的挂载点;只读 workspace 遇到缺失路径时直接跳过,因为此时既没有 lease 需要遮蔽,沙箱也无法创建 lease。
|
||||
|
||||
Workspace-write 会有意允许修改仓库元数据,包括 `.git` hook 和配置。后续宿主进程因此必须把整个可写工作区视为沙箱输出;没有单独的信任决策时,不得执行由仓库控制的 hook、filter 或配置。
|
||||
|
||||
## 影响层次与边界
|
||||
|
||||
Core Config 负责不可变准入和受限工具注册。CLI config 传递可信策略。ShellTool 委托启动并关闭尚未覆盖的捷径。bwrap adapter 传递经过校验的终结状态。测试覆盖策略归属、Shell 路由、默认行为和 Linux 生产 headless 执行。
|
||||
|
||||
文件工具迁移、完整副作用清单、署名流程迁移、移除整个 CLI 的 bwrap、Landlock 和自动后端选择留到后续阶段。不声明读取机密性或 inode 级不可变性。宿主模型、认证和 session 持久化位于工具沙箱之外。本阶段不交付新的公开启用模式。
|
||||
|
||||
## 验证与验收
|
||||
|
||||
先使用隔离 HOME、临时 workspace/state 和确定性的假 OpenAI server 执行全局 CLI 基线,证明当前 ShellTool 的无约束行为。随后 build、typecheck、bundle 并执行针对性 package 测试,保留旧 Shell 测试集。
|
||||
|
||||
在真实 Linux 上,测试专用 launcher 调用生产 `loadCliConfig`、认证初始化和 `runNonInteractive`,仅注入可信策略。验证模型 HTTP 与宿主 session 持久化、workspace 写入、根外写入和关闭网络的拒绝、真实 sed 约束、不支持文件工具的拒绝、前后台生命周期,以及两个独立 workspace 的策略。准确称为生产 headless pipeline,不宣称公开 `node dist/cli.js` 可以启用该模式。单独 smoke test 普通 bundle。复用进程 adapter 的 Linux 生命周期证据;改动 adapter 时重跑。
|
||||
|
||||
验收要求:无宿主回退、无虚假终结成功、派生/切换目录不隐式授权、默认行为不退化。精确 artifact 与测试证据记录在 `.qwen/e2e-tests/`。完成两轮干净自审和独立代码审查。本阶段没有尚未确定的设计问题。
|
||||
|
||||
## 验证结果(2026-09-16)
|
||||
|
||||
实现通过 2,769 项定向测试(core 2,154 项、CLI 615 项,另有 1 项原有跳过测试)、完整仓库 build、typecheck、bundle、定向 lint 和格式检查。普通 bundle CLI 仍返回 `0.23.4`。实现前的隔离全局 CLI 基线证明了此前可越界写入的行为。
|
||||
|
||||
在 Lima `qwen-sbx`(Linux `7.0.0-31-generic`、ARM64、Node `v22.22.1`、bwrap `0.11.1`)上,仓库中的 `scripts/sandbox-runtime` 套件通过生产 headless 链路与真实 ShellTool 生命周期完成 22 项检查,已有 adapter 套件通过 31 项,源码输入 hash 未变化,因此该结果继续有效。证据覆盖同一进程内两个 Config 的不同 session ID 与交叉写入拒绝、宿主模型请求和 session JSONL、内核写入/网络拒绝、受限启动/工具注册、Git clean filter 仅在沙箱内执行,以及前台、后台和转后台时的回执丢失失败。namespace 清理检查比较身份,不依赖数值 PID。review lease 清理的生命周期单测同时覆盖正常回合收尾与退出回调,确认仅普通模式会调用宿主清理;未额外构造破坏性 lease 场景。
|
||||
|
||||
最终 runtime v4 manifest 包含 4,787 个源码与测试脚本输入 hash,执行后全部与工作区重新比对一致。launcher SHA-256 为 `3be748159cd10d7b1f1054042061b3e6f6678aab707fabe995cb8e49267dd792`。详细基线、中间 LSP 拒绝契约修正、Git 快照与 review lease 清理修复、最终报告和测试边界位于 `.qwen/e2e-tests/runtime-shell-sandbox.md`。本阶段未验证 Linux x64、其他内核、macOS/Windows 内核约束或公开 CLI 启用入口。
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as os from 'node:os';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
GOAL_DEFAULT_TOKEN_BUDGET,
|
||||
@@ -4816,6 +4817,100 @@ describe('loadCliConfig with includeDirectories', () => {
|
||||
expect(config.isLspEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('transports the trusted host policy and ignores an undeclared top-level setting', async () => {
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as import('node:fs').Stats);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((value) => value.toString());
|
||||
process.argv = ['node', 'script.js', '--bare', '-p', 'fixture'];
|
||||
const argv = await parseArguments();
|
||||
const policy = {
|
||||
workspace: path.resolve(path.sep, 'sandbox-fixture', 'workspace'),
|
||||
installation: path.resolve('/trusted-install'),
|
||||
state: path.resolve('/trusted-state'),
|
||||
maskedPaths: [
|
||||
path.resolve(path.sep, 'sandbox-fixture', 'workspace', '.env'),
|
||||
],
|
||||
filesystem: 'workspace-write' as const,
|
||||
network: 'closed' as const,
|
||||
};
|
||||
const config = await loadCliConfig(
|
||||
{},
|
||||
argv,
|
||||
policy.workspace,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
{ shellExecutionSandbox: policy },
|
||||
);
|
||||
expect(config.getShellExecutionSandbox()).toMatchObject({
|
||||
...policy,
|
||||
maskedPaths: expect.any(Array),
|
||||
});
|
||||
expect(config.getShellExecutionSandbox()?.maskedPaths).toEqual([
|
||||
policy.maskedPaths[0],
|
||||
path.join(policy.workspace, '.qwen', 'review-leases'),
|
||||
]);
|
||||
expect(config.getCoreTools()).toEqual(
|
||||
expect.arrayContaining([
|
||||
ToolNames.SHELL,
|
||||
ToolNames.TASK_STOP,
|
||||
ToolNames.READ_FILE,
|
||||
ToolNames.WRITE_FILE,
|
||||
ToolNames.EDIT,
|
||||
]),
|
||||
);
|
||||
const ordinary = await loadCliConfig(
|
||||
{ shellExecutionSandbox: policy } as Settings,
|
||||
argv,
|
||||
policy.workspace,
|
||||
[],
|
||||
);
|
||||
expect(ordinary.getShellExecutionSandbox()).toBeUndefined();
|
||||
const rejectedModes: Array<[string, Partial<CliArgs>]> = [
|
||||
['interactive frontend', { bare: false }],
|
||||
['missing prompt', { prompt: undefined }],
|
||||
['prompt-interactive frontend', { promptInteractive: 'fixture' }],
|
||||
['stream-json frontend', { inputFormat: 'stream-json' }],
|
||||
['worktree startup', { worktree: 'review' }],
|
||||
['additional context directories', { includeDirectories: ['/outside'] }],
|
||||
];
|
||||
for (const [, overrides] of rejectedModes) {
|
||||
await expect(
|
||||
loadCliConfig(
|
||||
{},
|
||||
{ ...argv, ...overrides },
|
||||
policy.workspace,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
{ shellExecutionSandbox: policy },
|
||||
),
|
||||
).rejects.toThrow('requires bare noninteractive mode');
|
||||
}
|
||||
vi.stubEnv('QWEN_AGENT_EXECUTION_BACKEND', 'docker');
|
||||
await expect(
|
||||
loadCliConfig(
|
||||
{},
|
||||
argv,
|
||||
policy.workspace,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
{ shellExecutionSandbox: policy },
|
||||
),
|
||||
).rejects.toThrow('agent execution environments');
|
||||
});
|
||||
|
||||
it('should ignore coreTools overrides in bare mode', async () => {
|
||||
process.argv = ['node', 'script.js', '--bare', '--core-tools', 'web_fetch'];
|
||||
const argv = await parseArguments();
|
||||
|
||||
@@ -1622,6 +1622,7 @@ export async function loadCliConfig(
|
||||
*/
|
||||
hostPolicy?: {
|
||||
toolInvocationGuard?: ToolInvocationGuard;
|
||||
shellExecutionSandbox?: ConfigParameters['shellExecutionSandbox'];
|
||||
/** Host-managed session whose exact private cwd is bound after bootstrap. */
|
||||
provisionalWorkspace?: true;
|
||||
sessionRestore?: {
|
||||
@@ -1639,6 +1640,42 @@ export async function loadCliConfig(
|
||||
process.env['QWEN_DEBUG_LOG_FILE'] = '1';
|
||||
}
|
||||
const bareMode = isBareMode(argv.bare);
|
||||
const requestedShellExecutionSandbox = hostPolicy?.shellExecutionSandbox;
|
||||
const shellExecutionSandbox = requestedShellExecutionSandbox
|
||||
? {
|
||||
...requestedShellExecutionSandbox,
|
||||
maskedPaths: [
|
||||
...(requestedShellExecutionSandbox.maskedPaths ?? []),
|
||||
path.join(
|
||||
requestedShellExecutionSandbox.workspace,
|
||||
'.qwen',
|
||||
'review-leases',
|
||||
),
|
||||
],
|
||||
}
|
||||
: undefined;
|
||||
const sandboxEnabled = Boolean(shellExecutionSandbox);
|
||||
if (
|
||||
sandboxEnabled &&
|
||||
(!bareMode ||
|
||||
!argv.prompt ||
|
||||
argv.promptInteractive !== undefined ||
|
||||
argv.inputFormat === 'stream-json' ||
|
||||
argv.acp ||
|
||||
argv.experimentalAcp ||
|
||||
argv.worktree !== undefined ||
|
||||
argv.experimentalLsp ||
|
||||
argv.mcpConfig ||
|
||||
argv.extensions?.length ||
|
||||
argv.includeDirectories?.length ||
|
||||
overrideExtensions?.length ||
|
||||
Object.keys(sessionMcpServers ?? {}).length ||
|
||||
provisionalWorkspace)
|
||||
) {
|
||||
throw new Error(
|
||||
'The internal tool execution sandbox requires bare noninteractive mode without ACP, worktrees, LSP, MCP, extensions or provisional workspaces.',
|
||||
);
|
||||
}
|
||||
const safeMode =
|
||||
argv.safeMode !== undefined ? argv.safeMode : isSafeModeEnv();
|
||||
|
||||
@@ -1677,8 +1714,7 @@ export async function loadCliConfig(
|
||||
if (!Storage.hasRuntimeBaseDirContext()) {
|
||||
Storage.setRuntimeBaseDir(settings.advanced?.runtimeOutputDir, cwd);
|
||||
}
|
||||
|
||||
const ideMode = settings.ide?.enabled ?? false;
|
||||
const ideMode = !sandboxEnabled && (settings.ide?.enabled ?? false);
|
||||
|
||||
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
|
||||
const trustedFolder = isWorkspaceTrusted(settings).isTrusted === true;
|
||||
@@ -2117,6 +2153,11 @@ export async function loadCliConfig(
|
||||
bareMode || safeMode ? ({} as Settings) : settings,
|
||||
argv,
|
||||
);
|
||||
if (shellExecutionSandbox && sandboxConfig) {
|
||||
throw new Error(
|
||||
'Tool execution sandbox cannot be combined with a whole-CLI sandbox.',
|
||||
);
|
||||
}
|
||||
const screenReader =
|
||||
argv.screenReader !== undefined
|
||||
? argv.screenReader
|
||||
@@ -2372,6 +2413,7 @@ export async function loadCliConfig(
|
||||
bareMode || safeMode ? undefined : settings.permissions?.autoMode,
|
||||
},
|
||||
toolInvocationGuard: hostPolicy?.toolInvocationGuard,
|
||||
shellExecutionSandbox,
|
||||
// Permission rule persistence callback (writes to settings files).
|
||||
onPersistPermissionRule: async (scope, ruleType, rule) => {
|
||||
const currentSettings = loadSettings(cwd);
|
||||
|
||||
@@ -70,7 +70,11 @@ import type { ControlService } from './nonInteractive/control/ControlService.js'
|
||||
import { CommandKind, type ExecutionMode } from './ui/commands/types.js';
|
||||
import { goalCommand } from './ui/commands/goalCommand.js';
|
||||
import { filterCommandsForMode } from './services/commandUtils.js';
|
||||
import { _resetCleanupFunctionsForTest } from './utils/cleanup.js';
|
||||
import {
|
||||
_resetCleanupFunctionsForTest,
|
||||
runExitCleanup,
|
||||
} from './utils/cleanup.js';
|
||||
import { cleanupReviewWorktreeLeases } from './services/review-worktree-lease.js';
|
||||
import {
|
||||
AlreadyReportedError,
|
||||
_resetExitLatchForTest,
|
||||
@@ -84,6 +88,9 @@ const getActiveInteractionSpanSpy = vi.hoisted(() => vi.fn());
|
||||
const addAgentOutputMessageAttributesSpy = vi.hoisted(() => vi.fn());
|
||||
const interactionSpan = vi.hoisted(() => ({}));
|
||||
vi.mock('./ui/hooks/atCommandProcessor.js');
|
||||
vi.mock('./services/review-worktree-lease.js', () => ({
|
||||
cleanupReviewWorktreeLeases: vi.fn(),
|
||||
}));
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
@@ -498,6 +505,48 @@ describe('runNonInteractive', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it.each([false, true])(
|
||||
'scopes review lease cleanup to the ordinary runtime (sandbox=%s)',
|
||||
async (sandbox) => {
|
||||
setupMetricsMock();
|
||||
mockConfig.getShellExecutionSandbox = vi.fn().mockReturnValue(
|
||||
sandbox
|
||||
? {
|
||||
workspace: '/test/project',
|
||||
installation: '/test/installation',
|
||||
state: '/test/state',
|
||||
filesystem: 'workspace-write',
|
||||
network: 'closed',
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
vi.mocked(cleanupReviewWorktreeLeases).mockClear();
|
||||
mockLlmClient.sendMessageStream.mockImplementation(async function* () {
|
||||
await runExitCleanup();
|
||||
yield { type: LlmEventType.Content, value: 'Done' };
|
||||
});
|
||||
|
||||
await runNonInteractive(mockConfig, mockSettings, 'Hello', 'test-prompt');
|
||||
|
||||
if (sandbox) {
|
||||
expect(cleanupReviewWorktreeLeases).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(cleanupReviewWorktreeLeases).toHaveBeenCalledWith({
|
||||
sessionId: 'test-session-id',
|
||||
promptId: 'test-prompt',
|
||||
repositoryRoot: '/test/project',
|
||||
gitTimeout: 1_000,
|
||||
});
|
||||
expect(cleanupReviewWorktreeLeases).toHaveBeenCalledWith({
|
||||
sessionId: 'test-session-id',
|
||||
promptId: 'test-prompt',
|
||||
repositoryRoot: '/test/project',
|
||||
gitTimeout: undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
type GoalControlCase = {
|
||||
name: string;
|
||||
input: string;
|
||||
|
||||
@@ -593,13 +593,16 @@ export async function runNonInteractive(
|
||||
// Get readonly values once at the start
|
||||
const sessionId = config.getSessionId();
|
||||
const permissionMode = config.getApprovalMode() as PermissionMode;
|
||||
const cleanupReviewWorktrees = (gitTimeout?: number) =>
|
||||
const cleanupReviewWorktrees = (gitTimeout?: number) => {
|
||||
// Review leases live in the tool-writable workspace in this mode.
|
||||
if (config.getShellExecutionSandbox?.()) return;
|
||||
cleanupReviewWorktreeLeases({
|
||||
sessionId,
|
||||
promptId: prompt_id,
|
||||
repositoryRoot: config.getProjectRoot(),
|
||||
gitTimeout,
|
||||
});
|
||||
};
|
||||
const unregisterReviewWorktreeCleanup = registerCleanup(() =>
|
||||
cleanupReviewWorktrees(1_000),
|
||||
);
|
||||
|
||||
@@ -133,6 +133,7 @@ import {
|
||||
type Extension,
|
||||
} from '../extension/extensionManager.js';
|
||||
import { SkillManager } from '../skills/skill-manager.js';
|
||||
import * as sandboxPolicy from '../sandbox/runtime-shell-policy.js';
|
||||
import type { SkillConfig } from '../skills/types.js';
|
||||
import { createSkillScopedAgentConfig } from '../memory/skillReviewAgentPlanner.js';
|
||||
import { maybeRunAutoSkillCurator } from '../skills/skill-curator.js';
|
||||
@@ -2982,7 +2983,209 @@ describe('Server Config (config.ts)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('tool sandbox initialization', () => {
|
||||
const parameters = () => ({
|
||||
...baseParams,
|
||||
sandbox: undefined,
|
||||
cwd: TARGET_DIR,
|
||||
interactive: true,
|
||||
bareMode: false,
|
||||
enableAutoSkill: true,
|
||||
shellExecutionSandbox: {
|
||||
workspace: path.resolve(TARGET_DIR),
|
||||
installation: path.resolve('/installation'),
|
||||
state: path.resolve('/sandbox-state'),
|
||||
filesystem: 'workspace-write' as const,
|
||||
network: 'closed' as const,
|
||||
},
|
||||
});
|
||||
|
||||
it('stops before recording, hooks, skills and registry setup when probing fails', async () => {
|
||||
const probe = vi
|
||||
.spyOn(sandboxPolicy, 'probeShellSandbox')
|
||||
.mockRejectedValue(new Error('probe unavailable'));
|
||||
try {
|
||||
const config = new Config(parameters());
|
||||
const record = vi.spyOn(
|
||||
config as unknown as { activateChatRecording: () => unknown },
|
||||
'activateChatRecording',
|
||||
);
|
||||
await expect(config.initialize()).rejects.toThrow('probe unavailable');
|
||||
expect(record).not.toHaveBeenCalled();
|
||||
expect(HookSystem).not.toHaveBeenCalled();
|
||||
expect(SkillManager.prototype.startWatching).not.toHaveBeenCalled();
|
||||
expect(ToolRegistry.prototype.registerFactory).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
probe.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps pure skill reads and registers only admitted tools after a successful probe', async () => {
|
||||
const probe = vi
|
||||
.spyOn(sandboxPolicy, 'probeShellSandbox')
|
||||
.mockResolvedValue();
|
||||
try {
|
||||
const config = new Config(parameters());
|
||||
const refreshExtensions = vi.spyOn(
|
||||
config.getExtensionManager(),
|
||||
'refreshCache',
|
||||
);
|
||||
await config.initialize();
|
||||
expect(probe).toHaveBeenCalledWith(
|
||||
config.getShellExecutionSandbox(),
|
||||
undefined,
|
||||
);
|
||||
expect(HookSystem).not.toHaveBeenCalled();
|
||||
expect(maybeRunAutoSkillCurator).not.toHaveBeenCalled();
|
||||
expect(refreshExtensions).not.toHaveBeenCalled();
|
||||
expect(SkillManager.prototype.startWatching).not.toHaveBeenCalled();
|
||||
expect(SkillManager.prototype.refreshCache).toHaveBeenCalled();
|
||||
expect(
|
||||
(ToolRegistry.prototype.registerFactory as Mock).mock.calls.map(
|
||||
(call) => call[0],
|
||||
),
|
||||
).toEqual([
|
||||
ToolNames.SHELL,
|
||||
ToolNames.TASK_STOP,
|
||||
ToolNames.READ_FILE,
|
||||
ToolNames.WRITE_FILE,
|
||||
ToolNames.EDIT,
|
||||
ToolNames.MONITOR,
|
||||
ToolNames.AGENT,
|
||||
ToolNames.GLOB,
|
||||
ToolNames.LS,
|
||||
ToolNames.ASK_USER_QUESTION,
|
||||
]);
|
||||
expect(ToolRegistry.prototype.discoverAllTools).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
probe.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('omits user-interaction tools from the admitted headless registry', async () => {
|
||||
const probe = vi
|
||||
.spyOn(sandboxPolicy, 'probeShellSandbox')
|
||||
.mockResolvedValue();
|
||||
try {
|
||||
const config = new Config({
|
||||
...parameters(),
|
||||
interactive: false,
|
||||
bareMode: true,
|
||||
});
|
||||
await config.initialize();
|
||||
expect(
|
||||
(ToolRegistry.prototype.registerFactory as Mock).mock.calls.map(
|
||||
(call) => call[0],
|
||||
),
|
||||
).toEqual([
|
||||
ToolNames.SHELL,
|
||||
ToolNames.TASK_STOP,
|
||||
ToolNames.READ_FILE,
|
||||
ToolNames.WRITE_FILE,
|
||||
ToolNames.EDIT,
|
||||
ToolNames.MONITOR,
|
||||
ToolNames.AGENT,
|
||||
ToolNames.GLOB,
|
||||
ToolNames.LS,
|
||||
]);
|
||||
} finally {
|
||||
probe.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('derived Config ownership', () => {
|
||||
it('preserves the shell sandbox ceiling and rejects relocation before state mutation', async () => {
|
||||
const policy = {
|
||||
workspace: path.resolve(TARGET_DIR),
|
||||
installation: path.resolve('/installation'),
|
||||
state: path.resolve('/sandbox-state'),
|
||||
filesystem: 'workspace-write' as const,
|
||||
network: 'closed' as 'closed' | 'open',
|
||||
};
|
||||
const parent = new Config({
|
||||
...baseParams,
|
||||
sandbox: undefined,
|
||||
cwd: TARGET_DIR,
|
||||
bareMode: false,
|
||||
interactive: true,
|
||||
fileCheckpointingEnabled: true,
|
||||
ideMode: true,
|
||||
enableManagedAutoMemory: true,
|
||||
enableManagedAutoDream: true,
|
||||
enableTeamMemory: true,
|
||||
enableTeamMemorySync: true,
|
||||
agentTeamEnabled: true,
|
||||
workflowsEnabled: true,
|
||||
sessionWorkflowEnabled: true,
|
||||
cronEnabled: true,
|
||||
shellExecutionSandbox: policy,
|
||||
});
|
||||
const snapshot = parent.getShellExecutionSandbox();
|
||||
policy.network = 'open';
|
||||
expect(snapshot?.network).toBe('closed');
|
||||
expect(Object.isFrozen(snapshot)).toBe(true);
|
||||
expect(parent.getCoreTools()).toEqual([
|
||||
ToolNames.SHELL,
|
||||
ToolNames.TASK_STOP,
|
||||
ToolNames.READ_FILE,
|
||||
ToolNames.WRITE_FILE,
|
||||
ToolNames.EDIT,
|
||||
ToolNames.MONITOR,
|
||||
ToolNames.AGENT,
|
||||
ToolNames.EXEC,
|
||||
ToolNames.GLOB,
|
||||
ToolNames.LS,
|
||||
ToolNames.ASK_USER_QUESTION,
|
||||
ToolNames.STRUCTURED_OUTPUT,
|
||||
]);
|
||||
expect(parent.getBareMode()).toBe(false);
|
||||
expect(parent.getIdeMode()).toBe(false);
|
||||
expect(() => parent.setIdeMode(true)).toThrow('does not support IDE');
|
||||
expect(parent.getDisableAllHooks()).toBe(true);
|
||||
expect(parent.getHookSystem()).toBeUndefined();
|
||||
expect(parent.getManagedAutoMemoryEnabled()).toBe(false);
|
||||
expect(parent.isManagedMemoryAvailable()).toBe(false);
|
||||
expect(parent.getManagedAutoDreamEnabled()).toBe(false);
|
||||
expect(parent.getTeamMemoryEnabled()).toBe(false);
|
||||
expect(parent.getTeamMemorySyncEnabled()).toBe(false);
|
||||
expect(parent.getAutoSkillEnabled()).toBe(false);
|
||||
expect(parent.isAgentTeamEnabled()).toBe(false);
|
||||
expect(parent.isWorkflowsEnabled()).toBe(false);
|
||||
expect(parent.isSessionWorkflowEnabled()).toBe(false);
|
||||
expect(parent.isCronEnabled()).toBe(false);
|
||||
expect(parent.isLspEnabled()).toBe(false);
|
||||
expect(parent.getFileCheckpointingEnabled()).toBe(false);
|
||||
expect(() => parent.enableFileCheckpointing()).toThrow('unavailable');
|
||||
expect(() =>
|
||||
parent.addMcpServers({ remote: { command: 'node' } }),
|
||||
).toThrow('does not support MCP');
|
||||
expect(() =>
|
||||
parent.addRuntimeMcpServer('remote', { command: 'node' }),
|
||||
).toThrow('does not support MCP');
|
||||
await expect(
|
||||
parent.reinitializeMcpServers({ remote: { command: 'node' } }),
|
||||
).rejects.toThrow('does not support MCP');
|
||||
expect(parent.getMcpServers()).toEqual({});
|
||||
expect(parent.getExtensions()).toEqual([]);
|
||||
const fileService = parent.getFileSystemService();
|
||||
expect(() => parent.setFileSystemService(fileService)).toThrow(
|
||||
'delegated filesystem',
|
||||
);
|
||||
expect(parent.getFileSystemService()).toBe(fileService);
|
||||
const child = deriveWorktreeConfig(
|
||||
parent,
|
||||
path.join(TARGET_DIR, 'child'),
|
||||
);
|
||||
expect(child.getShellExecutionSandbox()).toBe(snapshot);
|
||||
expect(() => deriveWorktreeConfig(parent, '/other')).toThrow(
|
||||
'admitted workspace',
|
||||
);
|
||||
await expect(parent.relocateWorkingDirectory('/other')).rejects.toThrow(
|
||||
'admitted workspace',
|
||||
);
|
||||
expect(parent.getTargetDir()).toBe(path.resolve(TARGET_DIR));
|
||||
});
|
||||
it('keeps session approval independent of nested agent and worktree modes', () => {
|
||||
const parent = new Config({
|
||||
...baseParams,
|
||||
|
||||
@@ -239,6 +239,12 @@ import type { GoalRecoveryRecord } from '../goals/goal-persistence.js';
|
||||
import { GOAL_DEFAULT_TOKEN_BUDGET } from '../goals/goal-protocol.js';
|
||||
import { createGoalVerifier } from '../goals/goal-verifier.js';
|
||||
import type { ToolInvocationGuard } from '../core/tool-invocation-guard.js';
|
||||
import type { BwrapPolicy } from '../sandbox/bwrap-execution.js';
|
||||
import {
|
||||
admitShellSandbox,
|
||||
probeShellSandbox,
|
||||
assertShellSandboxCwd,
|
||||
} from '../sandbox/runtime-shell-policy.js';
|
||||
|
||||
// Utils
|
||||
import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
|
||||
@@ -1075,6 +1081,8 @@ export interface ConfigParameters {
|
||||
* before execution. A configured guard fails closed.
|
||||
*/
|
||||
toolInvocationGuard?: ToolInvocationGuard;
|
||||
/** Internal trusted-host integration; never loaded from workspace settings. */
|
||||
shellExecutionSandbox?: Readonly<BwrapPolicy>;
|
||||
toolDiscoveryCommand?: string;
|
||||
toolCallCommand?: string;
|
||||
mcpServerCommand?: string;
|
||||
@@ -2501,6 +2509,17 @@ export function deriveConfig(
|
||||
base: Config,
|
||||
overrides: DerivedConfigOverrides = {},
|
||||
): Config {
|
||||
const shellSandbox = base.getShellExecutionSandbox?.();
|
||||
if (shellSandbox) {
|
||||
for (const key of [
|
||||
'getTargetDir',
|
||||
'getCwd',
|
||||
'getWorkingDir',
|
||||
'getProjectRoot',
|
||||
] as const) {
|
||||
assertShellSandboxCwd(shellSandbox, overrides[key]?.() ?? base[key]());
|
||||
}
|
||||
}
|
||||
const derived = Object.create(base) as Config;
|
||||
for (const key in overrides) {
|
||||
if (!Object.hasOwn(overrides, key)) continue;
|
||||
@@ -2519,6 +2538,7 @@ export function deriveConfig(
|
||||
}
|
||||
|
||||
export class Config {
|
||||
private readonly shellExecutionSandbox: Readonly<BwrapPolicy> | undefined;
|
||||
private sessionId: string;
|
||||
private sessionSourceType?: string;
|
||||
private sessionSourceId?: string;
|
||||
@@ -3043,6 +3063,11 @@ export class Config {
|
||||
)
|
||||
: undefined;
|
||||
this.sessionRuntimeBaseDir = Storage.getRuntimeBaseDir();
|
||||
this.shellExecutionSandbox = admitShellSandbox(
|
||||
params,
|
||||
this.sessionRuntimeBaseDir,
|
||||
Storage.getGlobalQwenDir(),
|
||||
);
|
||||
this.provisionalWorkspace = params.provisionalWorkspace === true;
|
||||
this.sessionId = params.sessionId ?? randomUUID();
|
||||
// Only set the global env marker once per process lifetime, so
|
||||
@@ -3720,6 +3745,8 @@ export class Config {
|
||||
options?: ConfigInitializeOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (this.shellExecutionSandbox)
|
||||
await probeShellSandbox(this.shellExecutionSandbox, options?.signal);
|
||||
const activation = this.activateChatRecording();
|
||||
this.sessionWriterActivationPromise = activation;
|
||||
try {
|
||||
@@ -3772,7 +3799,6 @@ export class Config {
|
||||
this.debugLogger.info('Config initialization started');
|
||||
await this.proxyDispatcherReady;
|
||||
options?.signal?.throwIfAborted();
|
||||
|
||||
// Omni multimodal support declares ffmpeg/ffprobe as hard runtime
|
||||
// prerequisites: fail fast at startup with an actionable message
|
||||
// instead of erroring midway through the first video interaction.
|
||||
@@ -3791,7 +3817,7 @@ export class Config {
|
||||
await assertOmniRuntimeDependencies();
|
||||
}
|
||||
|
||||
if (options?.skipFileCheckpointing === true) {
|
||||
if (options?.skipFileCheckpointing === true || this.shellExecutionSandbox) {
|
||||
this.fileCheckpointingEnabled = false;
|
||||
this.fileHistoryService = undefined;
|
||||
}
|
||||
@@ -3811,9 +3837,17 @@ export class Config {
|
||||
(n) => n.trim() !== '' && n.toLowerCase() !== 'none',
|
||||
);
|
||||
recordStartupEvent('config_initialize_extensions_initial_start');
|
||||
if (!this.isSafeMode() && !this.getBareMode()) {
|
||||
if (
|
||||
!this.shellExecutionSandbox &&
|
||||
!this.isSafeMode() &&
|
||||
!this.getBareMode()
|
||||
) {
|
||||
await this.extensionManager.refreshCache();
|
||||
} else if (!this.isSafeMode() && explicitExtensionNames.length > 0) {
|
||||
} else if (
|
||||
!this.shellExecutionSandbox &&
|
||||
!this.isSafeMode() &&
|
||||
explicitExtensionNames.length > 0
|
||||
) {
|
||||
await this.extensionManager.refreshCache({
|
||||
names: explicitExtensionNames,
|
||||
});
|
||||
@@ -4175,7 +4209,11 @@ export class Config {
|
||||
}
|
||||
}
|
||||
this.skillManager = new SkillManager(this);
|
||||
if (this.getBareMode() || this.isSafeMode()) {
|
||||
if (
|
||||
this.shellExecutionSandbox ||
|
||||
this.getBareMode() ||
|
||||
this.isSafeMode()
|
||||
) {
|
||||
await this.skillManager.refreshCache();
|
||||
} else {
|
||||
await this.skillManager.startWatching();
|
||||
@@ -4242,7 +4280,11 @@ export class Config {
|
||||
}
|
||||
|
||||
recordStartupEvent('config_initialize_extensions_final_start');
|
||||
if (!this.getBareMode() && !this.isSafeMode()) {
|
||||
if (
|
||||
!this.shellExecutionSandbox &&
|
||||
!this.getBareMode() &&
|
||||
!this.isSafeMode()
|
||||
) {
|
||||
await this.extensionManager.refreshCache();
|
||||
}
|
||||
recordStartupEvent('config_initialize_extensions_final_end');
|
||||
@@ -4348,6 +4390,7 @@ export class Config {
|
||||
// also respects the `allowedMcpServers` filter already applied there.
|
||||
const hasMcpServers = Object.keys(this.getMcpServers() ?? {}).length > 0;
|
||||
if (
|
||||
!this.shellExecutionSandbox &&
|
||||
skipInlineMcpDiscovery &&
|
||||
(!(this.getBareMode() || this.isSafeMode()) || hasMcpServers) &&
|
||||
!this.provisionalWorkspace &&
|
||||
@@ -4377,7 +4420,11 @@ export class Config {
|
||||
// directly would cause launches from a monorepo subdirectory to
|
||||
// scan `<subdir>/.qwen/worktrees/` — which never exists — and the
|
||||
// sweep would silently be a no-op forever.
|
||||
if (!this.getBareMode() && !this.provisionalWorkspace) {
|
||||
if (
|
||||
!this.shellExecutionSandbox &&
|
||||
!this.getBareMode() &&
|
||||
!this.provisionalWorkspace
|
||||
) {
|
||||
void (async () => {
|
||||
try {
|
||||
// Resolve the repo top-level FIRST. The previous code bailed
|
||||
@@ -6771,6 +6818,9 @@ export class Config {
|
||||
memoryRefreshError?: unknown;
|
||||
mcpRefreshError?: unknown;
|
||||
}> {
|
||||
if (this.shellExecutionSandbox) {
|
||||
assertShellSandboxCwd(this.shellExecutionSandbox, path.resolve(newDir));
|
||||
}
|
||||
if (isDerivedConfig(this)) {
|
||||
throw new Error('Derived Configs cannot relocate working directories');
|
||||
}
|
||||
@@ -7137,6 +7187,21 @@ export class Config {
|
||||
|
||||
/** @deprecated Use getPermissionsAllow() instead. */
|
||||
getCoreTools(): string[] | undefined {
|
||||
if (this.shellExecutionSandbox)
|
||||
return [
|
||||
ToolNames.SHELL,
|
||||
ToolNames.TASK_STOP,
|
||||
ToolNames.READ_FILE,
|
||||
ToolNames.WRITE_FILE,
|
||||
ToolNames.EDIT,
|
||||
ToolNames.MONITOR,
|
||||
ToolNames.AGENT,
|
||||
ToolNames.EXEC,
|
||||
ToolNames.GLOB,
|
||||
ToolNames.LS,
|
||||
ToolNames.ASK_USER_QUESTION,
|
||||
ToolNames.STRUCTURED_OUTPUT,
|
||||
];
|
||||
if (this.getBareMode()) {
|
||||
return DEFAULT_BARE_CORE_TOOLS;
|
||||
}
|
||||
@@ -7460,6 +7525,7 @@ export class Config {
|
||||
}
|
||||
|
||||
getMcpServers(): Record<string, MCPServerConfig> | undefined {
|
||||
if (this.shellExecutionSandbox) return {};
|
||||
// Safe mode distrusts LOCAL/ambient state (settings.json, extensions,
|
||||
// project `.mcp.json`) — not the caller's own explicit, per-invocation
|
||||
// request. `topTierMcpServers` (ACP `session/new`'s `mcpServers` field,
|
||||
@@ -7548,6 +7614,8 @@ export class Config {
|
||||
}
|
||||
|
||||
addMcpServers(servers: Record<string, MCPServerConfig>): void {
|
||||
if (this.shellExecutionSandbox && Object.keys(servers).length)
|
||||
throw new Error('Tool execution sandbox does not support MCP servers.');
|
||||
if (this.initialized) {
|
||||
throw new Error('Cannot modify mcpServers after initialization');
|
||||
}
|
||||
@@ -7562,6 +7630,8 @@ export class Config {
|
||||
* {@link getMcpServers} still layers them on top. See sub-task 3.
|
||||
*/
|
||||
setMcpServers(servers: Record<string, MCPServerConfig> | undefined): void {
|
||||
if (this.shellExecutionSandbox && Object.keys(servers ?? {}).length)
|
||||
throw new Error('Tool execution sandbox does not support MCP servers.');
|
||||
this.mcpServers = servers;
|
||||
}
|
||||
|
||||
@@ -7703,6 +7773,7 @@ export class Config {
|
||||
}
|
||||
|
||||
private async refreshMcpServers(): Promise<void> {
|
||||
if (this.shellExecutionSandbox) return;
|
||||
if (!this.initialized) {
|
||||
// No tool registry yet — boot-time discovery will pick up the new map.
|
||||
this.debugLogger.debug(
|
||||
@@ -7776,6 +7847,8 @@ export class Config {
|
||||
* of the settings layer (Task 5).
|
||||
*/
|
||||
addRuntimeMcpServer(name: string, config: MCPServerConfig): void {
|
||||
if (this.shellExecutionSandbox)
|
||||
throw new Error('Tool execution sandbox does not support MCP servers.');
|
||||
this.runtimeMcpServers.set(name, config);
|
||||
}
|
||||
|
||||
@@ -7806,7 +7879,12 @@ export class Config {
|
||||
}
|
||||
|
||||
isLspEnabled(): boolean {
|
||||
return this.lspEnabled && !this.getBareMode() && !this.provisionalWorkspace;
|
||||
return (
|
||||
this.lspEnabled &&
|
||||
!this.shellExecutionSandbox &&
|
||||
!this.getBareMode() &&
|
||||
!this.provisionalWorkspace
|
||||
);
|
||||
}
|
||||
|
||||
getLspClient(): LspClient | undefined {
|
||||
@@ -7861,6 +7939,8 @@ export class Config {
|
||||
* Allows wiring an LSP client after Config construction but before initialize().
|
||||
*/
|
||||
setLspClient(client: LspClient | undefined): void {
|
||||
if (this.shellExecutionSandbox && client)
|
||||
throw new Error('Tool execution sandbox does not support LSP.');
|
||||
if (this.initialized) {
|
||||
throw new Error('Cannot set LSP client after initialization');
|
||||
}
|
||||
@@ -8006,6 +8086,8 @@ export class Config {
|
||||
}
|
||||
|
||||
setArenaManager(manager: ArenaManager | null): void {
|
||||
if (this.shellExecutionSandbox && manager)
|
||||
throw new Error('Tool execution sandbox does not support agent arenas.');
|
||||
this.arenaManager = manager;
|
||||
this.arenaManagerChangeCallback?.(manager);
|
||||
}
|
||||
@@ -8035,6 +8117,8 @@ export class Config {
|
||||
}
|
||||
|
||||
setTeamManager(manager: TeamManager | null): void {
|
||||
if (this.shellExecutionSandbox && manager)
|
||||
throw new Error('Tool execution sandbox does not support agent teams.');
|
||||
this.teamManager = manager;
|
||||
for (const cb of this.teamManagerChangeCallbacks) {
|
||||
cb(manager);
|
||||
@@ -8868,6 +8952,7 @@ export class Config {
|
||||
}
|
||||
|
||||
isCronEnabled(): boolean {
|
||||
if (this.shellExecutionSandbox) return false;
|
||||
if (process.env['QWEN_CODE_DISABLE_CRON'] === '1') return false;
|
||||
return this.cronEnabled;
|
||||
}
|
||||
@@ -8897,12 +8982,14 @@ export class Config {
|
||||
}
|
||||
|
||||
isAgentTeamEnabled(): boolean {
|
||||
if (this.shellExecutionSandbox) return false;
|
||||
// Agent team is experimental and opt-in: enabled via settings or env var
|
||||
if (process.env['QWEN_CODE_ENABLE_AGENT_TEAM'] === '1') return true;
|
||||
return this.agentTeamEnabled;
|
||||
}
|
||||
|
||||
isArtifactEnabled(): boolean {
|
||||
if (this.shellExecutionSandbox) return false;
|
||||
// Publishing writes outside the project and opens a browser, so it is
|
||||
// limited to interactive or managed preview sessions, excluding SDK use.
|
||||
// Managed previews render in Web Shell instead of opening a host browser.
|
||||
@@ -9121,6 +9208,7 @@ export class Config {
|
||||
}
|
||||
|
||||
isImageGenerationEnabled(): boolean {
|
||||
if (this.shellExecutionSandbox) return false;
|
||||
return this.getImageGenerationConfig() !== undefined;
|
||||
}
|
||||
|
||||
@@ -9131,6 +9219,7 @@ export class Config {
|
||||
}
|
||||
|
||||
isWorkflowsEnabled(): boolean {
|
||||
if (this.shellExecutionSandbox) return false;
|
||||
if (this.provisionalWorkspace) return false;
|
||||
// Workflows are opt-in via settings, env, or the bundled review skill.
|
||||
// P1 also honors a kill switch: QWEN_CODE_DISABLE_WORKFLOWS=1 forces off
|
||||
@@ -9145,6 +9234,7 @@ export class Config {
|
||||
|
||||
async enableReviewWorkflow(): Promise<void> {
|
||||
if (
|
||||
this.shellExecutionSandbox ||
|
||||
this.workflowsEnabled === false ||
|
||||
!isTopLevelSession() ||
|
||||
this.getBareMode() ||
|
||||
@@ -9173,6 +9263,7 @@ export class Config {
|
||||
* destroying it.
|
||||
*/
|
||||
isSessionWorkflowEnabled(): boolean {
|
||||
if (this.shellExecutionSandbox) return false;
|
||||
return (
|
||||
this.sessionWorkflowEnabledProvider?.() ?? this.sessionWorkflowEnabled
|
||||
);
|
||||
@@ -9510,10 +9601,14 @@ export class Config {
|
||||
}
|
||||
|
||||
getFileCheckpointingEnabled(): boolean {
|
||||
return this.fileCheckpointingEnabled;
|
||||
return !this.shellExecutionSandbox && this.fileCheckpointingEnabled;
|
||||
}
|
||||
|
||||
enableFileCheckpointing(): void {
|
||||
if (this.shellExecutionSandbox)
|
||||
throw new Error(
|
||||
'File checkpointing is unavailable with tools.executionSandbox.',
|
||||
);
|
||||
this.fileCheckpointingEnabled = true;
|
||||
this.fileHistoryService = undefined;
|
||||
}
|
||||
@@ -9522,7 +9617,7 @@ export class Config {
|
||||
if (!this.fileHistoryService) {
|
||||
const service = new FileHistoryService(
|
||||
this.sessionId,
|
||||
this.fileCheckpointingEnabled,
|
||||
this.getFileCheckpointingEnabled(),
|
||||
this.cwd,
|
||||
(snapshot) => {
|
||||
if (this.fileHistoryService !== service) return;
|
||||
@@ -9631,6 +9726,7 @@ export class Config {
|
||||
* Returns undefined if hooks are not enabled.
|
||||
*/
|
||||
getHookSystem(): HookSystem | undefined {
|
||||
if (this.shellExecutionSandbox) return undefined;
|
||||
return this.hookSystem;
|
||||
}
|
||||
|
||||
@@ -9652,6 +9748,7 @@ export class Config {
|
||||
* Check if all hooks are disabled.
|
||||
*/
|
||||
getDisableAllHooks(): boolean {
|
||||
if (this.shellExecutionSandbox) return true;
|
||||
return this.disableAllHooks || this.getBareMode() || this.isSafeMode();
|
||||
}
|
||||
|
||||
@@ -9660,6 +9757,7 @@ export class Config {
|
||||
}
|
||||
|
||||
getManagedAutoMemoryEnabled(): boolean {
|
||||
if (this.shellExecutionSandbox) return false;
|
||||
return (
|
||||
this.enableManagedAutoMemory && !this.getBareMode() && !this.isSafeMode()
|
||||
);
|
||||
@@ -9671,6 +9769,7 @@ export class Config {
|
||||
* for tests / power users ('0' forces off, '1' forces on).
|
||||
*/
|
||||
getTeamMemoryEnabled(): boolean {
|
||||
if (this.shellExecutionSandbox) return false;
|
||||
if (this.getBareMode() || this.provisionalWorkspace) {
|
||||
return false;
|
||||
}
|
||||
@@ -9691,6 +9790,7 @@ export class Config {
|
||||
* Off by default since it mutates the repo and pushes. Inert in bare mode.
|
||||
*/
|
||||
getTeamMemorySyncEnabled(): boolean {
|
||||
if (this.shellExecutionSandbox) return false;
|
||||
if (this.getBareMode() || this.provisionalWorkspace) {
|
||||
return false;
|
||||
}
|
||||
@@ -9705,16 +9805,19 @@ export class Config {
|
||||
}
|
||||
|
||||
isManagedMemoryAvailable(): boolean {
|
||||
if (this.shellExecutionSandbox) return false;
|
||||
return this.enableManagedAutoMemory && !this.getBareMode();
|
||||
}
|
||||
|
||||
getManagedAutoDreamEnabled(): boolean {
|
||||
if (this.shellExecutionSandbox) return false;
|
||||
return (
|
||||
this.enableManagedAutoDream && !this.getBareMode() && !this.isSafeMode()
|
||||
);
|
||||
}
|
||||
|
||||
getAutoSkillEnabled(): boolean {
|
||||
if (this.shellExecutionSandbox) return false;
|
||||
return (
|
||||
this.enableAutoSkill &&
|
||||
!this.getBareMode() &&
|
||||
@@ -9923,6 +10026,7 @@ export class Config {
|
||||
}
|
||||
|
||||
getExtensions(): Extension[] {
|
||||
if (this.shellExecutionSandbox) return [];
|
||||
const extensions = this.extensionManager.getLoadedExtensions();
|
||||
if (this.overrideExtensions) {
|
||||
const overrideExtensionNames = new Set(
|
||||
@@ -9980,7 +10084,7 @@ export class Config {
|
||||
}
|
||||
|
||||
getIdeMode(): boolean {
|
||||
return this.ideMode;
|
||||
return !this.shellExecutionSandbox && this.ideMode;
|
||||
}
|
||||
|
||||
getFolderTrustFeature(): boolean {
|
||||
@@ -10025,6 +10129,10 @@ export class Config {
|
||||
}
|
||||
|
||||
setIdeMode(value: boolean): void {
|
||||
if (this.shellExecutionSandbox && value)
|
||||
throw new Error(
|
||||
'Tool execution sandbox does not support IDE integration.',
|
||||
);
|
||||
this.ideMode = value;
|
||||
}
|
||||
|
||||
@@ -10093,6 +10201,11 @@ export class Config {
|
||||
* Set a custom FileSystemService
|
||||
*/
|
||||
setFileSystemService(fileSystemService: FileSystemService): void {
|
||||
if (this.shellExecutionSandbox) {
|
||||
throw new Error(
|
||||
'Internal sandbox does not support delegated filesystem services.',
|
||||
);
|
||||
}
|
||||
this.fileSystemService = fileSystemService;
|
||||
}
|
||||
|
||||
@@ -10921,6 +11034,10 @@ export class Config {
|
||||
return this.toolInvocationGuard;
|
||||
}
|
||||
|
||||
getShellExecutionSandbox(): Readonly<BwrapPolicy> | undefined {
|
||||
return this.shellExecutionSandbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the callback for persisting permission rules to settings files.
|
||||
* Returns undefined if no callback was provided (e.g. SDK mode).
|
||||
@@ -11172,6 +11289,56 @@ export class Config {
|
||||
});
|
||||
};
|
||||
|
||||
if (this.shellExecutionSandbox) {
|
||||
await registerLazy(ToolNames.SHELL, async () => {
|
||||
const { ShellTool } = await import('../tools/shell.js');
|
||||
return new ShellTool(this);
|
||||
});
|
||||
await registerLazy(ToolNames.TASK_STOP, async () => {
|
||||
const { TaskStopTool } = await import('../tools/task-stop.js');
|
||||
return new TaskStopTool(this);
|
||||
});
|
||||
await registerLazy(ToolNames.READ_FILE, async () => {
|
||||
const { ReadFileTool } = await import('../tools/read-file.js');
|
||||
return new ReadFileTool(this);
|
||||
});
|
||||
await registerLazy(ToolNames.WRITE_FILE, async () => {
|
||||
const { WriteFileTool } = await import('../tools/write-file.js');
|
||||
return new WriteFileTool(this);
|
||||
});
|
||||
await registerLazy(ToolNames.EDIT, async () => {
|
||||
const { EditTool } = await import('../tools/edit.js');
|
||||
return new EditTool(this);
|
||||
});
|
||||
await registerLazy(ToolNames.MONITOR, async () => {
|
||||
const { MonitorTool } = await import('../tools/monitor.js');
|
||||
return new MonitorTool(this);
|
||||
});
|
||||
await registerLazy(ToolNames.AGENT, async () => {
|
||||
const { AgentTool } = await import('../tools/agent/agent.js');
|
||||
return new AgentTool(this);
|
||||
});
|
||||
await registerLazy(ToolNames.GLOB, async () => {
|
||||
const { GlobTool } = await import('../tools/glob.js');
|
||||
return new GlobTool(this);
|
||||
});
|
||||
await registerLazy(ToolNames.LS, async () => {
|
||||
const { LSTool } = await import('../tools/ls.js');
|
||||
return new LSTool(this);
|
||||
});
|
||||
if (resolveInteractionMode(this) !== 'headless') {
|
||||
await registerLazy(ToolNames.ASK_USER_QUESTION, async () => {
|
||||
const { AskUserQuestionTool } = await import(
|
||||
'../tools/askUserQuestion.js'
|
||||
);
|
||||
return new AskUserQuestionTool(this);
|
||||
});
|
||||
}
|
||||
await registerExecIfEnabled();
|
||||
await registerStructuredOutputIfRequested();
|
||||
return registry;
|
||||
}
|
||||
|
||||
if (this.getBareMode()) {
|
||||
await registerLazy(ToolNames.READ_FILE, async () => {
|
||||
const { ReadFileTool } = await import('../tools/read-file.js');
|
||||
|
||||
@@ -3834,6 +3834,29 @@ describe('Gemini Client (client.ts)', () => {
|
||||
});
|
||||
|
||||
describe('getMainSessionSystemInstruction', () => {
|
||||
it('skips host Git snapshots throughout a sandboxed shell session', async () => {
|
||||
mockConfig.getShellExecutionSandbox = vi.fn().mockReturnValue({
|
||||
workspace: '/test/project/root',
|
||||
installation: '/test/installation',
|
||||
state: '/test/state',
|
||||
filesystem: 'workspace-write',
|
||||
network: 'closed',
|
||||
});
|
||||
vi.mocked(getRecentGitStatus).mockClear();
|
||||
vi.mocked(getRecentGitStatus).mockReturnValue('Host Git snapshot');
|
||||
|
||||
await client.startChat();
|
||||
await client.addWorkingDirectoryChangedContext(
|
||||
'/test/project/root',
|
||||
'/test/project/root/subdir',
|
||||
);
|
||||
|
||||
expect(getRecentGitStatus).not.toHaveBeenCalled();
|
||||
expect(
|
||||
client.getChat()['generationConfig'].systemInstruction,
|
||||
).not.toContain('Host Git snapshot');
|
||||
});
|
||||
|
||||
it('records the gitStatus-free base as the static system prefix on Config', () => {
|
||||
vi.mocked(getCoreSystemPrompt).mockReturnValueOnce('core base prompt');
|
||||
vi.mocked(getRecentGitStatus).mockReturnValueOnce('Git snapshot A');
|
||||
|
||||
@@ -1625,6 +1625,10 @@ export class LlmClient {
|
||||
}
|
||||
|
||||
private getCachedGitStatus(): string | null {
|
||||
if (this.config.getShellExecutionSandbox?.()) {
|
||||
// Even git status can execute repository-configured filters on the host.
|
||||
return null;
|
||||
}
|
||||
if (this.cachedGitStatus === undefined) {
|
||||
// Mirror claude-code: append git status (branch + recent commits) to the
|
||||
// system prompt so the main agent treats version history as authoritative
|
||||
|
||||
@@ -64,6 +64,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('startSpeculation', () => {
|
||||
it('rejects tool sandbox sessions before starting host speculation', async () => {
|
||||
const config = {
|
||||
getShellExecutionSandbox: () => ({}),
|
||||
} as unknown as Config;
|
||||
await expect(startSpeculation(config, 'write a.ts')).rejects.toThrow(
|
||||
'unavailable with tools.executionSandbox',
|
||||
);
|
||||
expect(forkedAgentMocks.getCacheSafeParams).not.toHaveBeenCalled();
|
||||
expect(forkedAgentMocks.runForkedAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
it('does not start when the session-scoped lookup returns null', async () => {
|
||||
const config = {
|
||||
getSessionId: vi.fn().mockReturnValue('spec-session'),
|
||||
|
||||
@@ -141,6 +141,11 @@ export async function startSpeculation(
|
||||
parentSignal?: AbortSignal,
|
||||
options?: { model?: string },
|
||||
): Promise<SpeculationState> {
|
||||
if (config.getShellExecutionSandbox?.()) {
|
||||
throw new Error(
|
||||
'Speculative execution is unavailable with tools.executionSandbox.',
|
||||
);
|
||||
}
|
||||
const cacheSafe = getCacheSafeParams(config.getSessionId());
|
||||
if (!cacheSafe) {
|
||||
throw new Error('CacheSafeParams not available for speculation');
|
||||
|
||||
@@ -1859,6 +1859,17 @@ function makeConfig(
|
||||
describe('PermissionManager', () => {
|
||||
let pm: PermissionManager;
|
||||
|
||||
it('does not implicitly allow read-only shell commands under internal sandbox policy', async () => {
|
||||
const manager = new PermissionManager({
|
||||
...makeConfig(),
|
||||
getShellExecutionSandbox: () => ({}),
|
||||
});
|
||||
manager.initialize();
|
||||
expect(await manager.isCommandAllowed('git status && ls', '/project')).toBe(
|
||||
'ask',
|
||||
);
|
||||
});
|
||||
|
||||
describe('basic rule evaluation', () => {
|
||||
beforeEach(() => {
|
||||
pm = new PermissionManager(
|
||||
|
||||
@@ -147,6 +147,7 @@ function splitCommandForRules(command: string, toolName: string): string[] {
|
||||
* PermissionManager therefore only needs these three getters.
|
||||
*/
|
||||
export interface PermissionManagerConfig {
|
||||
getShellExecutionSandbox?(): unknown;
|
||||
/** Merged allow-rules (settings + coreTools + allowedTools). */
|
||||
getPermissionsAllow(): string[] | undefined;
|
||||
/** Merged ask-rules (settings only). */
|
||||
@@ -687,6 +688,7 @@ export class PermissionManager {
|
||||
command: string,
|
||||
cwd?: string,
|
||||
): Promise<'allow' | 'ask'> {
|
||||
if (this.config.getShellExecutionSandbox?.()) return 'ask';
|
||||
try {
|
||||
const isReadOnly = cwd
|
||||
? await isShellCommandReadOnlyASTInDirectory(command, cwd)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -56,6 +57,8 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => {
|
||||
let state: string;
|
||||
let bwrap: string;
|
||||
let originalTmpdir: string | undefined;
|
||||
let capturedPayloadEnv: Record<string, string>;
|
||||
let capturedPayloadEnvMode: number;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -71,6 +74,8 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => {
|
||||
writeFileSync(bwrap, 'stub');
|
||||
originalTmpdir = process.env['TMPDIR'];
|
||||
delete process.env['TMPDIR'];
|
||||
capturedPayloadEnv = {};
|
||||
capturedPayloadEnvMode = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -104,6 +109,11 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => {
|
||||
.spyOn(ShellExecutionService, 'executeLaunch')
|
||||
.mockImplementation(async (launch) => {
|
||||
const statusPath = launch.args[2];
|
||||
const payloadEnvPath = launch.args[3];
|
||||
capturedPayloadEnv = JSON.parse(
|
||||
readFileSync(payloadEnvPath, 'utf8'),
|
||||
) as Record<string, string>;
|
||||
capturedPayloadEnvMode = statSync(payloadEnvPath).mode & 0o777;
|
||||
if (receipt) writeFileSync(statusPath, JSON.stringify(receipt));
|
||||
return {
|
||||
pid: 123,
|
||||
@@ -120,10 +130,11 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => {
|
||||
};
|
||||
});
|
||||
|
||||
it('builds a literal launch, filters internal secrets, and cleans confirmed resources', async () => {
|
||||
it('builds a literal launch, transports the payload env privately, and prepares writable masks', async () => {
|
||||
const maskedPath = path.join(workspace, '.qwen', 'review-leases');
|
||||
const launch = mockLaunch({ state: 'confirmed', exitCode: 0 });
|
||||
const handle = await executeBwrap(
|
||||
policy(),
|
||||
{ ...policy(), maskedPaths: [maskedPath] },
|
||||
payload(),
|
||||
() => {},
|
||||
new AbortController().signal,
|
||||
@@ -133,28 +144,75 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => {
|
||||
});
|
||||
const relayLaunch = launch.mock.calls[0][0];
|
||||
const argv = relayLaunch.args;
|
||||
const admittedWorkspace = realpathSync(workspace);
|
||||
const admittedMaskedPath = path.join(
|
||||
admittedWorkspace,
|
||||
'.qwen',
|
||||
'review-leases',
|
||||
);
|
||||
expect(argv).toContain('--unshare-pid');
|
||||
expect(argv).toContain('--unshare-net');
|
||||
expect(argv).toContain('--clearenv');
|
||||
expect(argv).toContain('GH_TOKEN');
|
||||
expect(argv).toContain('visible-user-value');
|
||||
expect(argv).not.toContain('--clearenv');
|
||||
expect(argv).not.toContain('--setenv');
|
||||
expect(argv).not.toContain('GH_TOKEN');
|
||||
expect(argv).not.toContain('visible-user-value');
|
||||
expect(argv).not.toContain('QWEN_SERVER_TOKEN');
|
||||
expect(argv).not.toContain('internal-secret');
|
||||
const term = argv.indexOf('TERM');
|
||||
expect(argv.slice(term - 1, term + 2)).toEqual([
|
||||
'--setenv',
|
||||
'TERM',
|
||||
'xterm-256color',
|
||||
]);
|
||||
const scratch = argv[argv.indexOf('TMPDIR') + 1];
|
||||
expect(capturedPayloadEnv).toMatchObject({
|
||||
GH_TOKEN: 'visible-user-value',
|
||||
TERM: 'xterm-256color',
|
||||
});
|
||||
expect(capturedPayloadEnv).not.toHaveProperty('QWEN_SERVER_TOKEN');
|
||||
expect(capturedPayloadEnvMode).toBe(0o600);
|
||||
const workspaceBind = argv.findIndex(
|
||||
(value, index) =>
|
||||
value === '--bind' &&
|
||||
argv[index + 1] === admittedWorkspace &&
|
||||
argv[index + 2] === admittedWorkspace,
|
||||
);
|
||||
const mask = argv.indexOf('--tmpfs');
|
||||
expect(workspaceBind).toBeGreaterThan(-1);
|
||||
expect(argv.slice(mask, mask + 2)).toEqual(['--tmpfs', admittedMaskedPath]);
|
||||
expect(mask).toBeGreaterThan(workspaceBind);
|
||||
expect(existsSync(maskedPath)).toBe(true);
|
||||
const scratch = capturedPayloadEnv['TMPDIR'];
|
||||
expect(path.isAbsolute(scratch)).toBe(true);
|
||||
expect(existsSync(scratch)).toBe(false);
|
||||
expect(readdirSync(state)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips absent masks without mutating a read-only workspace', async () => {
|
||||
const maskedPath = path.join(workspace, '.qwen', 'review-leases');
|
||||
const launch = mockLaunch({ state: 'confirmed', exitCode: 0 });
|
||||
const handle = await executeBwrap(
|
||||
{
|
||||
...policy(),
|
||||
filesystem: 'read-only',
|
||||
maskedPaths: [maskedPath],
|
||||
},
|
||||
payload(),
|
||||
() => {},
|
||||
new AbortController().signal,
|
||||
);
|
||||
await handle.result;
|
||||
expect(launch.mock.calls[0][0].args).not.toContain('--tmpfs');
|
||||
expect(existsSync(maskedPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects mask paths outside the workspace', async () => {
|
||||
await expect(
|
||||
executeBwrap(
|
||||
{ ...policy(), maskedPaths: [state] },
|
||||
payload(),
|
||||
() => {},
|
||||
new AbortController().signal,
|
||||
),
|
||||
).rejects.toThrow('inside the workspace');
|
||||
});
|
||||
|
||||
it('uses an absolute scratch root for a relative TMPDIR without leaking it', async () => {
|
||||
process.env['TMPDIR'] = 'relative-tmp';
|
||||
const launch = mockLaunch({ state: 'confirmed', exitCode: 0 });
|
||||
mockLaunch({ state: 'confirmed', exitCode: 0 });
|
||||
const handle = await executeBwrap(
|
||||
policy(),
|
||||
payload(),
|
||||
@@ -162,8 +220,7 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => {
|
||||
new AbortController().signal,
|
||||
);
|
||||
await handle.result;
|
||||
const argv = launch.mock.calls[0][0].args;
|
||||
const scratch = argv[argv.indexOf('TMPDIR') + 1];
|
||||
const scratch = capturedPayloadEnv['TMPDIR'];
|
||||
expect(path.dirname(scratch)).toBe(realpathSync('/tmp'));
|
||||
expect(existsSync(scratch)).toBe(false);
|
||||
expect(existsSync(path.join(process.cwd(), 'relative-tmp'))).toBe(false);
|
||||
@@ -181,7 +238,7 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => {
|
||||
|
||||
it('allows a temporary root that contains disjoint sandbox roots', async () => {
|
||||
process.env['TMPDIR'] = root;
|
||||
const launch = mockLaunch({ state: 'confirmed', exitCode: 0 });
|
||||
mockLaunch({ state: 'confirmed', exitCode: 0 });
|
||||
const handle = await executeBwrap(
|
||||
policy(),
|
||||
payload(),
|
||||
@@ -189,8 +246,7 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => {
|
||||
new AbortController().signal,
|
||||
);
|
||||
await handle.result;
|
||||
const argv = launch.mock.calls[0][0].args;
|
||||
const scratch = argv[argv.indexOf('TMPDIR') + 1];
|
||||
const scratch = capturedPayloadEnv['TMPDIR'];
|
||||
expect(path.dirname(scratch)).toBe(realpathSync(root));
|
||||
expect(existsSync(scratch)).toBe(false);
|
||||
});
|
||||
@@ -208,7 +264,7 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => {
|
||||
});
|
||||
const argv = launch.mock.calls[0][0].args;
|
||||
const statusPath = argv[2];
|
||||
const scratch = argv[argv.indexOf('TMPDIR') + 1];
|
||||
const scratch = capturedPayloadEnv['TMPDIR'];
|
||||
expect(readFileSync(statusPath, 'utf8')).toContain('unconfirmed');
|
||||
expect(existsSync(scratch)).toBe(true);
|
||||
rmSync(path.dirname(statusPath), { recursive: true, force: true });
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { existsSync, realpathSync, statSync } from 'node:fs';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { existsSync, mkdirSync, realpathSync, statSync } from 'node:fs';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveBundleDir } from '../utils/bundlePaths.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { isSubpath, realpathNearestExisting } from '../utils/paths.js';
|
||||
import { isInternalSecretEnvVar } from '../utils/sanitize-child-env.js';
|
||||
import {
|
||||
ShellExecutionService,
|
||||
@@ -32,6 +33,7 @@ export interface BwrapPolicy {
|
||||
workspace: string;
|
||||
installation: string;
|
||||
state: string;
|
||||
maskedPaths?: readonly string[];
|
||||
filesystem: 'read-only' | 'workspace-write';
|
||||
network: 'open' | 'closed';
|
||||
bwrapPath?: string;
|
||||
@@ -64,16 +66,7 @@ export function sandboxAsset(name: 'bwrap-relay' | 'file-worker'): string {
|
||||
return realpathSync(asset);
|
||||
}
|
||||
|
||||
const contains = (parent: string, child: string) => {
|
||||
const relative = path.relative(parent, child);
|
||||
return (
|
||||
relative === '' ||
|
||||
(relative !== '..' &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative))
|
||||
);
|
||||
};
|
||||
const overlaps = (a: string, b: string) => contains(a, b) || contains(b, a);
|
||||
const overlaps = (a: string, b: string) => isSubpath(a, b) || isSubpath(b, a);
|
||||
const directory = (value: string) => {
|
||||
if (!path.isAbsolute(value))
|
||||
throw new Error('Sandbox paths must be absolute.');
|
||||
@@ -136,7 +129,7 @@ export async function executeBwrap(
|
||||
];
|
||||
const checkWritable = (root: string) => {
|
||||
if (
|
||||
contains(root, realpathSync(os.homedir())) ||
|
||||
isSubpath(root, realpathSync(os.homedir())) ||
|
||||
protectedRoots.some((protectedRoot) => overlaps(root, protectedRoot))
|
||||
) {
|
||||
throw new Error('Writable directory overlaps a protected root.');
|
||||
@@ -145,8 +138,16 @@ export async function executeBwrap(
|
||||
// Keep this admission invariant even for read-only profiles so a later profile
|
||||
// change cannot turn a trusted installation/state directory into a workspace.
|
||||
checkWritable(workspace);
|
||||
const maskedPaths = (policy.maskedPaths ?? []).map((value) => {
|
||||
if (!path.isAbsolute(value))
|
||||
throw new Error('Sandbox mask paths must be absolute.');
|
||||
const resolved = realpathNearestExisting(value);
|
||||
if (resolved === workspace || !isSubpath(workspace, resolved))
|
||||
throw new Error('Sandbox mask paths must remain inside the workspace.');
|
||||
return resolved;
|
||||
});
|
||||
const cwd = directory(payload.cwd);
|
||||
if (!contains(workspace, cwd))
|
||||
if (!isSubpath(workspace, cwd))
|
||||
throw new Error('Payload cwd must be inside the workspace.');
|
||||
const executable = payload.executable;
|
||||
const args = [...payload.args];
|
||||
@@ -180,9 +181,9 @@ export async function executeBwrap(
|
||||
? realpathSync(requestedScratchRoot)
|
||||
: realpathSync('/tmp');
|
||||
if (
|
||||
contains(workspace, scratchRoot) ||
|
||||
isSubpath(workspace, scratchRoot) ||
|
||||
protectedRoots.some((protectedRoot) =>
|
||||
contains(protectedRoot, scratchRoot),
|
||||
isSubpath(protectedRoot, scratchRoot),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
@@ -193,6 +194,29 @@ export async function executeBwrap(
|
||||
checkWritable(scratch);
|
||||
if (overlaps(workspace, scratch))
|
||||
throw new Error('Workspace and scratch must be disjoint.');
|
||||
const payloadEnv = {
|
||||
...env,
|
||||
PWD: cwd,
|
||||
TMPDIR: scratch,
|
||||
TMP: scratch,
|
||||
TEMP: scratch,
|
||||
TERM: env['TERM'] || 'xterm-256color',
|
||||
};
|
||||
for (const [key, value] of Object.entries(payloadEnv)) {
|
||||
if (
|
||||
!key ||
|
||||
key.includes('=') ||
|
||||
key.includes('\0') ||
|
||||
value.includes('\0')
|
||||
)
|
||||
throw new Error('Invalid payload environment.');
|
||||
}
|
||||
const payloadEnvPath = path.join(control, 'payload-env.json');
|
||||
await writeFile(payloadEnvPath, JSON.stringify(payloadEnv), {
|
||||
encoding: 'utf8',
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
});
|
||||
const bwrapArgs = [
|
||||
'--ro-bind',
|
||||
'/',
|
||||
@@ -203,32 +227,19 @@ export async function executeBwrap(
|
||||
'--dev',
|
||||
'/dev',
|
||||
'--die-with-parent',
|
||||
'--clearenv',
|
||||
];
|
||||
for (const [key, value] of Object.entries({
|
||||
...env,
|
||||
PWD: cwd,
|
||||
TMPDIR: scratch,
|
||||
TMP: scratch,
|
||||
TEMP: scratch,
|
||||
// `--clearenv` wipes everything before these --setenv apply, so a
|
||||
// payload env without TERM would run with TERM unset (ncurses:
|
||||
// "unknown terminal type"). The relay bootstrap TERM default never
|
||||
// reaches the payload; the default belongs here.
|
||||
TERM: env['TERM'] || 'xterm-256color',
|
||||
})) {
|
||||
if (
|
||||
!key ||
|
||||
key.includes('=') ||
|
||||
key.includes('\0') ||
|
||||
value.includes('\0')
|
||||
)
|
||||
throw new Error('Invalid payload environment.');
|
||||
bwrapArgs.push('--setenv', key, value);
|
||||
}
|
||||
bwrapArgs.push('--bind', scratch, scratch);
|
||||
if (filesystem === 'workspace-write')
|
||||
bwrapArgs.push('--bind', workspace, workspace);
|
||||
for (const maskedPath of maskedPaths) {
|
||||
if (!existsSync(maskedPath)) {
|
||||
if (filesystem === 'read-only') continue;
|
||||
mkdirSync(maskedPath, { recursive: true });
|
||||
}
|
||||
if (!statSync(maskedPath).isDirectory())
|
||||
throw new Error('Sandbox mask paths must be directories.');
|
||||
bwrapArgs.push('--tmpfs', maskedPath);
|
||||
}
|
||||
if (network === 'closed') bwrapArgs.push('--unshare-net');
|
||||
bwrapArgs.push('--chdir', cwd, '--', executable, ...args);
|
||||
const statusPath = path.join(control, 'status.json');
|
||||
@@ -322,7 +333,14 @@ export async function executeBwrap(
|
||||
const handle = await ShellExecutionService.executeLaunch(
|
||||
{
|
||||
executable: node,
|
||||
args: [relay, String(process.pid), statusPath, bwrap, ...bwrapArgs],
|
||||
args: [
|
||||
relay,
|
||||
String(process.pid),
|
||||
statusPath,
|
||||
payloadEnvPath,
|
||||
bwrap,
|
||||
...bwrapArgs,
|
||||
],
|
||||
cwd,
|
||||
env: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
|
||||
@@ -5,11 +5,19 @@
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { constants, openSync, writeFileSync, closeSync } from 'node:fs';
|
||||
import {
|
||||
closeSync,
|
||||
constants,
|
||||
openSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { MAX_STATUS_BYTES, parseBwrapStatus } from './bwrap-status.js';
|
||||
|
||||
const [parentPid, statusPath, bwrap, ...args] = process.argv.slice(2);
|
||||
const [parentPid, statusPath, payloadEnvPath, bwrap, ...args] =
|
||||
process.argv.slice(2);
|
||||
if (process.ppid !== Number(parentPid)) process.exit(1);
|
||||
const parentWatch = setInterval(() => {
|
||||
if (process.ppid !== Number(parentPid)) process.exit(1);
|
||||
@@ -23,9 +31,28 @@ const fd = openSync(
|
||||
constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
const envFd = openSync(
|
||||
payloadEnvPath,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
let parsedEnv: unknown;
|
||||
try {
|
||||
parsedEnv = JSON.parse(readFileSync(envFd, 'utf8'));
|
||||
} finally {
|
||||
closeSync(envFd);
|
||||
unlinkSync(payloadEnvPath);
|
||||
}
|
||||
if (!parsedEnv || typeof parsedEnv !== 'object' || Array.isArray(parsedEnv))
|
||||
process.exit(1);
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(parsedEnv).map(([key, value]) => {
|
||||
if (typeof value !== 'string') process.exit(1);
|
||||
return [key, value];
|
||||
}),
|
||||
);
|
||||
const child = spawn(bwrap, ['--json-status-fd', '3', ...args], {
|
||||
stdio: ['inherit', 'inherit', 'inherit', 'pipe'],
|
||||
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', TERM: 'xterm-256color' },
|
||||
env,
|
||||
});
|
||||
let wire = '';
|
||||
let bytes = 0;
|
||||
|
||||
@@ -77,6 +77,15 @@ describe('sandbox file worker client', () => {
|
||||
writeSandboxFile(policy, request, new AbortController().signal),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
it.each([
|
||||
['invalid JSON', 'not json'],
|
||||
['negative reply', '{"ok":false,"error":"partial write"}'],
|
||||
])('rejects a zero exit with %s', async (_name, reply) => {
|
||||
result({}, reply);
|
||||
await expect(
|
||||
writeSandboxFile(policy, request, new AbortController().signal),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
it('reports a confirmed nonzero exit before parsing an invalid reply', async () => {
|
||||
result(
|
||||
{
|
||||
|
||||
@@ -62,15 +62,17 @@ export async function writeSandboxFile(
|
||||
`Sandbox file write failed (${result.sandboxStatus.state}): ${detail}`,
|
||||
);
|
||||
}
|
||||
if (result.sandboxStatus.exitCode === 0) return;
|
||||
let parsedReply: Record<string, unknown> | undefined;
|
||||
try {
|
||||
parsedReply = JSON.parse(reply.trim()) as Record<string, unknown>;
|
||||
} catch {
|
||||
if (result.sandboxStatus.exitCode === 0)
|
||||
throw new Error('Invalid sandbox file worker reply.');
|
||||
throw new Error(
|
||||
`Sandbox file worker exited ${result.sandboxStatus.exitCode}: ${diagnostics || result.output || reply || 'invalid reply'}`,
|
||||
);
|
||||
}
|
||||
if (result.sandboxStatus.exitCode === 0 && parsedReply['ok'] === true) return;
|
||||
if (parsedReply['ok'] === false && typeof parsedReply['error'] === 'string') {
|
||||
throw Object.assign(new Error(parsedReply['error']), {
|
||||
...(typeof parsedReply['code'] === 'string'
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { StandardFileSystemService } from '../services/fileSystemService.js';
|
||||
import { FileReadCache } from '../services/fileReadCache.js';
|
||||
import { WriteFileTool } from '../tools/write-file.js';
|
||||
import { EditTool } from '../tools/edit.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { captureRuntimeFileVersion, writeRuntimeFile } from './runtime-file.js';
|
||||
import { writeSandboxFile } from './file-worker-client.js';
|
||||
import { getSandboxFileVersion } from './file-version.js';
|
||||
|
||||
vi.mock('./file-worker-client.js', () => ({ writeSandboxFile: vi.fn() }));
|
||||
vi.mock('../telemetry/loggers.js', () => ({ logFileOperation: vi.fn() }));
|
||||
|
||||
describe('runtime file mutation routing', () => {
|
||||
let root: string;
|
||||
let service: StandardFileSystemService;
|
||||
let cache: FileReadCache;
|
||||
let config: Config;
|
||||
const trackEdit = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(writeSandboxFile).mockReset();
|
||||
trackEdit.mockReset();
|
||||
root = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-file-')),
|
||||
);
|
||||
service = new StandardFileSystemService();
|
||||
cache = new FileReadCache();
|
||||
config = {
|
||||
getShellExecutionSandbox: () => ({
|
||||
workspace: root,
|
||||
installation: '/installation',
|
||||
state: '/state',
|
||||
filesystem: 'workspace-write',
|
||||
network: 'closed',
|
||||
}),
|
||||
getTargetDir: () => root,
|
||||
getProjectRoot: () => root,
|
||||
getFileSystemService: () => service,
|
||||
getFileReadCache: () => cache,
|
||||
getFileReadCacheDisabled: () => false,
|
||||
getFileHistoryService: () => ({ trackEdit }),
|
||||
getDefaultFileEncoding: () => 'utf-8',
|
||||
getDebugMode: () => false,
|
||||
isRecordArtifactEnabled: () => false,
|
||||
} as unknown as Config;
|
||||
});
|
||||
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
it('retains the ordinary delegated filesystem and provenance without policy', async () => {
|
||||
vi.spyOn(config, 'getShellExecutionSandbox').mockReturnValue(undefined);
|
||||
const write = vi.spyOn(service, 'writeTextFile').mockResolvedValue({});
|
||||
const params = {
|
||||
path: path.join(root, 'a'),
|
||||
content: 'text',
|
||||
toolWriteOrigin: 'edit' as const,
|
||||
};
|
||||
expect(captureRuntimeFileVersion(config, params.path)).toBeUndefined();
|
||||
await writeRuntimeFile(
|
||||
config,
|
||||
params,
|
||||
undefined,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(write).toHaveBeenCalledWith(params);
|
||||
expect(writeSandboxFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('encodes BOM/CRLF bytes for the worker without a host write', async () => {
|
||||
const write = vi.spyOn(service, 'writeTextFile');
|
||||
await writeRuntimeFile(
|
||||
config,
|
||||
{
|
||||
path: path.join(root, 'a'),
|
||||
content: '世界\nline',
|
||||
_meta: { bom: true, lineEnding: 'crlf' },
|
||||
},
|
||||
null,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(vi.mocked(writeSandboxFile).mock.calls[0][1].content).toEqual(
|
||||
Buffer.from('\ufeff世界\r\nline'),
|
||||
);
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(path.join(root, 'a'))).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['write', 'edit'] as const)(
|
||||
'does not create host directories or retry a failed %s',
|
||||
async (kind) => {
|
||||
const file = path.join(root, 'absent', 'nested', 'file');
|
||||
const write = vi.spyOn(service, 'writeTextFile');
|
||||
vi.mocked(writeSandboxFile).mockRejectedValue(
|
||||
new Error('backend unavailable'),
|
||||
);
|
||||
const tool =
|
||||
kind === 'write'
|
||||
? new WriteFileTool(config).build({
|
||||
file_path: file,
|
||||
content: 'created',
|
||||
})
|
||||
: new EditTool(config).build({
|
||||
file_path: file,
|
||||
old_string: '',
|
||||
new_string: 'created',
|
||||
});
|
||||
const result = await tool.execute(new AbortController().signal);
|
||||
expect(result.error).toBeDefined();
|
||||
expect(writeSandboxFile).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(writeSandboxFile).mock.calls[0][1].expected).toBeNull();
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(path.dirname(file))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['write', 'edit'] as const)(
|
||||
'keeps the pre-read version across %s preparation',
|
||||
async (kind) => {
|
||||
const file = path.join(root, 'file');
|
||||
fs.writeFileSync(file, 'original');
|
||||
const expected = getSandboxFileVersion(file);
|
||||
vi.spyOn(config, 'getFileReadCacheDisabled').mockReturnValue(true);
|
||||
const read = service.readTextFile.bind(service);
|
||||
vi.spyOn(service, 'readTextFile').mockImplementation(async (params) => {
|
||||
const text = await read(params);
|
||||
fs.writeFileSync(file, 'external content');
|
||||
return text;
|
||||
});
|
||||
vi.mocked(writeSandboxFile).mockRejectedValue(
|
||||
Object.assign(new Error('stale'), { code: 'ESTALE' }),
|
||||
);
|
||||
const tool =
|
||||
kind === 'write'
|
||||
? new WriteFileTool(config).build({ file_path: file, content: 'new' })
|
||||
: new EditTool(config).build({
|
||||
file_path: file,
|
||||
old_string: 'original',
|
||||
new_string: 'new',
|
||||
});
|
||||
const result = await tool.execute(new AbortController().signal);
|
||||
expect(result.error?.type).toBe(ToolErrorType.FILE_CHANGED_SINCE_READ);
|
||||
expect(vi.mocked(writeSandboxFile).mock.calls[0][1].expected).toEqual(
|
||||
expected,
|
||||
);
|
||||
expect(fs.readFileSync(file, 'utf8')).toBe('external content');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
encodeTextFileContentAsync,
|
||||
type CoreWriteTextFileRequest,
|
||||
} from '../services/fileSystemService.js';
|
||||
import {
|
||||
getSandboxFileVersion,
|
||||
type SandboxFileVersion,
|
||||
} from './file-version.js';
|
||||
import { writeSandboxFile } from './file-worker-client.js';
|
||||
import { assertShellSandboxCwd } from './runtime-shell-policy.js';
|
||||
|
||||
export function captureRuntimeFileVersion(
|
||||
config: Config,
|
||||
filePath: string,
|
||||
): SandboxFileVersion | null | undefined {
|
||||
if (!config.getShellExecutionSandbox?.()) return undefined;
|
||||
return getSandboxFileVersion(filePath);
|
||||
}
|
||||
|
||||
export async function writeRuntimeFile(
|
||||
config: Config,
|
||||
params: CoreWriteTextFileRequest,
|
||||
expected: SandboxFileVersion | null | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const policy = config.getShellExecutionSandbox?.();
|
||||
if (!policy) {
|
||||
await config.getFileSystemService().writeTextFile(params);
|
||||
return;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
if (expected === undefined) throw new Error('Missing sandbox file version.');
|
||||
assertShellSandboxCwd(policy, config.getTargetDir());
|
||||
const content = await encodeTextFileContentAsync(
|
||||
params.path,
|
||||
params.content,
|
||||
params._meta,
|
||||
);
|
||||
await writeSandboxFile(
|
||||
policy,
|
||||
{
|
||||
operation: 'write',
|
||||
destination: params.path,
|
||||
content,
|
||||
expected,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
} from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ConfigParameters } from '../config/config.js';
|
||||
import {
|
||||
admitShellSandbox,
|
||||
assertShellSandboxCwd,
|
||||
probeShellSandbox,
|
||||
} from './runtime-shell-policy.js';
|
||||
|
||||
const executeBwrap = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./bwrap-execution.js', () => ({ executeBwrap }));
|
||||
|
||||
describe('runtime shell policy admission', () => {
|
||||
let root: string;
|
||||
let params: ConfigParameters;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const name of [
|
||||
'SANDBOX',
|
||||
'QWEN_SANDBOX',
|
||||
'QWEN_SANDBOX_NET',
|
||||
'QWEN_SANDBOX_PROXY_COMMAND',
|
||||
])
|
||||
vi.stubEnv(name, undefined);
|
||||
root = realpathSync(
|
||||
mkdtempSync(path.join(os.tmpdir(), 'qwen-policy-test-')),
|
||||
);
|
||||
for (const name of [
|
||||
'workspace',
|
||||
'outside',
|
||||
'installation',
|
||||
'state',
|
||||
'runtime',
|
||||
'config',
|
||||
])
|
||||
mkdirSync(path.join(root, name));
|
||||
params = {
|
||||
targetDir: path.join(root, 'workspace'),
|
||||
cwd: path.join(root, 'workspace'),
|
||||
debugMode: false,
|
||||
bareMode: true,
|
||||
interactive: false,
|
||||
shellExecutionSandbox: {
|
||||
workspace: path.join(root, 'workspace'),
|
||||
installation: path.join(root, 'installation'),
|
||||
state: path.join(root, 'state'),
|
||||
filesystem: 'workspace-write',
|
||||
network: 'closed',
|
||||
},
|
||||
} as ConfigParameters;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const admit = (params: ConfigParameters, root: string) =>
|
||||
admitShellSandbox(
|
||||
params,
|
||||
path.join(root, 'runtime'),
|
||||
path.join(root, 'config'),
|
||||
);
|
||||
|
||||
it('snapshots the canonical policy and both host state roots', () => {
|
||||
const workspaceAlias = path.join(root, 'workspace-alias');
|
||||
const installationAlias = path.join(root, 'installation-alias');
|
||||
const stateAlias = path.join(root, 'state-alias');
|
||||
for (const [target, alias] of [
|
||||
[path.join(root, 'workspace'), workspaceAlias],
|
||||
[path.join(root, 'installation'), installationAlias],
|
||||
[path.join(root, 'state'), stateAlias],
|
||||
])
|
||||
symlinkSync(
|
||||
target,
|
||||
alias,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
params.shellExecutionSandbox = {
|
||||
...params.shellExecutionSandbox!,
|
||||
workspace: workspaceAlias,
|
||||
installation: installationAlias,
|
||||
state: stateAlias,
|
||||
bwrapPath: path.join(root, 'installation', 'bwrap'),
|
||||
};
|
||||
const policy = admit(params, root)!;
|
||||
params.shellExecutionSandbox = {
|
||||
...params.shellExecutionSandbox!,
|
||||
workspace: path.join(root, 'outside'),
|
||||
network: 'open',
|
||||
};
|
||||
expect(policy.workspace).toBe(path.join(root, 'workspace'));
|
||||
expect(policy.installation).toBe(path.join(root, 'installation'));
|
||||
expect(policy.state).toBe(path.join(root, 'state'));
|
||||
expect(policy.bwrapPath).toBe(path.join(root, 'installation', 'bwrap'));
|
||||
expect(policy.network).toBe('closed');
|
||||
expect(Object.isFrozen(policy)).toBe(true);
|
||||
expect(policy).not.toHaveProperty('protectedRoots');
|
||||
});
|
||||
|
||||
it('canonicalizes workspace-local masks and rejects masks outside it', () => {
|
||||
params.shellExecutionSandbox = {
|
||||
...params.shellExecutionSandbox!,
|
||||
maskedPaths: [path.join(root, 'workspace', '.qwen', 'review-leases')],
|
||||
};
|
||||
const policy = admit(params, root)!;
|
||||
expect(policy.maskedPaths).toEqual([
|
||||
path.join(root, 'workspace', '.qwen', 'review-leases'),
|
||||
]);
|
||||
expect(Object.isFrozen(policy.maskedPaths)).toBe(true);
|
||||
params.shellExecutionSandbox = {
|
||||
...params.shellExecutionSandbox,
|
||||
maskedPaths: [path.join(root, 'outside')],
|
||||
};
|
||||
expect(() => admit(params, root)).toThrow('inside the workspace');
|
||||
});
|
||||
|
||||
it('keeps frontend mode outside core policy admission', () => {
|
||||
expect(
|
||||
admit({ ...params, bareMode: false, interactive: true }, root),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('preserves whole-CLI bwrap when the new policy is absent', () => {
|
||||
expect(
|
||||
admit(
|
||||
{
|
||||
...params,
|
||||
shellExecutionSandbox: undefined,
|
||||
sandbox: { command: 'bwrap' },
|
||||
},
|
||||
root,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'SANDBOX',
|
||||
'QWEN_SANDBOX',
|
||||
'QWEN_SANDBOX_NET',
|
||||
'QWEN_SANDBOX_PROXY_COMMAND',
|
||||
])('rejects legacy %s environment', (name) => {
|
||||
vi.stubEnv(name, 'bwrap');
|
||||
expect(() => admit(params, root)).toThrow('legacy sandbox environment');
|
||||
});
|
||||
|
||||
it.each(['SANDBOX', 'QWEN_SANDBOX_NET', 'QWEN_SANDBOX_PROXY_COMMAND'])(
|
||||
'ignores empty legacy %s environment',
|
||||
(name) => {
|
||||
vi.stubEnv(name, ' ');
|
||||
expect(() => admit(params, root)).not.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['false', '0'])('accepts disabled QWEN_SANDBOX=%s', (value) => {
|
||||
vi.stubEnv('QWEN_SANDBOX', value);
|
||||
expect(() => admit(params, root)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a distinct cwd outside the admitted workspace', () => {
|
||||
expect(() =>
|
||||
admit({ ...params, cwd: path.join(root, 'outside') }, root),
|
||||
).toThrow('admitted workspace');
|
||||
});
|
||||
|
||||
it('rejects a missing cwd with the policy error', () => {
|
||||
const policy = admit(params, root)!;
|
||||
expect(() =>
|
||||
assertShellSandboxCwd(policy, path.join(root, 'workspace', 'missing')),
|
||||
).toThrow('admitted workspace');
|
||||
});
|
||||
|
||||
it('rejects cwd symlinks into another workspace', () => {
|
||||
symlinkSync(
|
||||
path.join(root, 'outside'),
|
||||
path.join(root, 'workspace', 'link'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
const policy = admit(params, root)!;
|
||||
expect(() =>
|
||||
assertShellSandboxCwd(policy, path.join(root, 'workspace', 'link')),
|
||||
).toThrow('admitted workspace');
|
||||
expect(() =>
|
||||
assertShellSandboxCwd(policy, path.join(root, 'workspace')),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it.each(['runtime', 'config', 'state', 'installation'])(
|
||||
'rejects workspace overlap with %s',
|
||||
(name) => {
|
||||
params.targetDir = path.join(root, name);
|
||||
params.shellExecutionSandbox = {
|
||||
...params.shellExecutionSandbox!,
|
||||
workspace: params.targetDir,
|
||||
};
|
||||
expect(() => admit(params, root)).toThrow('overlaps protected');
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects workspace ancestors and descendants of protected roots', () => {
|
||||
params.targetDir = root;
|
||||
params.cwd = root;
|
||||
params.shellExecutionSandbox = {
|
||||
...params.shellExecutionSandbox!,
|
||||
workspace: root,
|
||||
};
|
||||
expect(() => admit(params, root)).toThrow('overlaps protected');
|
||||
|
||||
const nested = path.join(root, 'runtime', 'nested');
|
||||
mkdirSync(nested);
|
||||
params.targetDir = nested;
|
||||
params.cwd = nested;
|
||||
params.shellExecutionSandbox = {
|
||||
...params.shellExecutionSandbox,
|
||||
workspace: nested,
|
||||
};
|
||||
expect(() => admit(params, root)).toThrow('overlaps protected');
|
||||
});
|
||||
|
||||
it.each([[['']], [['none']], [[' NONE ']]])(
|
||||
'accepts the no-extension override %j',
|
||||
(overrideExtensions) => {
|
||||
expect(() =>
|
||||
admit({ ...params, overrideExtensions }, root),
|
||||
).not.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it.each<Partial<ConfigParameters>>([
|
||||
{ provisionalWorkspace: true },
|
||||
{ sdkMode: true },
|
||||
{ experimentalZedIntegration: true },
|
||||
{ overrideExtensions: ['extension'] },
|
||||
{ mcpServers: { x: { command: 'node' } } },
|
||||
{ topTierMcpServers: { x: { command: 'node' } } },
|
||||
{ toolDiscoveryCommand: 'node' },
|
||||
{ toolCallCommand: 'node' },
|
||||
{ mcpServerCommand: 'node' },
|
||||
{ lsp: { enabled: true } },
|
||||
{ sandbox: { command: 'docker' } },
|
||||
{ agentExecutionBackend: 'container' },
|
||||
{ executionEnvironmentFactory: vi.fn() },
|
||||
])('rejects unsupported startup inputs %j', (overrides) => {
|
||||
expect(() => admit({ ...params, ...overrides }, root)).toThrow(
|
||||
'does not support',
|
||||
);
|
||||
});
|
||||
it('probes a fixed command and requires a confirmed zero exit receipt', async () => {
|
||||
vi.stubGlobal(
|
||||
'process',
|
||||
Object.create(process, { platform: { value: 'linux' } }),
|
||||
);
|
||||
executeBwrap.mockResolvedValue({
|
||||
result: Promise.resolve({
|
||||
sandboxStatus: { state: 'confirmed', exitCode: 0 },
|
||||
}),
|
||||
});
|
||||
await probeShellSandbox(admit(params, root)!);
|
||||
expect(executeBwrap).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{
|
||||
executable: '/bin/bash',
|
||||
args: ['-c', 'true'],
|
||||
cwd: params.targetDir,
|
||||
env: { PATH: '/usr/bin:/bin' },
|
||||
},
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
executeBwrap.mockResolvedValue({
|
||||
result: Promise.resolve({ sandboxStatus: { state: 'unconfirmed' } }),
|
||||
});
|
||||
await expect(probeShellSandbox(admit(params, root)!)).rejects.toThrow(
|
||||
'capability probe failed',
|
||||
);
|
||||
expect(executeBwrap).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('aborts a hung capability probe after ten seconds', async () => {
|
||||
vi.stubGlobal(
|
||||
'process',
|
||||
Object.create(process, { platform: { value: 'linux' } }),
|
||||
);
|
||||
const timeout = new AbortController();
|
||||
const timeoutSpy = vi
|
||||
.spyOn(AbortSignal, 'timeout')
|
||||
.mockReturnValue(timeout.signal);
|
||||
let started!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
executeBwrap.mockImplementation(
|
||||
async (_policy, _command, _output, signal: AbortSignal) => {
|
||||
started();
|
||||
return {
|
||||
result: new Promise((resolve) =>
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
resolve({
|
||||
sandboxStatus: { state: 'unconfirmed' },
|
||||
aborted: true,
|
||||
}),
|
||||
{ once: true },
|
||||
),
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
try {
|
||||
const pending = probeShellSandbox(admit(params, root)!);
|
||||
await ready;
|
||||
timeout.abort();
|
||||
await expect(pending).rejects.toThrow('capability probe failed');
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(10_000);
|
||||
} finally {
|
||||
timeoutSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { realpathSync, statSync } from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { ConfigParameters } from '../config/config.js';
|
||||
import { isSubpath, realpathNearestExisting } from '../utils/paths.js';
|
||||
import type { BwrapPolicy } from './bwrap-execution.js';
|
||||
|
||||
export function assertShellSandboxCwd(
|
||||
policy: Readonly<BwrapPolicy>,
|
||||
cwd: string,
|
||||
): void {
|
||||
if (
|
||||
!path.isAbsolute(cwd) ||
|
||||
statSync(cwd, { throwIfNoEntry: false })?.isDirectory() !== true ||
|
||||
!isSubpath(policy.workspace, realpathSync(cwd))
|
||||
) {
|
||||
throw new Error(
|
||||
'Shell sandbox cwd must remain inside the admitted workspace.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function admitShellSandbox(
|
||||
params: ConfigParameters,
|
||||
runtimeRoot: string,
|
||||
globalConfigRoot: string,
|
||||
): Readonly<BwrapPolicy> | undefined {
|
||||
const policy = params.shellExecutionSandbox;
|
||||
if (!policy) return undefined;
|
||||
if (params.sandbox?.command === 'bwrap') {
|
||||
throw new Error(
|
||||
'Whole-CLI bwrap is no longer supported. Use tools.executionSandbox instead.',
|
||||
);
|
||||
}
|
||||
const legacySelection = process.env['QWEN_SANDBOX']?.trim().toLowerCase();
|
||||
if (
|
||||
process.env['SANDBOX']?.trim() ||
|
||||
(legacySelection && !['false', '0'].includes(legacySelection)) ||
|
||||
process.env['QWEN_SANDBOX_NET']?.trim() ||
|
||||
process.env['QWEN_SANDBOX_PROXY_COMMAND']?.trim()
|
||||
) {
|
||||
throw new Error(
|
||||
'Tool execution sandbox cannot be combined with legacy sandbox environment settings. Remove SANDBOX, QWEN_SANDBOX, QWEN_SANDBOX_NET and QWEN_SANDBOX_PROXY_COMMAND and restart.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
params.provisionalWorkspace ||
|
||||
params.sdkMode ||
|
||||
params.experimentalZedIntegration ||
|
||||
params.sandbox ||
|
||||
params.overrideExtensions?.some(
|
||||
(name) => name.trim() !== '' && name.trim().toLowerCase() !== 'none',
|
||||
) ||
|
||||
Object.keys(params.mcpServers ?? {}).length ||
|
||||
Object.keys(params.topTierMcpServers ?? {}).length ||
|
||||
params.mcpServerCommand ||
|
||||
params.toolDiscoveryCommand ||
|
||||
params.toolCallCommand ||
|
||||
params.lsp?.enabled ||
|
||||
params.lspClient ||
|
||||
params.agentExecutionBackend !== undefined ||
|
||||
params.executionEnvironmentFactory !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
'Tool execution sandbox does not support SDK/ACP sessions, provisional workspaces, extensions, MCP, discovery, LSP, agent execution environments, or a whole-CLI sandbox.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!['read-only', 'workspace-write'].includes(policy.filesystem) ||
|
||||
!['open', 'closed'].includes(policy.network)
|
||||
) {
|
||||
throw new Error('Unsupported shell sandbox policy.');
|
||||
}
|
||||
if (
|
||||
policy.bwrapPath !== undefined &&
|
||||
(typeof policy.bwrapPath !== 'string' || !path.isAbsolute(policy.bwrapPath))
|
||||
) {
|
||||
throw new Error('Invalid tool execution sandbox paths.');
|
||||
}
|
||||
const canonical = (value: string) => {
|
||||
if (typeof value !== 'string' || !path.isAbsolute(value))
|
||||
throw new Error('Shell sandbox paths must be absolute.');
|
||||
return realpathNearestExisting(value);
|
||||
};
|
||||
const workspace = canonical(policy.workspace);
|
||||
const installation = canonical(policy.installation);
|
||||
const state = canonical(policy.state);
|
||||
const maskedPaths = Object.freeze((policy.maskedPaths ?? []).map(canonical));
|
||||
const protectedRoots = Object.freeze(
|
||||
[installation, state, runtimeRoot, globalConfigRoot].map(canonical),
|
||||
);
|
||||
if (
|
||||
protectedRoots.some(
|
||||
(root) => isSubpath(root, workspace) || isSubpath(workspace, root),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Shell sandbox workspace overlaps protected state or installation.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
maskedPaths.some(
|
||||
(maskedPath) =>
|
||||
maskedPath === workspace || !isSubpath(workspace, maskedPath),
|
||||
)
|
||||
) {
|
||||
throw new Error('Shell sandbox masks must remain inside the workspace.');
|
||||
}
|
||||
const admitted: Readonly<BwrapPolicy> = Object.freeze({
|
||||
workspace,
|
||||
installation,
|
||||
state,
|
||||
...(maskedPaths.length > 0 ? { maskedPaths } : {}),
|
||||
filesystem: policy.filesystem,
|
||||
network: policy.network,
|
||||
...(policy.bwrapPath ? { bwrapPath: policy.bwrapPath } : {}),
|
||||
});
|
||||
assertShellSandboxCwd(admitted, params.targetDir);
|
||||
assertShellSandboxCwd(admitted, params.cwd ?? process.cwd());
|
||||
return admitted;
|
||||
}
|
||||
|
||||
export async function probeShellSandbox(
|
||||
policy: Readonly<BwrapPolicy>,
|
||||
signal: AbortSignal = new AbortController().signal,
|
||||
): Promise<void> {
|
||||
signal.throwIfAborted();
|
||||
if (process.platform !== 'linux')
|
||||
throw new Error('bwrap execution requires Linux.');
|
||||
if (realpathNearestExisting(policy.state) !== policy.state)
|
||||
throw new Error('Sandbox state directory changed after admission.');
|
||||
await mkdir(policy.state, { recursive: true, mode: 0o700 });
|
||||
if (realpathSync(policy.state) !== policy.state)
|
||||
throw new Error('Sandbox state directory changed after admission.');
|
||||
const { executeBwrap } = await import('./bwrap-execution.js');
|
||||
const handle = await executeBwrap(
|
||||
policy,
|
||||
{
|
||||
executable: '/bin/bash',
|
||||
args: ['-c', 'true'],
|
||||
cwd: policy.workspace,
|
||||
env: { PATH: '/usr/bin:/bin' },
|
||||
},
|
||||
() => {},
|
||||
AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
);
|
||||
const result = await handle.result;
|
||||
if (
|
||||
result.sandboxStatus.state !== 'confirmed' ||
|
||||
result.sandboxStatus.exitCode !== 0 ||
|
||||
result.error ||
|
||||
result.aborted
|
||||
) {
|
||||
throw new Error(
|
||||
`Sandbox capability probe failed: ${result.error?.message ?? (result.output || result.sandboxStatus.state)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { executeRuntimeShell } from './runtime-shell.js';
|
||||
import { executeBwrap } from './bwrap-execution.js';
|
||||
import { ShellExecutionService } from '../services/shellExecutionService.js';
|
||||
import { assertShellSandboxCwd } from './runtime-shell-policy.js';
|
||||
import { sessionIdContext } from '../utils/sessionIdContext.js';
|
||||
|
||||
vi.mock('./bwrap-execution.js', () => ({ executeBwrap: vi.fn() }));
|
||||
vi.mock('./runtime-shell-policy.js', () => ({
|
||||
assertShellSandboxCwd: vi.fn(),
|
||||
}));
|
||||
vi.mock('../services/shellExecutionService.js', () => ({
|
||||
ShellExecutionService: { execute: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('runtime shell dispatch', () => {
|
||||
const policy = {
|
||||
workspace: '/workspace',
|
||||
installation: '/installation',
|
||||
state: '/state',
|
||||
filesystem: 'workspace-write' as const,
|
||||
network: 'closed' as const,
|
||||
};
|
||||
const runtime = (enabled = true, sessionId = 'runtime-session') =>
|
||||
({
|
||||
getShellExecutionSandbox: () => (enabled ? policy : undefined),
|
||||
getTargetDir: () => '/workspace',
|
||||
getSessionId: () => sessionId,
|
||||
storage: { getProjectDir: () => `/state/${sessionId}` },
|
||||
}) as unknown as Config;
|
||||
const callback = vi.fn();
|
||||
const signal = new AbortController().signal;
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it('preserves the legacy service and all options without policy', async () => {
|
||||
const options = { streamStdout: true };
|
||||
const handle = { pid: 7, result: Promise.resolve({ output: 'legacy' }) };
|
||||
vi.mocked(ShellExecutionService.execute).mockResolvedValue(
|
||||
handle as Awaited<ReturnType<typeof ShellExecutionService.execute>>,
|
||||
);
|
||||
expect(
|
||||
await executeRuntimeShell(
|
||||
runtime(false),
|
||||
'echo ok',
|
||||
'/workspace',
|
||||
callback,
|
||||
signal,
|
||||
false,
|
||||
{},
|
||||
options,
|
||||
),
|
||||
).toBe(handle);
|
||||
expect(ShellExecutionService.execute).toHaveBeenCalledWith(
|
||||
'echo ok',
|
||||
'/workspace',
|
||||
callback,
|
||||
signal,
|
||||
false,
|
||||
{},
|
||||
options,
|
||||
);
|
||||
expect(executeBwrap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'uses a literal command and owning session environment (pty=%s)',
|
||||
async (pty) => {
|
||||
vi.stubEnv('GH_TOKEN', 'expected-user-value');
|
||||
const command = 'printf "%s" "$HOME"; echo literal';
|
||||
await sessionIdContext.run('different-session', () =>
|
||||
executeRuntimeShell(
|
||||
runtime(),
|
||||
command,
|
||||
'/workspace/sub',
|
||||
callback,
|
||||
signal,
|
||||
pty,
|
||||
{ pager: 'less' },
|
||||
{ streamStdout: true },
|
||||
),
|
||||
);
|
||||
expect(assertShellSandboxCwd).toHaveBeenCalledWith(policy, '/workspace');
|
||||
expect(assertShellSandboxCwd).toHaveBeenCalledWith(
|
||||
policy,
|
||||
'/workspace/sub',
|
||||
);
|
||||
const args = vi.mocked(executeBwrap).mock.calls[0];
|
||||
expect(args[0]).toBe(policy);
|
||||
expect(args[1]).toMatchObject({
|
||||
executable: '/bin/bash',
|
||||
args: ['-c', command],
|
||||
cwd: '/workspace/sub',
|
||||
env: {
|
||||
QWEN_CODE: '1',
|
||||
QWEN_CODE_SESSION_ID: 'runtime-session',
|
||||
QWEN_CODE_PROJECT_DIR: '/state/runtime-session',
|
||||
GH_TOKEN: 'expected-user-value',
|
||||
TERM: 'xterm-256color',
|
||||
PAGER: 'less',
|
||||
},
|
||||
});
|
||||
if (pty) expect(args[1].env['GIT_PAGER']).toBe('less');
|
||||
expect(args.slice(2)).toEqual([
|
||||
callback,
|
||||
signal,
|
||||
pty,
|
||||
{ pager: 'less' },
|
||||
{ streamStdout: true },
|
||||
]);
|
||||
expect(ShellExecutionService.execute).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not fall back after admission or backend failure', async () => {
|
||||
vi.mocked(assertShellSandboxCwd).mockImplementationOnce(() => {
|
||||
throw new Error('outside');
|
||||
});
|
||||
await expect(
|
||||
executeRuntimeShell(
|
||||
runtime(),
|
||||
'touch marker',
|
||||
'/other',
|
||||
callback,
|
||||
signal,
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow('outside');
|
||||
expect(executeBwrap).not.toHaveBeenCalled();
|
||||
vi.mocked(executeBwrap).mockRejectedValueOnce(new Error('setup failed'));
|
||||
await expect(
|
||||
executeRuntimeShell(
|
||||
runtime(),
|
||||
'touch marker',
|
||||
'/workspace',
|
||||
callback,
|
||||
signal,
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow('setup failed');
|
||||
expect(ShellExecutionService.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { ShellExecutionService } from '../services/shellExecutionService.js';
|
||||
import type {
|
||||
ShellExecutionHandle,
|
||||
ShellExecutionConfig,
|
||||
ShellExecuteOptions,
|
||||
ShellOutputEvent,
|
||||
} from '../services/shellExecutionService.js';
|
||||
import { getShellContextEnvVars } from '../services/shellContextEnv.js';
|
||||
import { sanitizeChildEnv } from '../utils/sanitize-child-env.js';
|
||||
import { getShellPagerEnv } from '../utils/shell-pager-env.js';
|
||||
import { sessionIdContext } from '../utils/sessionIdContext.js';
|
||||
import { executeBwrap } from './bwrap-execution.js';
|
||||
import { assertShellSandboxCwd } from './runtime-shell-policy.js';
|
||||
|
||||
export function executeRuntimeShell(
|
||||
runtime: Config,
|
||||
command: string,
|
||||
cwd: string,
|
||||
onOutput: (event: ShellOutputEvent) => void,
|
||||
signal: AbortSignal,
|
||||
usePty: boolean,
|
||||
config: ShellExecutionConfig = {},
|
||||
options: ShellExecuteOptions = {},
|
||||
): Promise<ShellExecutionHandle> {
|
||||
const policy = runtime.getShellExecutionSandbox?.();
|
||||
if (!policy) {
|
||||
return ShellExecutionService.execute(
|
||||
command,
|
||||
cwd,
|
||||
onOutput,
|
||||
signal,
|
||||
usePty,
|
||||
config,
|
||||
options,
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertShellSandboxCwd(policy, runtime.getTargetDir());
|
||||
assertShellSandboxCwd(policy, cwd);
|
||||
const env = Object.fromEntries(
|
||||
Object.entries({
|
||||
...sanitizeChildEnv(process.env),
|
||||
QWEN_CODE: '1',
|
||||
TERM: 'xterm-256color',
|
||||
...getShellPagerEnv(config.pager, {
|
||||
includeGitPager: usePty,
|
||||
platform: 'linux',
|
||||
}),
|
||||
...sessionIdContext.run(runtime.getSessionId(), getShellContextEnvVars),
|
||||
QWEN_CODE_SESSION_ID: runtime.getSessionId(),
|
||||
QWEN_CODE_PROJECT_DIR: runtime.storage.getProjectDir(),
|
||||
}).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||
),
|
||||
);
|
||||
return executeBwrap(
|
||||
policy,
|
||||
{ executable: '/bin/bash', args: ['-c', command], cwd, env },
|
||||
onOutput,
|
||||
signal,
|
||||
usePty,
|
||||
config,
|
||||
options,
|
||||
);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
@@ -3218,6 +3218,38 @@ bad`);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ executor: executorConfig.executor },
|
||||
{ mcpServers: { remote: { command: 'node' } } },
|
||||
{ hooks: { Stop: [] } },
|
||||
])(
|
||||
'rejects sandbox child overrides %j before invoking any executor',
|
||||
async (overrides) => {
|
||||
vi.spyOn(mockConfig, 'getShellExecutionSandbox').mockReturnValue(
|
||||
{} as NonNullable<ReturnType<Config['getShellExecutionSandbox']>>,
|
||||
);
|
||||
const externalCreate = vi.fn();
|
||||
vi.spyOn(mockConfig, 'getExternalAgentExecutor').mockReturnValue({
|
||||
create: externalCreate,
|
||||
});
|
||||
await expect(
|
||||
manager.createAgentHeadless(
|
||||
{
|
||||
...executorConfig,
|
||||
executor: undefined,
|
||||
...overrides,
|
||||
} as SubagentConfig,
|
||||
mockConfig,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'does not support agent executors, MCP servers or hooks',
|
||||
);
|
||||
expect(externalCreate).not.toHaveBeenCalled();
|
||||
expect(mockAgentHeadlessCreate).not.toHaveBeenCalled();
|
||||
expect(mockToolRegistry.warmAll).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('refuses to run in-process when no executor is registered', async () => {
|
||||
vi.spyOn(mockConfig, 'getExternalAgentExecutor').mockReturnValue(
|
||||
undefined,
|
||||
|
||||
@@ -933,6 +933,18 @@ export class SubagentManager {
|
||||
subagentId?: string;
|
||||
},
|
||||
): Promise<{ subagent: SubagentExecutor; dispose: () => Promise<void> }> {
|
||||
if (
|
||||
runtimeContext.getShellExecutionSandbox?.() &&
|
||||
(config.executor !== undefined ||
|
||||
Object.keys(config.mcpServers ?? {}).length > 0 ||
|
||||
Object.keys(config.hooks ?? {}).length > 0)
|
||||
) {
|
||||
throw new SubagentError(
|
||||
'Tool execution sandbox does not support agent executors, MCP servers or hooks.',
|
||||
SubagentErrorCode.INVALID_CONFIG,
|
||||
config.name,
|
||||
);
|
||||
}
|
||||
// Track per-spawn cleanup callbacks declared outside the inner
|
||||
// `try/catch` so the catch can fire them on a constructor failure
|
||||
// before the caller ever receives the return value. The successful
|
||||
|
||||
@@ -863,6 +863,29 @@ describe('AgentTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each<Partial<AgentParams>>([
|
||||
{ isolation: 'worktree' },
|
||||
{ working_dir: '/another/workspace' },
|
||||
{ name: 'teammate' },
|
||||
])(
|
||||
'rejects unsupported sandbox agent entry %j before execution',
|
||||
async (overrides) => {
|
||||
config.getShellExecutionSandbox = vi.fn().mockReturnValue({});
|
||||
const invocation = (
|
||||
agentTool as AgentToolWithProtectedMethods
|
||||
).createInvocation({
|
||||
description: 'Sandbox agent',
|
||||
prompt: 'inspect the project',
|
||||
subagent_type: 'file-search',
|
||||
...overrides,
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.llmContent).toContain('same-workspace in-process agents');
|
||||
expect(mockSubagentManager.loadSubagent).not.toHaveBeenCalled();
|
||||
expect(AgentHeadless.create).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with correct name and properties', () => {
|
||||
expect(agentTool.name).toBe('agent');
|
||||
|
||||
@@ -596,7 +596,10 @@ export async function rebuildToolRegistryOnOverride(
|
||||
skipDiscovery: true,
|
||||
forSubAgent: true,
|
||||
});
|
||||
if (!override.getExecutionEnvironment?.()) {
|
||||
if (
|
||||
!override.getExecutionEnvironment?.() &&
|
||||
!base.getShellExecutionSandbox?.()
|
||||
) {
|
||||
agentRegistry.copyDiscoveredToolsFrom(base.getToolRegistry());
|
||||
}
|
||||
ov.getToolRegistry = () => agentRegistry;
|
||||
@@ -2457,6 +2460,18 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
|
||||
executionBackendError,
|
||||
);
|
||||
}
|
||||
if (
|
||||
this.config.getShellExecutionSandbox?.() &&
|
||||
(this.params.isolation || this.params.working_dir || this.params.name)
|
||||
) {
|
||||
const message =
|
||||
'Tool execution sandbox supports only same-workspace in-process agents; worktrees, working_dir and teammates are unavailable.';
|
||||
return {
|
||||
llmContent: message,
|
||||
returnDisplay: message,
|
||||
error: { message },
|
||||
};
|
||||
}
|
||||
const sessionWorkflowAgent =
|
||||
this.config.getSessionWorkflowPlanRevision?.() !== undefined;
|
||||
if (this.params.plan_mode_required === true) {
|
||||
|
||||
@@ -21,6 +21,11 @@ import { makeRelative, shortenPath, unescapePath } from '../utils/paths.js';
|
||||
import { getErrorMessage, isNodeError } from '../utils/errors.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { ApprovalMode } from '../config/config.js';
|
||||
import {
|
||||
captureRuntimeFileVersion,
|
||||
writeRuntimeFile,
|
||||
} from '../sandbox/runtime-file.js';
|
||||
import type { SandboxFileVersion } from '../sandbox/file-version.js';
|
||||
import { isAnyAutoMemPath, isTeamAutoMemPath } from '../memory/paths.js';
|
||||
import { checkTeamMemorySecrets } from '../memory/team-memory-secret-guard.js';
|
||||
import {
|
||||
@@ -115,6 +120,7 @@ export interface EditToolParams {
|
||||
}
|
||||
|
||||
interface CalculatedEdit {
|
||||
sandboxFileVersion?: SandboxFileVersion | null;
|
||||
currentContent: string | null;
|
||||
newContent: string;
|
||||
occurrences: number;
|
||||
@@ -145,6 +151,10 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
||||
* @throws File system errors if reading the file fails unexpectedly (e.g., permissions)
|
||||
*/
|
||||
private async calculateEdit(params: EditToolParams): Promise<CalculatedEdit> {
|
||||
const sandboxFileVersion = captureRuntimeFileVersion(
|
||||
this.config,
|
||||
params.file_path,
|
||||
);
|
||||
const replaceAll = params.replace_all ?? false;
|
||||
let currentContent: string | null = null;
|
||||
let fileExists = await isFilefileExists(params.file_path);
|
||||
@@ -374,6 +384,7 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
||||
return {
|
||||
currentContent,
|
||||
newContent,
|
||||
sandboxFileVersion,
|
||||
occurrences,
|
||||
error,
|
||||
isNewFile,
|
||||
@@ -416,7 +427,10 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
||||
if (abortSignal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
const errorMsg = getErrorMessage(error);
|
||||
const errorMsg =
|
||||
isNodeError(error) && error.code === 'EISDIR'
|
||||
? `Target is a directory, not a file: ${this.params.file_path} (${error.code})`
|
||||
: getErrorMessage(error);
|
||||
throw new Error(`Error preparing edit: ${errorMsg}`);
|
||||
}
|
||||
|
||||
@@ -546,14 +560,9 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
||||
//
|
||||
// It does NOT eliminate the race. A concurrent writer that
|
||||
// lands between this stat and the writeTextFile call below
|
||||
// can still be clobbered — that residual is an OS-level
|
||||
// limitation of the stat-then-write pattern, and the only
|
||||
// way to close it is an atomic write (write to a temp file,
|
||||
// then rename) or a content-hash post-check that re-reads
|
||||
// the bytes after the write. Both are deferred to a follow-up
|
||||
// PR; operators who care about strict overwrite-protection
|
||||
// should set `fileReadCacheDisabled: true` and rely on
|
||||
// application-level locking.
|
||||
// can still be clobbered. Atomic replacement is not compare-and-swap;
|
||||
// the sandbox worker rechecks the prepared version before commit,
|
||||
// but strict protection against concurrent writers requires locking.
|
||||
//
|
||||
// Run unconditionally (not gated on `editData.isNewFile`):
|
||||
// `isNewFile` was decided back in calculateEdit, but a file
|
||||
@@ -596,7 +605,9 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
||||
// directories on the failure path — a real (if minor) FS
|
||||
// litter that the previous order created on every rejected
|
||||
// edit.
|
||||
this.ensureParentDirectoriesExist(this.params.file_path);
|
||||
if (!this.config.getShellExecutionSandbox?.()) {
|
||||
this.ensureParentDirectoriesExist(this.params.file_path);
|
||||
}
|
||||
|
||||
// For new files, apply default file encoding setting
|
||||
// For existing files, preserve the original encoding (BOM and charset)
|
||||
@@ -609,25 +620,35 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
||||
// No explicit setting: auto-detect (e.g. .ps1 on non-UTF-8 Windows)
|
||||
useBOM = needsUtf8Bom(this.params.file_path);
|
||||
}
|
||||
await this.config.getFileSystemService().writeTextFile({
|
||||
path: this.params.file_path,
|
||||
content: editData.newContent,
|
||||
toolWriteOrigin: 'edit',
|
||||
_meta: {
|
||||
bom: useBOM,
|
||||
await writeRuntimeFile(
|
||||
this.config,
|
||||
{
|
||||
path: this.params.file_path,
|
||||
content: editData.newContent,
|
||||
toolWriteOrigin: 'edit',
|
||||
_meta: {
|
||||
bom: useBOM,
|
||||
},
|
||||
},
|
||||
});
|
||||
editData.sandboxFileVersion,
|
||||
signal,
|
||||
);
|
||||
} else {
|
||||
await this.config.getFileSystemService().writeTextFile({
|
||||
path: this.params.file_path,
|
||||
content: editData.newContent,
|
||||
toolWriteOrigin: 'edit',
|
||||
_meta: {
|
||||
bom: editData.bom,
|
||||
encoding: editData.encoding,
|
||||
lineEnding: editData.lineEnding,
|
||||
await writeRuntimeFile(
|
||||
this.config,
|
||||
{
|
||||
path: this.params.file_path,
|
||||
content: editData.newContent,
|
||||
toolWriteOrigin: 'edit',
|
||||
_meta: {
|
||||
bom: editData.bom,
|
||||
encoding: editData.encoding,
|
||||
lineEnding: editData.lineEnding,
|
||||
},
|
||||
},
|
||||
});
|
||||
editData.sandboxFileVersion,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
// Track AI contribution for commit attribution
|
||||
@@ -732,7 +753,14 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
||||
returnDisplay: `Error writing file: ${errorMsg}`,
|
||||
error: {
|
||||
message: errorMsg,
|
||||
type: ToolErrorType.FILE_WRITE_FAILURE,
|
||||
type:
|
||||
isNodeError(error) &&
|
||||
error.code === 'ESTALE' &&
|
||||
this.config.getShellExecutionSandbox?.()
|
||||
? ToolErrorType.FILE_CHANGED_SINCE_READ
|
||||
: isNodeError(error) && error.code === 'EISDIR'
|
||||
? ToolErrorType.TARGET_IS_DIRECTORY
|
||||
: ToolErrorType.FILE_WRITE_FAILURE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ vi.mock('node:os', async (importOriginal) => {
|
||||
|
||||
// Mock child_process.spawn
|
||||
const mockSpawn = vi.hoisted(() => vi.fn());
|
||||
const mockRuntimeShell = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../sandbox/runtime-shell.js', () => ({
|
||||
executeRuntimeShell: mockRuntimeShell,
|
||||
}));
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||
|
||||
@@ -140,6 +144,10 @@ vi.mock('../utils/shellAstParser.js', () => ({
|
||||
|
||||
import { MonitorTool, sanitizeMonitorLine } from './monitor.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type {
|
||||
ShellExecutionResult,
|
||||
ShellOutputEvent,
|
||||
} from '../services/shellExecutionService.js';
|
||||
import { MonitorRegistry } from '../services/monitorRegistry.js';
|
||||
import type { ToolCallConfirmationDetails } from './tools.js';
|
||||
import { runWithAgentContext } from '../agents/runtime/agent-context.js';
|
||||
@@ -201,6 +209,7 @@ describe('MonitorTool', () => {
|
||||
delete process.env['GIT_PAGER'];
|
||||
|
||||
vi.clearAllMocks();
|
||||
mockRuntimeShell.mockReset();
|
||||
mockOsPlatform.mockReturnValue('linux');
|
||||
|
||||
monitorRegistry = new MonitorRegistry();
|
||||
@@ -213,6 +222,7 @@ describe('MonitorTool', () => {
|
||||
|
||||
mockConfig = {
|
||||
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
|
||||
getShellExecutionSandbox: vi.fn().mockReturnValue(undefined),
|
||||
getMonitorRegistry: vi.fn().mockReturnValue(monitorRegistry),
|
||||
getPermissionManager: vi.fn().mockReturnValue(undefined),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
@@ -275,6 +285,127 @@ describe('MonitorTool', () => {
|
||||
}
|
||||
).createInvocation(params);
|
||||
|
||||
describe('tool execution sandbox', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockConfig.getShellExecutionSandbox).mockReturnValue(
|
||||
{} as NonNullable<ReturnType<Config['getShellExecutionSandbox']>>,
|
||||
);
|
||||
});
|
||||
|
||||
it('streams stdout and stderr independently and settles the existing registry', async () => {
|
||||
let output!: (event: ShellOutputEvent) => void;
|
||||
let signal!: AbortSignal;
|
||||
let finish!: (result: ShellExecutionResult) => void;
|
||||
const result = new Promise<ShellExecutionResult>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
mockRuntimeShell.mockImplementation(
|
||||
async (_config, _command, _cwd, onOutput, abortSignal) => {
|
||||
output = onOutput;
|
||||
signal = abortSignal;
|
||||
return { pid: 9876, result };
|
||||
},
|
||||
);
|
||||
const emit = vi.spyOn(monitorRegistry, 'emitEvent');
|
||||
const turn = new AbortController();
|
||||
await createInvocation({ command: 'watch command' }).execute(turn.signal);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(mockRuntimeShell).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
'watch command',
|
||||
'/test/dir',
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{ maxBufferedOutputBytes: 4096 },
|
||||
{ streamStdout: true },
|
||||
);
|
||||
const entry = monitorRegistry.getRunning()[0]!;
|
||||
expect(entry.pid).toBe(9876);
|
||||
turn.abort();
|
||||
expect(signal.aborted).toBe(false);
|
||||
output({ type: 'data', chunk: 'out', stream: 'stdout' });
|
||||
output({ type: 'data', chunk: 'err\n', stream: 'stderr' });
|
||||
output({ type: 'data', chunk: 'put\nlast', stream: 'stdout' });
|
||||
finish({ exitCode: 0, signal: null } as ShellExecutionResult);
|
||||
await result;
|
||||
await Promise.resolve();
|
||||
expect(emit.mock.calls.map((call) => call[1])).toEqual([
|
||||
'err',
|
||||
'output',
|
||||
'last',
|
||||
]);
|
||||
expect(entry.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('fails and stops a sandboxed monitor after binary output', async () => {
|
||||
let output!: (event: ShellOutputEvent) => void;
|
||||
let signal!: AbortSignal;
|
||||
mockRuntimeShell.mockImplementation(
|
||||
async (_config, _command, _cwd, onOutput, abortSignal) => {
|
||||
output = onOutput;
|
||||
signal = abortSignal;
|
||||
return { result: new Promise(() => {}) };
|
||||
},
|
||||
);
|
||||
await createInvocation({ command: 'watch command' }).execute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
const entry = monitorRegistry.getRunning()[0]!;
|
||||
output({ type: 'binary_detected' });
|
||||
expect(entry.status).toBe('failed');
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('fails an unconfirmed sandbox completion', async () => {
|
||||
mockRuntimeShell.mockResolvedValue({
|
||||
result: Promise.resolve({
|
||||
error: new Error('Sandbox termination is unconfirmed'),
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
}),
|
||||
});
|
||||
await createInvocation({ command: 'watch command' }).execute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(monitorRegistry.getAll()[0]?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('stops through the monitor abort controller without replaying on the host', async () => {
|
||||
let signal!: AbortSignal;
|
||||
mockRuntimeShell.mockImplementation(
|
||||
async (_config, _command, _cwd, _output, abortSignal) => {
|
||||
signal = abortSignal;
|
||||
return { result: new Promise(() => {}) };
|
||||
},
|
||||
);
|
||||
await createInvocation({ command: 'watch command' }).execute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
monitorRegistry.cancel(monitorRegistry.getRunning()[0]!.monitorId);
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails setup without falling back to a host process', async () => {
|
||||
mockRuntimeShell.mockRejectedValue(new Error('sandbox unavailable'));
|
||||
const result = await createInvocation({
|
||||
command: 'watch command',
|
||||
}).execute(new AbortController().signal);
|
||||
expect(result.llmContent).toContain('sandbox unavailable');
|
||||
expect(monitorRegistry.getRunning()).toEqual([]);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not run host AST permission probes', async () => {
|
||||
const invocation = createInvocation({ command: 'git status' });
|
||||
expect(await invocation.getDefaultPermission()).toBe('ask');
|
||||
await invocation.getConfirmationDetails(new AbortController().signal);
|
||||
expect(mockIsShellCommandReadOnlyAST).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema', () => {
|
||||
it('declares monitor limits as integers', () => {
|
||||
const schema = monitorTool.schema.parametersJsonSchema as {
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
* Auto-stops after max_events or idle_timeout_ms of silence.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { executeRuntimeShell } from '../sandbox/runtime-shell.js';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -171,6 +172,7 @@ class MonitorToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
|
||||
override async getDefaultPermission(): Promise<PermissionDecision> {
|
||||
if (this.config.getShellExecutionSandbox?.()) return 'ask';
|
||||
const normalized = normalizeMonitorShellCommand(this.params.command);
|
||||
const command = normalized.safetyCommand;
|
||||
const cwd =
|
||||
@@ -222,7 +224,8 @@ class MonitorToolInvocation extends BaseToolInvocation<
|
||||
// permission boundary.
|
||||
let isReadOnly = false;
|
||||
try {
|
||||
isReadOnly = await isShellCommandReadOnlyASTInDirectory(sub, cwd);
|
||||
if (!this.config.getShellExecutionSandbox?.())
|
||||
isReadOnly = await isShellCommandReadOnlyASTInDirectory(sub, cwd);
|
||||
} catch (e) {
|
||||
// Conservative fallback: if AST analysis fails, keep the sub-command
|
||||
// in the confirmation scope instead of accidentally dropping it.
|
||||
@@ -365,23 +368,25 @@ class MonitorToolInvocation extends BaseToolInvocation<
|
||||
|
||||
// Spawn the process
|
||||
const { executable, argsPrefix } = getShellConfiguration();
|
||||
let child;
|
||||
const sandboxed = Boolean(this.config.getShellExecutionSandbox?.());
|
||||
let child: ChildProcess | undefined;
|
||||
try {
|
||||
child = spawn(executable, [...argsPrefix, command], {
|
||||
cwd: this.params.directory || this.config.getTargetDir(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
env: {
|
||||
...sanitizeChildEnv(process.env),
|
||||
QWEN_CODE: '1',
|
||||
TERM: 'dumb', // no color codes for streaming
|
||||
...getShellPagerEnv(this.config.getShellExecutionConfig().pager, {
|
||||
includeGitPager: false,
|
||||
platform: os.platform(),
|
||||
}),
|
||||
...getShellContextEnvVars(),
|
||||
},
|
||||
});
|
||||
if (!sandboxed)
|
||||
child = spawn(executable, [...argsPrefix, command], {
|
||||
cwd: this.params.directory || this.config.getTargetDir(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
env: {
|
||||
...sanitizeChildEnv(process.env),
|
||||
QWEN_CODE: '1',
|
||||
TERM: 'dumb', // no color codes for streaming
|
||||
...getShellPagerEnv(this.config.getShellExecutionConfig().pager, {
|
||||
includeGitPager: false,
|
||||
platform: os.platform(),
|
||||
}),
|
||||
...getShellContextEnvVars(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
llmContent: `Monitor failed to start: ${getErrorMessage(err)}`,
|
||||
@@ -389,7 +394,7 @@ class MonitorToolInvocation extends BaseToolInvocation<
|
||||
};
|
||||
}
|
||||
|
||||
registration.pid = child.pid;
|
||||
registration.pid = child?.pid;
|
||||
let exited = false;
|
||||
|
||||
// Capture async spawn errors (ENOENT, EACCES, etc.) during the window
|
||||
@@ -398,7 +403,7 @@ class MonitorToolInvocation extends BaseToolInvocation<
|
||||
const captureEarlySpawnError = (err: Error): void => {
|
||||
earlySpawnError ??= err;
|
||||
};
|
||||
child.on('error', captureEarlySpawnError);
|
||||
child?.on('error', captureEarlySpawnError);
|
||||
|
||||
// ----- Line buffering & throttling state ---------------------------------
|
||||
// Declared up-front (before `abortHandler`) so that the synchronous abort
|
||||
@@ -463,34 +468,33 @@ class MonitorToolInvocation extends BaseToolInvocation<
|
||||
};
|
||||
|
||||
const killChildProcessGroup = (): void => {
|
||||
if (exited || !child.pid) return;
|
||||
const pid = child?.pid;
|
||||
if (exited || !pid) return;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const tk = spawn(
|
||||
'taskkill',
|
||||
['/pid', child.pid.toString(), '/f', '/t'],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
const tk = spawn('taskkill', ['/pid', pid.toString(), '/f', '/t'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
tk.on('error', (err) =>
|
||||
debugLogger.warn(
|
||||
`Monitor taskkill failed for pid ${child.pid}: ${getErrorMessage(err)}`,
|
||||
`Monitor taskkill failed for pid ${pid}: ${getErrorMessage(err)}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
process.kill(-pid, 'SIGTERM');
|
||||
} catch (err) {
|
||||
debugLogger.warn(
|
||||
`Monitor ${monitorId} SIGTERM failed (pid=${child.pid}): ${getErrorMessage(err)}`,
|
||||
`Monitor ${monitorId} SIGTERM failed (pid=${pid}): ${getErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!exited && child.pid) {
|
||||
if (!exited) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
} catch (err) {
|
||||
debugLogger.warn(
|
||||
`Monitor ${monitorId} SIGKILL escalation failed (pid=${child.pid}): ${getErrorMessage(err)}`,
|
||||
`Monitor ${monitorId} SIGKILL escalation failed (pid=${pid}): ${getErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -519,23 +523,28 @@ class MonitorToolInvocation extends BaseToolInvocation<
|
||||
abortHandler();
|
||||
entryAc.signal.removeEventListener('abort', abortHandler);
|
||||
(
|
||||
child.stdout as { destroy?: () => void } | null | undefined
|
||||
child?.stdout as { destroy?: () => void } | null | undefined
|
||||
)?.destroy?.();
|
||||
(
|
||||
child.stderr as { destroy?: () => void } | null | undefined
|
||||
child?.stderr as { destroy?: () => void } | null | undefined
|
||||
)?.destroy?.();
|
||||
child.removeListener('error', captureEarlySpawnError);
|
||||
child.on('error', () => {});
|
||||
child?.removeListener('error', captureEarlySpawnError);
|
||||
child?.on('error', () => {});
|
||||
return {
|
||||
llmContent: `Monitor failed to start: ${getErrorMessage(err)}`,
|
||||
returnDisplay: `Monitor failed: ${getErrorMessage(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const processLines = (buffer: { value: string }, data: Buffer): void => {
|
||||
const processLines = (
|
||||
buffer: { value: string },
|
||||
data: Buffer | string,
|
||||
): void => {
|
||||
if (registration.status !== 'running') return;
|
||||
|
||||
const text = stripAnsi(data.toString('utf-8'));
|
||||
const text = stripAnsi(
|
||||
typeof data === 'string' ? data : data.toString('utf-8'),
|
||||
);
|
||||
buffer.value += text;
|
||||
|
||||
// Guard against unbounded partial-line accumulation. If a command emits
|
||||
@@ -570,8 +579,8 @@ class MonitorToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on('data', (data: Buffer) => processLines(stdoutBuf, data));
|
||||
child.stderr?.on('data', (data: Buffer) => processLines(stderrBuf, data));
|
||||
child?.stdout?.on('data', (data: Buffer) => processLines(stdoutBuf, data));
|
||||
child?.stderr?.on('data', (data: Buffer) => processLines(stderrBuf, data));
|
||||
|
||||
// Shared cleanup: flush buffers, remove abort listener, log dropped lines.
|
||||
// Called from `close` after stdio streams drain, and from `error` when no
|
||||
@@ -604,7 +613,7 @@ class MonitorToolInvocation extends BaseToolInvocation<
|
||||
|
||||
const settleFromExit = (
|
||||
code: number | null,
|
||||
sig: NodeJS.Signals | null,
|
||||
sig: NodeJS.Signals | number | null,
|
||||
): void => {
|
||||
if (registration.status !== 'running') return; // already settled
|
||||
|
||||
@@ -640,10 +649,70 @@ class MonitorToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
};
|
||||
|
||||
child.on('exit', onExit);
|
||||
child.on('close', onClose);
|
||||
child.on('error', onError);
|
||||
child.removeListener('error', captureEarlySpawnError);
|
||||
child?.on('exit', onExit);
|
||||
child?.on('close', onClose);
|
||||
child?.on('error', onError);
|
||||
child?.removeListener('error', captureEarlySpawnError);
|
||||
|
||||
if (sandboxed) {
|
||||
try {
|
||||
const shellExecutionConfig = this.config.getShellExecutionConfig();
|
||||
const handle = await executeRuntimeShell(
|
||||
this.config,
|
||||
command,
|
||||
this.params.directory || this.config.getTargetDir(),
|
||||
(event) => {
|
||||
if (event.type === 'binary_detected') {
|
||||
if (registration.status === 'running') {
|
||||
registry.fail(monitorId, 'Binary output detected');
|
||||
entryAc.abort();
|
||||
}
|
||||
} else if (
|
||||
event.type === 'data' &&
|
||||
typeof event.chunk === 'string'
|
||||
) {
|
||||
processLines(
|
||||
event.stream === 'stderr' ? stderrBuf : stdoutBuf,
|
||||
event.chunk,
|
||||
);
|
||||
}
|
||||
},
|
||||
entryAc.signal,
|
||||
false,
|
||||
{
|
||||
...shellExecutionConfig,
|
||||
maxBufferedOutputBytes: Math.min(
|
||||
shellExecutionConfig.maxBufferedOutputBytes ??
|
||||
PARTIAL_LINE_BUFFER_CAP,
|
||||
PARTIAL_LINE_BUFFER_CAP,
|
||||
),
|
||||
},
|
||||
{ streamStdout: true },
|
||||
);
|
||||
registration.pid = handle.pid;
|
||||
void handle.result.then(
|
||||
(result) => {
|
||||
exited = true;
|
||||
cleanup();
|
||||
if (result.error) {
|
||||
if (registration.status === 'running')
|
||||
registry.fail(monitorId, result.error.message);
|
||||
} else {
|
||||
settleFromExit(result.exitCode, result.signal);
|
||||
}
|
||||
},
|
||||
(error: unknown) =>
|
||||
onError(error instanceof Error ? error : new Error(String(error))),
|
||||
);
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error : new Error(String(error)));
|
||||
return {
|
||||
llmContent: `Monitor failed to start: ${getErrorMessage(error)}`,
|
||||
returnDisplay: `Monitor failed: ${getErrorMessage(error)}`,
|
||||
error: { message: getErrorMessage(error) },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (earlySpawnError) {
|
||||
onError(earlySpawnError);
|
||||
|
||||
@@ -15,6 +15,13 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
const mockShellExecutionService = vi.hoisted(() => vi.fn());
|
||||
const mockExecuteBwrap = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../sandbox/bwrap-execution.js', () => ({
|
||||
executeBwrap: mockExecuteBwrap,
|
||||
}));
|
||||
vi.mock('../sandbox/runtime-shell-policy.js', () => ({
|
||||
assertShellSandboxCwd: vi.fn(),
|
||||
}));
|
||||
const mockExecFile = vi.hoisted(() => vi.fn());
|
||||
const mockDebugLogger = vi.hoisted(() => ({
|
||||
debug: vi.fn(),
|
||||
@@ -288,6 +295,124 @@ describe('ShellTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('internal runtime sandbox routing', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig.getShellExecutionSandbox = vi.fn().mockReturnValue({
|
||||
workspace: '/test/dir',
|
||||
installation: '/install',
|
||||
state: '/state',
|
||||
filesystem: 'workspace-write',
|
||||
network: 'closed',
|
||||
});
|
||||
mockExecuteBwrap.mockResolvedValue({
|
||||
pid: 12345,
|
||||
result: Promise.resolve({
|
||||
rawOutput: Buffer.alloc(0),
|
||||
output: 'confined',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
sandboxStatus: { state: 'confirmed', exitCode: 0 },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('runs sed through the backend without host preview or write', async () => {
|
||||
const invocation = shellTool.build({
|
||||
command: "sed -i 's/old/new/' file.txt",
|
||||
is_background: false,
|
||||
});
|
||||
expect(
|
||||
(await invocation.getConfirmationDetails(new AbortController().signal))
|
||||
.type,
|
||||
).toBe('exec');
|
||||
await invocation.execute(new AbortController().signal);
|
||||
expect(mockExecuteBwrap).toHaveBeenCalledOnce();
|
||||
expect(mockFileSystemService.readTextFile).not.toHaveBeenCalled();
|
||||
expect(mockFileSystemService.writeTextFile).not.toHaveBeenCalled();
|
||||
expect(mockShellExecutionService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Git/PR metadata subprocesses off the host', async () => {
|
||||
const gitSpy = vi.spyOn(
|
||||
await import('node:child_process'),
|
||||
'execFileSync',
|
||||
);
|
||||
try {
|
||||
await shellTool
|
||||
.build({ command: 'git commit -m test', is_background: false })
|
||||
.execute(new AbortController().signal);
|
||||
await shellTool
|
||||
.build({
|
||||
command: 'gh pr create --title test --body test',
|
||||
is_background: false,
|
||||
})
|
||||
.execute(new AbortController().signal);
|
||||
await shellTool
|
||||
.build({
|
||||
command: 'gh pr create --title background --body background',
|
||||
is_background: true,
|
||||
})
|
||||
.execute(new AbortController().signal);
|
||||
expect(mockExecuteBwrap).toHaveBeenCalledTimes(3);
|
||||
expect(mockExecuteBwrap.mock.calls[0][1].args).toEqual([
|
||||
'-c',
|
||||
'git commit -m test',
|
||||
]);
|
||||
expect(mockExecuteBwrap.mock.calls[1][1].args).toEqual([
|
||||
'-c',
|
||||
'gh pr create --title test --body test',
|
||||
]);
|
||||
expect(mockExecuteBwrap.mock.calls[2][1].args).toEqual([
|
||||
'-c',
|
||||
'gh pr create --title background --body background',
|
||||
]);
|
||||
expect(gitSpy).not.toHaveBeenCalled();
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
expect(fetchCurrentBranchPullRequest).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
gitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes background execution and closes its stream on setup failure', async () => {
|
||||
const destroy = vi.fn();
|
||||
vi.mocked(fs.createWriteStream).mockReturnValue({
|
||||
on: vi.fn(),
|
||||
destroy,
|
||||
} as unknown as fs.WriteStream);
|
||||
mockExecuteBwrap.mockRejectedValueOnce(new Error('sandbox setup failed'));
|
||||
await expect(
|
||||
shellTool
|
||||
.build({ command: 'echo test', is_background: true })
|
||||
.execute(new AbortController().signal),
|
||||
).rejects.toThrow('sandbox setup failed');
|
||||
expect(mockExecuteBwrap.mock.calls[0][4]).toBe(false);
|
||||
expect(destroy).toHaveBeenCalledOnce();
|
||||
expect(fs.rmSync).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\.output$/),
|
||||
{
|
||||
force: true,
|
||||
},
|
||||
);
|
||||
expect(
|
||||
mockConfig.getBackgroundShellRegistry().register,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(mockShellExecutionService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses a conservative permission default without host git probes', async () => {
|
||||
expect(
|
||||
await shellTool
|
||||
.build({ command: 'git status', is_background: false })
|
||||
.getDefaultPermission(),
|
||||
).toBe('ask');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gh pr create binding', () => {
|
||||
const fetchCurrentBranchPullRequestMock = vi.mocked(
|
||||
fetchCurrentBranchPullRequest,
|
||||
|
||||
@@ -10,6 +10,7 @@ import os from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
import * as childProcess from 'node:child_process';
|
||||
import { ApprovalMode, type Config } from '../config/config.js';
|
||||
import { executeRuntimeShell } from '../sandbox/runtime-shell.js';
|
||||
import { ToolNames, ToolDisplayNames } from './tool-names.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import type {
|
||||
@@ -1703,6 +1704,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
|
||||
private getSedEditInfo(): SedEditInfo | null {
|
||||
if (
|
||||
this.config.getShellExecutionSandbox?.() ||
|
||||
this.params.is_background ||
|
||||
LEADING_ENV_ASSIGNMENT_RE.test(this.params.command)
|
||||
) {
|
||||
@@ -2052,6 +2054,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
* - All other commands → 'ask'
|
||||
*/
|
||||
override async getDefaultPermission(): Promise<PermissionDecision> {
|
||||
if (this.config.getShellExecutionSandbox?.()) return 'ask';
|
||||
// Gate on the RAW command before `stripShellWrapper` runs.
|
||||
// `stripShellWrapper` drops leading env-assignment tokens AND
|
||||
// unwraps `bash -c '...'` to its inner script — so for
|
||||
@@ -2142,6 +2145,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
const subCommands = splitCommands(command);
|
||||
const confirmableSubCommands: string[] = [];
|
||||
for (const sub of subCommands) {
|
||||
if (this.config.getShellExecutionSandbox?.()) {
|
||||
confirmableSubCommands.push(sub);
|
||||
continue;
|
||||
}
|
||||
let isReadOnly = false;
|
||||
try {
|
||||
isReadOnly = await isShellCommandReadOnlyASTInDirectory(sub, cwd);
|
||||
@@ -2313,9 +2320,11 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
// are preserved through to execution; the rewriters operate at the
|
||||
// top-level shell layer and become no-ops when the commit hides
|
||||
// inside a wrapper.
|
||||
const processedCommand = this.addAttributionToPR(
|
||||
this.addCoAuthorToGitCommit(this.params.command.trim()),
|
||||
);
|
||||
const processedCommand = this.config.getShellExecutionSandbox?.()
|
||||
? this.params.command.trim()
|
||||
: this.addAttributionToPR(
|
||||
this.addCoAuthorToGitCommit(this.params.command.trim()),
|
||||
);
|
||||
const commandToExecute = processedCommand;
|
||||
const cwd = this.params.directory || this.config.getTargetDir();
|
||||
|
||||
@@ -2343,9 +2352,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
// `git -C /other commit`), no consumer reads preHead and the
|
||||
// ~10–50 ms execFileSync is dead work that just blocks the
|
||||
// event loop before the user's real command spawns.
|
||||
const preHead: string | null = commitCtx.attributableInCwd
|
||||
? this.getGitHeadSync(cwd)
|
||||
: null;
|
||||
const preHead: string | null =
|
||||
!this.config.getShellExecutionSandbox?.() && commitCtx.attributableInCwd
|
||||
? this.getGitHeadSync(cwd)
|
||||
: null;
|
||||
|
||||
// Snapshot the attribution inputs BEFORE spawn so bindGhPrCreate can
|
||||
// tell a run that CREATED a PR from one that merely RESOLVED the
|
||||
@@ -2360,7 +2370,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
let preRunBranch: string | undefined;
|
||||
let preRunRepoKeys: AttributionRepoKeys | undefined;
|
||||
let preRunGhEnv: Readonly<Record<string, string | undefined>> | undefined;
|
||||
if (commandRunsGhPrCreate(commandToExecute)) {
|
||||
if (
|
||||
!this.config.getShellExecutionSandbox?.() &&
|
||||
commandRunsGhPrCreate(commandToExecute)
|
||||
) {
|
||||
// The verification legs must authenticate the way the create itself
|
||||
// does (inline GH_TOKEN with no ambient gh auth), or the gate's
|
||||
// advertised token shape binds nothing. The inline record is an
|
||||
@@ -2618,7 +2631,8 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
|
||||
let executionHandle;
|
||||
try {
|
||||
executionHandle = await ShellExecutionService.execute(
|
||||
executionHandle = await executeRuntimeShell(
|
||||
this.config,
|
||||
commandToExecute,
|
||||
cwd,
|
||||
onShellOutputEvent,
|
||||
@@ -2855,7 +2869,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
// succeeded but the per-file git note didn't land — without it, the only
|
||||
// signal is a QWEN_DEBUG_LOG_FILE entry the user has likely never set up.
|
||||
let attributionWarning: string | null = null;
|
||||
if (commitCtx.attributableInCwd) {
|
||||
if (
|
||||
!this.config.getShellExecutionSandbox?.() &&
|
||||
commitCtx.attributableInCwd
|
||||
) {
|
||||
// `git commit --amend` rewrites HEAD in place, so the standard
|
||||
// parent-vs-postHead diff (`${postHead}~1..${postHead}`) would
|
||||
// span the entire amended commit (the amended commit's parent
|
||||
@@ -3175,6 +3192,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
preRunRepoKeys: AttributionRepoKeys | undefined,
|
||||
ghCreateEnv?: Readonly<Record<string, string | undefined>>,
|
||||
): void {
|
||||
if (this.config.getShellExecutionSandbox?.()) return;
|
||||
void (async () => {
|
||||
try {
|
||||
if (!commandRunsGhPrCreate(command)) return;
|
||||
@@ -3836,9 +3854,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
'Stripped trailing & from background shell command — managed path handles backgrounding',
|
||||
);
|
||||
}
|
||||
const processedCommand = this.addAttributionToPR(
|
||||
this.addCoAuthorToGitCommit(noTrailingAmp),
|
||||
);
|
||||
const processedCommand = this.config.getShellExecutionSandbox?.()
|
||||
? noTrailingAmp
|
||||
: this.addAttributionToPR(this.addCoAuthorToGitCommit(noTrailingAmp));
|
||||
const cwd = this.params.directory || this.config.getTargetDir();
|
||||
|
||||
// Output goes under the project temp dir (which `ReadFileTool`
|
||||
@@ -3887,32 +3905,47 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
abortController: entryAc,
|
||||
};
|
||||
|
||||
const { result: resultPromise, pid } = await ShellExecutionService.execute(
|
||||
processedCommand,
|
||||
cwd,
|
||||
(event: ShellOutputEvent) => {
|
||||
if (event.type === 'data' && typeof event.chunk === 'string') {
|
||||
// Strip ANSI escape codes (color, cursor-move, clear-screen) before
|
||||
// writing — agents read the file as plain text, and dev servers /
|
||||
// build tools spam plenty of escape sequences that would render as
|
||||
// garbage. Costs ~one regex per chunk; cheap relative to disk I/O.
|
||||
outputStream.write(stripAnsi(event.chunk));
|
||||
}
|
||||
// ANSI array chunks and binary streams are not written to the output
|
||||
// file: agents read the file as plain text and binary spam would be
|
||||
// unhelpful.
|
||||
},
|
||||
entryAc.signal,
|
||||
// Background shells are non-interactive by design — no terminal to
|
||||
// attach a PTY to, no human to type at it. Force the child_process
|
||||
// path so we don't pull in node-pty for fire-and-forget commands.
|
||||
false,
|
||||
shellExecutionConfig ?? {},
|
||||
// Stream stdout/stderr through to the output file as chunks arrive.
|
||||
// Default child_process mode buffers until exit, which would leave
|
||||
// dev-server / watcher output files empty until the process dies.
|
||||
{ streamStdout: true },
|
||||
);
|
||||
let executionHandle;
|
||||
try {
|
||||
executionHandle = await executeRuntimeShell(
|
||||
this.config,
|
||||
processedCommand,
|
||||
cwd,
|
||||
(event: ShellOutputEvent) => {
|
||||
if (event.type === 'data' && typeof event.chunk === 'string') {
|
||||
// Strip ANSI escape codes (color, cursor-move, clear-screen) before
|
||||
// writing — agents read the file as plain text, and dev servers /
|
||||
// build tools spam plenty of escape sequences that would render as
|
||||
// garbage. Costs ~one regex per chunk; cheap relative to disk I/O.
|
||||
outputStream.write(stripAnsi(event.chunk));
|
||||
}
|
||||
// ANSI array chunks and binary streams are not written to the output
|
||||
// file: agents read the file as plain text and binary spam would be
|
||||
// unhelpful.
|
||||
},
|
||||
entryAc.signal,
|
||||
// Background shells are non-interactive by design — no terminal to
|
||||
// attach a PTY to, no human to type at it. Force the child_process
|
||||
// path so we don't pull in node-pty for fire-and-forget commands.
|
||||
false,
|
||||
shellExecutionConfig ?? {},
|
||||
// Stream stdout/stderr through to the output file as chunks arrive.
|
||||
// Default child_process mode buffers until exit, which would leave
|
||||
// dev-server / watcher output files empty until the process dies.
|
||||
{ streamStdout: true },
|
||||
);
|
||||
} catch (error) {
|
||||
outputStream.destroy();
|
||||
try {
|
||||
fs.rmSync(outputPath, { force: true });
|
||||
} catch (cleanupError) {
|
||||
debugLogger.warn(
|
||||
`background shell ${shellId} output cleanup failed: ${getErrorMessage(cleanupError)}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const { result: resultPromise, pid } = executionHandle;
|
||||
|
||||
if (pid !== undefined) registration.pid = pid;
|
||||
const registry = this.config.getBackgroundShellRegistry();
|
||||
|
||||
@@ -8,6 +8,10 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { ApprovalMode } from '../config/config.js';
|
||||
import {
|
||||
captureRuntimeFileVersion,
|
||||
writeRuntimeFile,
|
||||
} from '../sandbox/runtime-file.js';
|
||||
import { isAnyAutoMemPath, isTeamAutoMemPath } from '../memory/paths.js';
|
||||
import { checkTeamMemorySecrets } from '../memory/team-memory-secret-guard.js';
|
||||
import type {
|
||||
@@ -292,10 +296,28 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
return confirmationDetails;
|
||||
}
|
||||
|
||||
async execute(_abortSignal: AbortSignal): Promise<ToolResult> {
|
||||
async execute(abortSignal: AbortSignal): Promise<ToolResult> {
|
||||
const { file_path, content, ai_proposed_content, modified_by_user } =
|
||||
this.params;
|
||||
|
||||
let sandboxFileVersion: ReturnType<typeof captureRuntimeFileVersion>;
|
||||
try {
|
||||
sandboxFileVersion = captureRuntimeFileVersion(this.config, file_path);
|
||||
} catch (error) {
|
||||
const message = `${file_path}: ${getErrorMessage(error)}`;
|
||||
return {
|
||||
llmContent: message,
|
||||
returnDisplay: message,
|
||||
error: {
|
||||
message,
|
||||
type:
|
||||
isNodeError(error) && error.code === 'EISDIR'
|
||||
? ToolErrorType.TARGET_IS_DIRECTORY
|
||||
: ToolErrorType.FILE_WRITE_FAILURE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let fileExists = await isFilefileExists(file_path);
|
||||
let originalContent = '';
|
||||
let useBOM = false;
|
||||
@@ -467,14 +489,9 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
//
|
||||
// It does NOT eliminate the race. A concurrent writer that
|
||||
// lands between this stat and the writeTextFile call below
|
||||
// can still be clobbered — that residual is an OS-level
|
||||
// limitation of the stat-then-write pattern, and the only way
|
||||
// to close it is an atomic write (write-to-temp + rename) or
|
||||
// a content-hash post-check that re-reads the bytes after the
|
||||
// write. Both are deferred to a follow-up; operators who care
|
||||
// about strict overwrite-protection should set
|
||||
// `fileReadCacheDisabled: true` and rely on application-level
|
||||
// locking.
|
||||
// can still be clobbered. Atomic replacement is not compare-and-swap;
|
||||
// the sandbox worker rechecks the prepared version before commit,
|
||||
// but strict protection against concurrent writers requires locking.
|
||||
//
|
||||
// Run unconditionally (not gated on `fileExists`): if the path
|
||||
// was absent during the earlier checkPriorRead but a different
|
||||
@@ -518,21 +535,26 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
// directories on the failure path (rejected new-file writes
|
||||
// would otherwise litter the filesystem with empty mkdir'd
|
||||
// ancestors).
|
||||
if (!fileExists) {
|
||||
if (!fileExists && !this.config.getShellExecutionSandbox?.()) {
|
||||
fs.mkdirSync(dirName, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await this.config.getFileSystemService().writeTextFile({
|
||||
path: file_path,
|
||||
content,
|
||||
toolWriteOrigin: 'write_file',
|
||||
_meta: {
|
||||
bom: useBOM,
|
||||
encoding: detectedEncoding,
|
||||
lineEnding: detectedLineEnding,
|
||||
await writeRuntimeFile(
|
||||
this.config,
|
||||
{
|
||||
path: file_path,
|
||||
content,
|
||||
toolWriteOrigin: 'write_file',
|
||||
_meta: {
|
||||
bom: useBOM,
|
||||
encoding: detectedEncoding,
|
||||
lineEnding: detectedLineEnding,
|
||||
},
|
||||
},
|
||||
});
|
||||
sandboxFileVersion,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
// Track AI contribution for commit attribution.
|
||||
// Pass null only when the file truly did not exist before this write;
|
||||
@@ -653,7 +675,12 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
errorMsg = `Error writing to file '${file_path}': ${error.message} (${error.code})`;
|
||||
|
||||
// Log specific error types for better debugging
|
||||
if (error.code === 'EACCES') {
|
||||
if (
|
||||
error.code === 'ESTALE' &&
|
||||
this.config.getShellExecutionSandbox?.()
|
||||
) {
|
||||
errorType = ToolErrorType.FILE_CHANGED_SINCE_READ;
|
||||
} else if (error.code === 'EACCES') {
|
||||
errorMsg = `Permission denied writing to file: ${file_path} (${error.code})`;
|
||||
errorType = ToolErrorType.PERMISSION_DENIED;
|
||||
} else if (error.code === 'ENOSPC') {
|
||||
|
||||
@@ -1164,6 +1164,31 @@ describe('fileUtils', () => {
|
||||
actualNodeFs.unlinkSync(testBinaryFilePath);
|
||||
});
|
||||
|
||||
it.each([undefined, '1-2'])(
|
||||
'rejects sandbox PDF processing before invoking host helpers (pages=%s)',
|
||||
async (pages) => {
|
||||
actualNodeFs.writeFileSync(testPdfFilePath, '%PDF-1.4\n');
|
||||
const config = {
|
||||
...mockConfig,
|
||||
getShellExecutionSandbox: () => ({
|
||||
workspace: tempRootDir,
|
||||
installation: '/installation',
|
||||
state: '/state',
|
||||
filesystem: 'workspace-write',
|
||||
network: 'closed',
|
||||
}),
|
||||
} as Config;
|
||||
const result = await processSingleFileContent(testPdfFilePath, config, {
|
||||
fileType: 'pdf',
|
||||
pages,
|
||||
});
|
||||
expect(result.errorType).toBe(ToolErrorType.READ_CONTENT_FAILURE);
|
||||
expect(result.error).toContain('sandboxed Shell');
|
||||
expect(execFile).not.toHaveBeenCalled();
|
||||
expect(mockRender).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('should read a text file successfully', async () => {
|
||||
const content = 'Line 1\\nLine 2\\nLine 3';
|
||||
actualNodeFs.writeFileSync(testTextFilePath, content);
|
||||
|
||||
@@ -1230,6 +1230,16 @@ export async function processSingleFileContent(
|
||||
? 'text'
|
||||
: (options.fileType ??
|
||||
(bridgePreservesImage ? 'image' : await detectFileType(filePath)));
|
||||
if (fileType === 'pdf' && config.getShellExecutionSandbox?.()) {
|
||||
const message =
|
||||
'PDF processing is unavailable in this sandbox mode. Use a PDF utility through the sandboxed Shell tool.';
|
||||
return {
|
||||
llmContent: message,
|
||||
returnDisplay: message,
|
||||
error: message,
|
||||
errorType: ToolErrorType.READ_CONTENT_FAILURE,
|
||||
};
|
||||
}
|
||||
const shouldRenderImageOverview =
|
||||
fileType === 'image' && CANONICAL_IMAGE_MIME_TYPES.has(mediaMimeType);
|
||||
const displayName = path.basename(displayPath);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Internal runtime tool sandbox acceptance
|
||||
|
||||
These test scripts exercise the production `loadCliConfig`, authentication, noninteractive model/tool loop, Read/Write/Edit/Shell tools, and background task registry. They inject the trusted host policy through a test launcher. The ordinary public CLI does not accept this internal mode.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Install repository development dependencies and build workspace packages with Node.js 22 or newer.
|
||||
- Run the candidate on Linux with working unprivileged bubblewrap at `/usr/bin/bwrap`, Node.js at `/usr/bin/node`, and Bash at `/bin/bash`.
|
||||
- Keep the installation, workspace, runtime state, and HOME directories separate. Tests create their own disposable fixtures under `/tmp`.
|
||||
- If testing native PTY transport, provide the Linux `@lydell/node-pty` dependency in the test installation's `node_modules`; do not reuse macOS native packages. The separate `scripts/sandbox-prototype` verifier checks both transport implementations.
|
||||
|
||||
No model credentials or internet endpoint are needed. The driver serves a deterministic OpenAI-compatible endpoint over loopback and supplies only a synthetic API key. It clears ambient environment variables and creates an isolated HOME/runtime for each case. It does not change kernel configuration, AppArmor, or shared dependencies.
|
||||
|
||||
## Before implementation baseline
|
||||
|
||||
Run `node scripts/sandbox-runtime/baseline.mjs` on the development host with a global `qwen` installation. It drives the global CLI through a real model/tool turn and confirms the ordinary baseline can write both inside the disposable workspace and to a sibling directory. Evidence goes under `.qwen/e2e-tests/runtime-shell-sandbox` by default; an optional first argument overrides that output directory. Preserve the dated baseline evidence before implementing the feature.
|
||||
|
||||
## Build and verify a candidate
|
||||
|
||||
1. Run `node scripts/sandbox-runtime/build.mjs /absolute/empty/installation` from the exact source revision under test. The directory must be empty. The builder resolves repository paths from its own location and bundles the actual CLI and core sources into one production headless launcher. The manifest records the Git revision, worktree dirtiness, source inputs, all test scripts, and output hashes.
|
||||
2. Copy the complete installation to the Linux host if necessary. It must be outside the test workspaces. Link Linux native dependencies into this owned installation when possible. If they must be installed there, use npm with `--no-save --package-lock=false`; changing the manifest-tracked `package.json` invalidates the candidate by design.
|
||||
3. Keep the exact source checkout available on Linux, then run `node /absolute/installation/verify.mjs /absolute/installation /tmp/runtime-shell-report.json /absolute/source-checkout`. The verifier refuses a different revision, dirty state, or source-input hash before exercising the candidate. Store output on a writable Linux filesystem. Lima may mount the host repository read-only; copy the report back afterward.
|
||||
4. Retain stdout and the JSON report under `.qwen/e2e-tests`. The driver verifies artifact hashes again after execution, and exits nonzero on any failed check.
|
||||
|
||||
The checks cover workspace/network enforcement, host model/session persistence, repository clean filters during prompt startup, sed and unsupported NotebookEdit behavior, read-only/YOLO, two Config instances in the same process, ignored ambient startup effects, explicit unsupported MCP/extensions/LSP/ACP startup rejection, setup failure without replay, and real ShellTool cancellation/timeout/background/promotion/receipt-loss lifecycle. File checks cover Read-based freshness, new-file creation, UTF-8 and UTF-16 encoding/BOM/CRLF preservation, modes, relative and dangling symlinks, direct and symlinked outside-write denial without directory or temporary-file litter, read-only/YOLO, content larger than one MiB, special-file rejection, PDF host-helper exclusion, and setup failure without host fallback. Both Config instances also exercise independent file-write grants. The Git fixture confirms that the filter is exercised by an explicit confined Git tool call while its outside write is denied. Namespace cleanup checks compare `/proc/self/ns/pid` identities and reject the host namespace before enumerating descendants.
|
||||
|
||||
Fixtures and their stdout/stderr/session files remain under the unique `/tmp/qwen-runtime-shell-candidate-*` directory named in the report for inspection. Remove only that owned directory and the owned installation when the evidence is archived. The driver confirms all controlled payload namespaces have no remaining descendants. Production source is never edited by these scripts.
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
const output =
|
||||
process.argv[2] ??
|
||||
path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../../.qwen/e2e-tests/runtime-shell-sandbox',
|
||||
);
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-runtime-shell-baseline-'),
|
||||
);
|
||||
const workspace = path.join(root, 'workspace');
|
||||
const outside = path.join(root, 'outside');
|
||||
const home = path.join(root, 'home');
|
||||
const runtime = path.join(root, 'runtime');
|
||||
await Promise.all([workspace, outside, home, runtime].map((p) => fs.mkdir(p)));
|
||||
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
const command = `printf workspace-ok > ${quote(path.join(workspace, 'inside.txt'))}; printf outside-ok > ${quote(path.join(outside, 'outside.txt'))}; printf baseline-executed`;
|
||||
const requests = [];
|
||||
const server = http.createServer(async (req, res) => {
|
||||
let raw = '';
|
||||
for await (const data of req) raw += data;
|
||||
const body = JSON.parse(raw);
|
||||
const tools = body.messages?.filter((m) => m.role === 'tool') ?? [];
|
||||
requests.push({
|
||||
url: req.url,
|
||||
tools: body.tools?.map((t) => t.function?.name),
|
||||
toolResults: tools,
|
||||
stream: body.stream,
|
||||
});
|
||||
const toolCalls = tools.length
|
||||
? undefined
|
||||
: [
|
||||
{
|
||||
id: 'call_baseline_shell',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'run_shell_command',
|
||||
arguments: JSON.stringify({
|
||||
command,
|
||||
is_background: false,
|
||||
timeout: 10000,
|
||||
}),
|
||||
},
|
||||
},
|
||||
];
|
||||
const message = {
|
||||
role: 'assistant',
|
||||
content: tools.length ? 'BASELINE_COMPLETE' : '',
|
||||
...(toolCalls ? { tool_calls: toolCalls } : {}),
|
||||
};
|
||||
const common = {
|
||||
id: 'chatcmpl-baseline',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: 'mock-model',
|
||||
};
|
||||
const finish = toolCalls ? 'tool_calls' : 'stop';
|
||||
const usage = {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 120,
|
||||
};
|
||||
if (!body.stream) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
...common,
|
||||
object: 'chat.completion',
|
||||
choices: [{ index: 0, message, finish_reason: finish }],
|
||||
usage,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
||||
const send = (delta, finish_reason = null, extra = {}) =>
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ ...common, object: 'chat.completion.chunk', choices: [{ index: 0, delta, finish_reason }], ...extra })}\n\n`,
|
||||
);
|
||||
send({ role: 'assistant', content: '' });
|
||||
if (toolCalls)
|
||||
send({ tool_calls: toolCalls.map((t, index) => ({ index, ...t })) });
|
||||
else send({ content: message.content });
|
||||
send({}, finish, { usage });
|
||||
res.end('data: [DONE]\n\n');
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const port = server.address().port;
|
||||
const binary = spawnSync('/bin/zsh', ['-lc', 'command -v qwen'], {
|
||||
encoding: 'utf8',
|
||||
}).stdout.trim();
|
||||
const version = spawnSync(binary, ['--version'], {
|
||||
encoding: 'utf8',
|
||||
}).stdout.trim();
|
||||
const args = [
|
||||
'--bare',
|
||||
'--auth-type',
|
||||
'openai',
|
||||
'--openai-base-url',
|
||||
`http://127.0.0.1:${port}/v1`,
|
||||
'--openai-api-key',
|
||||
'sk-mock',
|
||||
'--model',
|
||||
'mock-model',
|
||||
'--approval-mode',
|
||||
'yolo',
|
||||
'--output-format',
|
||||
'json',
|
||||
'-p',
|
||||
'Run the disposable fixture check',
|
||||
];
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
HOME: home,
|
||||
QWEN_RUNTIME_DIR: runtime,
|
||||
TMPDIR: root,
|
||||
LANG: 'en_US.UTF-8',
|
||||
TERM: 'dumb',
|
||||
NO_PROXY: '127.0.0.1,localhost',
|
||||
CI: 'true',
|
||||
};
|
||||
let stdout = '',
|
||||
stderr = '';
|
||||
const child = spawn(binary, args, {
|
||||
cwd: workspace,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
child.stdout.on('data', (chunk) => (stdout += chunk));
|
||||
child.stderr.on('data', (chunk) => (stderr += chunk));
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), 60000);
|
||||
const result = await new Promise((resolve) =>
|
||||
child.on('exit', (code, signal) => resolve({ code, signal })),
|
||||
);
|
||||
clearTimeout(timer);
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
const read = async (p) => fs.readFile(p, 'utf8').catch(() => null);
|
||||
const inside = await read(path.join(workspace, 'inside.txt'));
|
||||
const outsideValue = await read(path.join(outside, 'outside.txt'));
|
||||
const report = {
|
||||
time: new Date().toISOString(),
|
||||
root,
|
||||
binary,
|
||||
version,
|
||||
args,
|
||||
env,
|
||||
command,
|
||||
result,
|
||||
requests,
|
||||
inside,
|
||||
outside: outsideValue,
|
||||
pass:
|
||||
result.code === 0 &&
|
||||
inside === 'workspace-ok' &&
|
||||
outsideValue === 'outside-ok' &&
|
||||
requests.some((r) => r.toolResults.length),
|
||||
};
|
||||
await Promise.all([
|
||||
fs.writeFile(
|
||||
path.join(output, 'baseline.json'),
|
||||
JSON.stringify(report, null, 2),
|
||||
),
|
||||
fs.writeFile(path.join(output, 'baseline.stdout.log'), stdout),
|
||||
fs.writeFile(path.join(output, 'baseline.stderr.log'), stderr),
|
||||
]);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!report.pass) process.exitCode = 1;
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { build } from 'esbuild';
|
||||
import { wasmLoader } from 'esbuild-plugin-wasm';
|
||||
import { createRequire } from 'node:module';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { hashFile, readSourceIdentity } from './source-manifest.mjs';
|
||||
|
||||
const source = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(source, '../..');
|
||||
const output = process.argv[2];
|
||||
if (!output || !path.isAbsolute(output))
|
||||
throw new Error('Supply empty absolute artifact directory');
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
if ((await fs.readdir(output)).length)
|
||||
throw new Error('Artifact directory must be empty');
|
||||
const require = createRequire(import.meta.url);
|
||||
const coreExports = JSON.parse(
|
||||
await fs.readFile(path.join(root, 'packages/core/package.json'), 'utf8'),
|
||||
).exports;
|
||||
const coreSource = (specifier) => {
|
||||
const key =
|
||||
specifier === '@qwen-code/qwen-code-core'
|
||||
? '.'
|
||||
: './' + specifier.slice('@qwen-code/qwen-code-core/'.length);
|
||||
const entry = coreExports[key];
|
||||
const file = typeof entry === 'string' ? entry : entry?.import;
|
||||
if (key === '.') return path.join(root, 'packages/core/src/index.ts');
|
||||
return path.join(
|
||||
root,
|
||||
'packages/core',
|
||||
(file ?? './src/' + key.slice(2))
|
||||
.replace('./dist/src/', './src/')
|
||||
.replace(/\.js$/, '.ts'),
|
||||
);
|
||||
};
|
||||
const common = {
|
||||
absWorkingDir: root,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node22',
|
||||
metafile: true,
|
||||
external: [
|
||||
'@lydell/node-pty',
|
||||
'node-pty',
|
||||
'@qwen-code/audio-capture',
|
||||
'@teddyzhu/clipboard',
|
||||
'sharp',
|
||||
],
|
||||
inject: [path.join(root, 'scripts/esbuild-shims.js')],
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
'process.env.CLI_VERSION': '"0.23.4-internal-test"',
|
||||
global: 'globalThis',
|
||||
__dirname: '__qwen_dirname',
|
||||
__filename: '__qwen_filename',
|
||||
},
|
||||
loader: { '.node': 'file' },
|
||||
alias: {
|
||||
'is-in-ci': path.join(root, 'packages/cli/src/patches/is-in-ci.ts'),
|
||||
'jsonc-parser': require.resolve('jsonc-parser/lib/esm/main.js'),
|
||||
'@qwen-code/web-templates': path.join(
|
||||
root,
|
||||
'packages/web-templates/src/index.ts',
|
||||
),
|
||||
punycode: require.resolve('punycode/'),
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: 'actual-core-source',
|
||||
setup(b) {
|
||||
b.onResolve(
|
||||
{ filter: /^@qwen-code\/qwen-code-core(?:\/|$)/ },
|
||||
(args) => ({ path: coreSource(args.path) }),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'wasm-binary',
|
||||
setup(b) {
|
||||
b.onResolve({ filter: /\.wasm\?binary$/ }, (args) => ({
|
||||
path: createRequire(path.join(args.resolveDir, '_dummy_.js')).resolve(
|
||||
args.path.replace(/\?binary$/, ''),
|
||||
),
|
||||
namespace: 'wasm-binary',
|
||||
}));
|
||||
b.onLoad({ filter: /.*/, namespace: 'wasm-binary' }, async (args) => ({
|
||||
contents: await fs.readFile(args.path),
|
||||
loader: 'binary',
|
||||
}));
|
||||
},
|
||||
},
|
||||
wasmLoader({ mode: 'embedded' }),
|
||||
],
|
||||
};
|
||||
const result = await build({
|
||||
...common,
|
||||
entryPoints: [path.join(source, 'launcher.mjs')],
|
||||
outfile: path.join(output, 'launcher.mjs'),
|
||||
});
|
||||
const workers = await build({
|
||||
...common,
|
||||
entryPoints: {
|
||||
sandboxBwrapRelay: 'packages/core/src/sandbox/bwrap-relay.ts',
|
||||
sandboxFileWorker: 'packages/core/src/sandbox/file-worker.ts',
|
||||
},
|
||||
outdir: output,
|
||||
});
|
||||
await fs.writeFile(path.join(output, 'package.json'), '{"type":"module"}\n');
|
||||
const inputs = {};
|
||||
for (const file of Object.keys({
|
||||
...result.metafile.inputs,
|
||||
...workers.metafile.inputs,
|
||||
})) {
|
||||
if (file.startsWith('wasm-binary:')) continue;
|
||||
inputs[file] = await hashFile(path.resolve(root, file));
|
||||
}
|
||||
for (const name of (await fs.readdir(source))
|
||||
.filter((name) => name.endsWith('.mjs') || name === 'README.md')
|
||||
.sort()) {
|
||||
const file = path.join(source, name);
|
||||
inputs[path.relative(root, file)] = await hashFile(file);
|
||||
}
|
||||
for (const name of [
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'tsconfig.json',
|
||||
'packages/core/package.json',
|
||||
'packages/cli/package.json',
|
||||
]) {
|
||||
inputs[name] = await hashFile(path.join(root, name));
|
||||
}
|
||||
const scriptsTests = path.join(root, 'scripts/tests');
|
||||
for (const name of (await fs.readdir(scriptsTests))
|
||||
.filter((name) => /^sandbox-runtime-.*\.test\.js$/.test(name))
|
||||
.sort()) {
|
||||
const file = path.join(scriptsTests, name);
|
||||
inputs[path.relative(root, file)] = await hashFile(file);
|
||||
}
|
||||
await fs.copyFile(
|
||||
path.join(source, 'verify.mjs'),
|
||||
path.join(output, 'verify.mjs'),
|
||||
);
|
||||
await fs.copyFile(
|
||||
path.join(source, 'source-manifest.mjs'),
|
||||
path.join(output, 'source-manifest.mjs'),
|
||||
);
|
||||
const artifacts = {};
|
||||
for (const file of await fs.readdir(output))
|
||||
artifacts[file] = await hashFile(path.join(output, file));
|
||||
const sourceIdentity = readSourceIdentity(root);
|
||||
const manifestPath = path.join(output, 'manifest.json');
|
||||
await fs.writeFile(
|
||||
manifestPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
created: new Date().toISOString(),
|
||||
...sourceIdentity,
|
||||
inputs,
|
||||
artifacts,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const manifestSha256 = await hashFile(manifestPath);
|
||||
if (sourceIdentity.dirty)
|
||||
console.warn('WARNING: candidate built from a dirty worktree.');
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ output, inputs: Object.keys(inputs).length, artifacts, manifestSha256 },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { loadCliConfig } from '../../packages/cli/src/config/config.js';
|
||||
import { LoadedSettings } from '../../packages/cli/src/config/settings.js';
|
||||
import { runNonInteractive } from '../../packages/cli/src/nonInteractiveCli.js';
|
||||
import { AuthType } from '../../packages/core/src/utils/auth-type.js';
|
||||
import { runExitCleanup } from '../../packages/cli/src/utils/cleanup.js';
|
||||
|
||||
const spec = JSON.parse(await fs.readFile(process.argv[2], 'utf8'));
|
||||
const installation = path.dirname(fileURLToPath(import.meta.url));
|
||||
const settingsFile = (name) => ({
|
||||
path: path.join(spec.home, name),
|
||||
settings: name === 'user.json' ? (spec.settings ?? {}) : {},
|
||||
});
|
||||
const settings = new LoadedSettings(
|
||||
settingsFile('system.json'),
|
||||
settingsFile('defaults.json'),
|
||||
settingsFile('user.json'),
|
||||
settingsFile('workspace.json'),
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
const argv = {
|
||||
bare: true,
|
||||
approvalMode: 'yolo',
|
||||
authType: 'openai',
|
||||
openaiApiKey: 'sk-mock',
|
||||
openaiBaseUrl: spec.baseUrl,
|
||||
model: 'mock-model',
|
||||
prompt: spec.prompt ?? 'Run the disposable fixture check',
|
||||
outputFormat: 'json',
|
||||
chatRecording: true,
|
||||
telemetry: false,
|
||||
...(spec.argv ?? {}),
|
||||
};
|
||||
const policy = {
|
||||
workspace: spec.workspace,
|
||||
state: spec.state,
|
||||
installation,
|
||||
filesystem: spec.filesystem ?? 'workspace-write',
|
||||
network: 'closed',
|
||||
...(spec.bwrapPath ? { bwrapPath: spec.bwrapPath } : {}),
|
||||
};
|
||||
const loadRuntime = (workspace, baseUrl = spec.baseUrl) =>
|
||||
loadCliConfig(
|
||||
spec.settings ?? {},
|
||||
{ ...argv, openaiBaseUrl: baseUrl },
|
||||
workspace,
|
||||
argv.extensions,
|
||||
spec.hooks,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
{ shellExecutionSandbox: { ...policy, workspace } },
|
||||
);
|
||||
let config = await loadRuntime(spec.workspace);
|
||||
const exists = (p) =>
|
||||
fs.access(p).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
async function until(predicate, label) {
|
||||
for (let i = 0; i < 400; i++) {
|
||||
if (await predicate()) return;
|
||||
await delay(25);
|
||||
}
|
||||
throw new Error(`Timed out: ${label}`);
|
||||
}
|
||||
let summary;
|
||||
try {
|
||||
await config.refreshAuth(AuthType.USE_OPENAI, true);
|
||||
await config.initialize();
|
||||
if (spec.startupOnly) {
|
||||
summary = { code: 0, initialized: true };
|
||||
} else if (!spec.direct) {
|
||||
const code = await runNonInteractive(
|
||||
config,
|
||||
settings,
|
||||
argv.prompt,
|
||||
`${config.getSessionId()}-acceptance`,
|
||||
);
|
||||
summary = {
|
||||
code,
|
||||
sessionId: config.getSessionId(),
|
||||
tasks: config.getBackgroundShellRegistry().getAll(),
|
||||
};
|
||||
if (spec.second) {
|
||||
const first = summary;
|
||||
config = await loadRuntime(spec.second.workspace, spec.second.baseUrl);
|
||||
await config.refreshAuth(AuthType.USE_OPENAI, true);
|
||||
await config.initialize();
|
||||
const secondCode = await runNonInteractive(
|
||||
config,
|
||||
settings,
|
||||
argv.prompt,
|
||||
`${config.getSessionId()}-second`,
|
||||
);
|
||||
summary = {
|
||||
code: code || secondCode,
|
||||
runtimes: [
|
||||
first,
|
||||
{
|
||||
code: secondCode,
|
||||
sessionId: config.getSessionId(),
|
||||
tasks: config.getBackgroundShellRegistry().getAll(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const registry = config.getToolRegistry();
|
||||
const shell = registry.getTool('run_shell_command');
|
||||
if (!shell) throw new Error('Production shell tool missing');
|
||||
const ac = new AbortController();
|
||||
let promoteAc, pid;
|
||||
const promise = shell.build(spec.direct.params).execute(
|
||||
ac.signal,
|
||||
undefined,
|
||||
undefined,
|
||||
(value) => {
|
||||
pid = value;
|
||||
},
|
||||
(value) => {
|
||||
promoteAc = value;
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
void promise.catch(() => {});
|
||||
if (spec.direct.ready)
|
||||
await until(() => exists(spec.direct.ready), 'payload ready');
|
||||
if (spec.direct.promote) {
|
||||
await until(() => !!promoteAc, 'promotion controller');
|
||||
promoteAc.abort({ kind: 'background' });
|
||||
}
|
||||
if (spec.direct.cancel) ac.abort({ kind: 'user' });
|
||||
if (spec.direct.removeReceipt) {
|
||||
const controls = (await fs.readdir(spec.state)).filter((name) =>
|
||||
name.startsWith('sandbox-control-'),
|
||||
);
|
||||
if (controls.length !== 1)
|
||||
throw new Error(`Expected one receipt directory, got ${controls}`);
|
||||
await fs.unlink(path.join(spec.state, controls[0], 'status.json'));
|
||||
}
|
||||
if (spec.direct.gate) await fs.writeFile(spec.direct.gate, 'go');
|
||||
const result = await promise;
|
||||
if (spec.direct.taskStop) {
|
||||
const task = config
|
||||
.getBackgroundShellRegistry()
|
||||
.getAll()
|
||||
.find((task) => task.status === 'running');
|
||||
if (!task) throw new Error('No running production background task');
|
||||
const stop = registry.getTool('task_stop');
|
||||
if (!stop) throw new Error('Production task_stop missing');
|
||||
await stop
|
||||
.build({ task_id: task.id })
|
||||
.execute(new AbortController().signal);
|
||||
}
|
||||
await until(
|
||||
() =>
|
||||
config
|
||||
.getBackgroundShellRegistry()
|
||||
.getAll()
|
||||
.every((task) => task.status !== 'running'),
|
||||
'background terminal state',
|
||||
);
|
||||
const tasks = config.getBackgroundShellRegistry().getAll();
|
||||
summary = {
|
||||
code: 0,
|
||||
sessionId: config.getSessionId(),
|
||||
pid,
|
||||
result,
|
||||
tasks,
|
||||
policy: config.getShellExecutionSandbox?.(),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
summary = { code: 1, error: error.stack };
|
||||
} finally {
|
||||
await fs.writeFile(
|
||||
spec.summary,
|
||||
JSON.stringify(
|
||||
summary,
|
||||
(_key, value) =>
|
||||
value instanceof Error ? { message: value.message } : value,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await runExitCleanup();
|
||||
}
|
||||
process.exit(summary.code);
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export const hashFile = async (file) =>
|
||||
createHash('sha256')
|
||||
.update(await fs.readFile(file))
|
||||
.digest('hex');
|
||||
|
||||
export function readSourceIdentity(root) {
|
||||
return {
|
||||
revision: execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
}).trim(),
|
||||
dirty:
|
||||
execFileSync(
|
||||
'git',
|
||||
['-c', 'status.showUntrackedFiles=normal', 'status', '--porcelain'],
|
||||
{
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
).trim() !== '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifySourceManifest(root, manifest) {
|
||||
const canonicalRoot = await fs.realpath(root);
|
||||
const identity = readSourceIdentity(canonicalRoot);
|
||||
assert.equal(
|
||||
identity.revision,
|
||||
manifest.revision,
|
||||
'Source revision differs from the candidate build',
|
||||
);
|
||||
const actualInputs = {};
|
||||
for (const name of Object.keys(manifest.inputs)) {
|
||||
if (path.isAbsolute(name)) {
|
||||
throw new Error(`Source manifest input must be relative: ${name}`);
|
||||
}
|
||||
const file = path.resolve(canonicalRoot, name);
|
||||
const relative = path.relative(canonicalRoot, file);
|
||||
if (relative === '..' || relative.startsWith(`..${path.sep}`)) {
|
||||
throw new Error(`Source manifest input escapes the source root: ${name}`);
|
||||
}
|
||||
try {
|
||||
actualInputs[name] = await hashFile(file);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Source manifest input is unavailable: ${name}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
actualInputs,
|
||||
manifest.inputs,
|
||||
'Source inputs differ from the candidate build',
|
||||
);
|
||||
assert.equal(
|
||||
identity.dirty,
|
||||
manifest.dirty,
|
||||
'Source worktree dirtiness differs from the candidate build',
|
||||
);
|
||||
return { ...identity, inputs: actualInputs };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
hashFile,
|
||||
readSourceIdentity,
|
||||
verifySourceManifest,
|
||||
} from '../sandbox-runtime/source-manifest.mjs';
|
||||
|
||||
const roots = [];
|
||||
|
||||
async function fixture() {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-source-manifest-'),
|
||||
);
|
||||
roots.push(root);
|
||||
await fs.writeFile(path.join(root, 'input.txt'), 'before\n');
|
||||
execFileSync('git', ['init', '-q'], { cwd: root });
|
||||
execFileSync('git', ['config', 'core.hooksPath', os.devNull], { cwd: root });
|
||||
execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: root });
|
||||
execFileSync('git', ['config', 'status.showUntrackedFiles', 'normal'], {
|
||||
cwd: root,
|
||||
});
|
||||
execFileSync('git', ['add', 'input.txt'], { cwd: root });
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'user.name=Qwen Test',
|
||||
'-c',
|
||||
'[email protected]',
|
||||
'commit',
|
||||
'-qm',
|
||||
'fixture',
|
||||
],
|
||||
{ cwd: root },
|
||||
);
|
||||
const identity = readSourceIdentity(root);
|
||||
return {
|
||||
root,
|
||||
manifest: {
|
||||
...identity,
|
||||
inputs: { 'input.txt': await hashFile(path.join(root, 'input.txt')) },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('sandbox runtime source manifest', () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await Promise.all(
|
||||
roots
|
||||
.splice(0)
|
||||
.map((root) => fs.rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it('records untracked files despite ambient Git status configuration', async () => {
|
||||
const { root } = await fixture();
|
||||
const globalConfig = path.join(root, 'ambient-gitconfig');
|
||||
await fs.writeFile(globalConfig, '[status]\n\tshowUntrackedFiles = no\n');
|
||||
vi.stubEnv('GIT_CONFIG_GLOBAL', globalConfig);
|
||||
await fs.writeFile(path.join(root, 'untracked.txt'), 'new\n');
|
||||
expect(readSourceIdentity(root).dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the declared SHA-256 byte encoding', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-source-hash-'));
|
||||
roots.push(root);
|
||||
const file = path.join(root, 'known.txt');
|
||||
await fs.writeFile(file, 'known bytes\n');
|
||||
await expect(hashFile(file)).resolves.toBe(
|
||||
'43697ca3ebe6227d6017713ca8449d76dc37602e0d5742e8728875c5a876cd5e',
|
||||
);
|
||||
});
|
||||
|
||||
it('binds a candidate to its exact source revision and inputs', async () => {
|
||||
const { root, manifest } = await fixture();
|
||||
await expect(verifySourceManifest(root, manifest)).resolves.toEqual(
|
||||
manifest,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects source changes after the candidate build', async () => {
|
||||
const { root, manifest } = await fixture();
|
||||
await fs.writeFile(path.join(root, 'input.txt'), 'after\n');
|
||||
await expect(verifySourceManifest(root, manifest)).rejects.toThrow(
|
||||
'Source inputs differ from the candidate build',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a different source revision with matching inputs', async () => {
|
||||
const { root, manifest } = await fixture();
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'user.name=Qwen Test',
|
||||
'-c',
|
||||
'[email protected]',
|
||||
'commit',
|
||||
'--allow-empty',
|
||||
'-qm',
|
||||
'next',
|
||||
],
|
||||
{ cwd: root },
|
||||
);
|
||||
await expect(verifySourceManifest(root, manifest)).rejects.toThrow(
|
||||
'Source revision differs from the candidate build',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a different source dirtiness state', async () => {
|
||||
const { root, manifest } = await fixture();
|
||||
await fs.writeFile(path.join(root, 'untracked.txt'), 'new\n');
|
||||
await expect(verifySourceManifest(root, manifest)).rejects.toThrow(
|
||||
'Source worktree dirtiness differs from the candidate build',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects source inputs that escape the checkout', async () => {
|
||||
const { root, manifest } = await fixture();
|
||||
await expect(
|
||||
verifySourceManifest(root, {
|
||||
...manifest,
|
||||
inputs: { '../outside': '0'.repeat(64) },
|
||||
}),
|
||||
).rejects.toThrow('escapes the source root');
|
||||
});
|
||||
|
||||
it('rejects absolute source inputs', async () => {
|
||||
const { root, manifest } = await fixture();
|
||||
await expect(
|
||||
verifySourceManifest(root, {
|
||||
...manifest,
|
||||
inputs: { [path.join(root, 'input.txt')]: '0'.repeat(64) },
|
||||
}),
|
||||
).rejects.toThrow('must be relative');
|
||||
});
|
||||
|
||||
it('reports a missing declared source input', async () => {
|
||||
const { root, manifest } = await fixture();
|
||||
await expect(
|
||||
verifySourceManifest(root, {
|
||||
...manifest,
|
||||
inputs: { 'missing.txt': '0'.repeat(64) },
|
||||
}),
|
||||
).rejects.toThrow('Source manifest input is unavailable: missing.txt');
|
||||
});
|
||||
|
||||
it('reports revision drift before stale input paths', async () => {
|
||||
const { root, manifest } = await fixture();
|
||||
await expect(
|
||||
verifySourceManifest(root, {
|
||||
...manifest,
|
||||
revision: '0'.repeat(40),
|
||||
inputs: { 'missing.txt': '0'.repeat(64) },
|
||||
}),
|
||||
).rejects.toThrow('Source revision differs from the candidate build');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user