feat(core): Add Landlock execution fallback (#12278)

* feat(core): Add Landlock execution fallback

Co-authored-by: Qwen-Coder <[email protected]>

* fix(ci): Track Landlock helper workflow size

Co-authored-by: Qwen-Coder <[email protected]>

* docs(sandbox): Clarify Landlock compatibility limits

Co-authored-by: Qwen-Coder <[email protected]>

* fix(sandbox): Repair installed Landlock helpers and prompt guidance

Restore missing execute bits only for the bundled helper, anchor asset lookup to the actual source layout, and describe the effective Landlock boundary in the model prompt. Keep portable asset checks on Windows.

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
jinyeandQwen-Coder authored and GitHub committed 2026-09-29 03:45:46 +00:00
1 parent 470706a204
commit 1a5a74bc35
54 files changed
+2566 -541

No files matched your search

+1
View File
@@ -66,6 +66,7 @@
11206 sync-release-to-oss.yml
2676 tui-parity.yml
7187 update-ecs-runner-qwen.yml
6206 verify-landlock-helper.yml
2307 web-shell-visuals-cleanup.yml
20047 web-shell-visuals-publish.yml
17127 web-shell-visuals.yml
@@ -0,0 +1,157 @@
name: 'Verify Landlock helper'
on:
pull_request:
paths:
- '.github/workflows/verify-landlock-helper.yml'
- 'packages/core/vendor/landlock-run/**'
- 'scripts/build_landlock_run.mjs'
push:
branches:
- 'main'
paths:
- '.github/workflows/verify-landlock-helper.yml'
- 'packages/core/vendor/landlock-run/**'
- 'scripts/build_landlock_run.mjs'
workflow_dispatch:
permissions:
contents: 'read'
jobs:
verify:
name: '${{ matrix.arch }}'
strategy:
fail-fast: false
matrix:
include:
- arch: 'x64'
runner: 'ubuntu-24.04'
zig_target: 'x86_64'
zig_sha256: '70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00'
- arch: 'arm64'
runner: 'ubuntu-24.04-arm'
zig_target: 'aarch64'
zig_sha256: 'ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17'
runs-on: '${{ matrix.runner }}'
timeout-minutes: 10
steps:
- uses: 'actions/checkout@v4'
- name: 'Install pinned Zig compiler'
env:
ZIG_SHA256: '${{ matrix.zig_sha256 }}'
ZIG_TARGET: '${{ matrix.zig_target }}'
ZIG_VERSION: '0.16.0'
run: |-
archive="zig-${ZIG_TARGET}-linux-${ZIG_VERSION}.tar.xz"
curl --fail --location --silent --show-error --output "${RUNNER_TEMP}/${archive}" "https://ziglang.org/download/${ZIG_VERSION}/${archive}"
echo "${ZIG_SHA256} ${RUNNER_TEMP}/${archive}" | sha256sum --check --strict
tar --extract --xz --file "${RUNNER_TEMP}/${archive}" --directory "${RUNNER_TEMP}"
echo "${RUNNER_TEMP}/zig-${ZIG_TARGET}-linux-${ZIG_VERSION}" >> "${GITHUB_PATH}"
- name: 'Rebuild committed helper'
env:
CC: 'zig'
run: |-
committed="packages/core/vendor/landlock-run/${{ matrix.arch }}-linux/qwen-landlock-run"
cp "${committed}" "${RUNNER_TEMP}/committed-helper"
node scripts/build_landlock_run.mjs '${{ matrix.arch }}'
cmp "${RUNNER_TEMP}/committed-helper" "${committed}"
file "${committed}"
- name: 'Verify kernel enforcement and inheritance'
env:
ARCH: '${{ matrix.arch }}'
run: |-
helper="packages/core/vendor/landlock-run/${ARCH}-linux/qwen-landlock-run"
root="$(mktemp -d)"
trap 'rm -rf "${root}"' EXIT
workspace="${root}/workspace"
scratch="${root}/scratch"
outside="${root}/outside"
mkdir -p "${workspace}" "${scratch}" "${outside}"
printf 'unchanged\n' > "${outside}/marker"
"${helper}" --probe | grep -E '^\{"abi":([3-9]|[1-9][0-9]+),"enforcement":"partial"\}$'
# shellcheck disable=SC2016
"${helper}" --ro / --rw /dev/null --rw "${scratch}" --rw "${workspace}" -- /bin/sh -c 'printf allowed > "$1"' sh "${workspace}/created"
test "$(cat "${workspace}/created")" = 'allowed'
# shellcheck disable=SC2016
if "${helper}" --ro / --rw /dev/null --rw "${scratch}" --rw "${workspace}" -- /bin/sh -c 'printf denied > "$1"' sh "${outside}/marker"; then
echo 'outside write unexpectedly succeeded' >&2
exit 1
fi
test "$(cat "${outside}/marker")" = 'unchanged'
# shellcheck disable=SC2016
if "${helper}" --ro / --rw /dev/null --rw "${scratch}" -- /bin/sh -c 'printf denied > "$1"' sh "${workspace}/read-only"; then
echo 'read-only workspace write unexpectedly succeeded' >&2
exit 1
fi
cat > "${workspace}/descendant.sh" <<'EOF'
#!/bin/sh
printf denied > "$1"
EOF
chmod 755 "${workspace}/descendant.sh"
# shellcheck disable=SC2016
if "${helper}" --ro / --rw /dev/null --rw "${scratch}" --rw "${workspace}" -- /bin/sh -c '"$1" "$2"' sh "${workspace}/descendant.sh" "${outside}/descendant"; then
echo 'descendant outside write unexpectedly succeeded' >&2
exit 1
fi
test ! -e "${outside}/descendant"
set +e
diagnostic="$(${helper} --ro / -- /does/not/exist 2>&1)"
status=$?
set -e
test "${status}" -eq 125
case "${diagnostic}" in
qwen-landlock-run:\ exec\ failed:*) ;;
*) echo "unexpected launcher diagnostic: ${diagnostic}" >&2; exit 1 ;;
esac
status_wire="${root}/status-wire"
"${helper}" --status-fd 3 --ro / -- /bin/true 3> "${status_wire}"
grep -E '^\{"state":"prepared","abi":([3-9]|[1-9][0-9]+)\}$' "${status_wire}"
set +e
"${helper}" --status-fd 3 --ro / -- /does/not/exist 3> "${status_wire}" 2> /dev/null
status=$?
set -e
test "${status}" -eq 125
test "$(grep -c '^{' "${status_wire}")" -eq 2
tail -n 1 "${status_wire}" | grep -E '^\{"state":"exec-failed","abi":([3-9]|[1-9][0-9]+)\}$'
- name: 'Verify inherited nonblocking standard streams'
env:
ARCH: '${{ matrix.arch }}'
run: |-
python3 - <<'PYTHON'
import os
import subprocess
import sys
helper = f"packages/core/vendor/landlock-run/{os.environ['ARCH']}-linux/qwen-landlock-run"
def nonblocking_stdio():
for fd in range(3):
os.set_blocking(fd, False)
program = """
import os, sys
assert all(os.get_blocking(fd) for fd in range(3))
data = sys.stdin.buffer.read()
sys.stdout.buffer.write(data)
sys.stderr.buffer.write(data)
"""
payload = bytes(range(256)) * 4096
result = subprocess.run(
[helper, '--ro', '/', '--', sys.executable, '-c', program],
input=payload, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
preexec_fn=nonblocking_stdio, timeout=30,
)
assert result.returncode == 0, (result.returncode, result.stderr[-500:])
assert result.stdout == payload
assert result.stderr == payload
PYTHON
@@ -0,0 +1,76 @@
# Landlock tool execution backend
[English](2026-09-20-landlock-execution-backend.md) | [简体中文](2026-09-20-landlock-execution-backend.zh-CN.md)
## Status and scope
This design adds a Landlock filesystem backend to the tool execution boundary introduced by the bwrap delivery. It builds on the public bwrap CLI cutover and trusted review-state migration already merged into main (#12267 and #12491), without restoring whole-CLI sandboxing. Shell commands, terminal shell entrypoints, and the existing file worker continue to enter confinement per tool invocation while the CLI, model transport, authentication, and session state remain on the host.
The first Landlock release is a fallback for Linux hosts where bwrap is missing or unusable. It confines pathname content and directory-structure mutations, but Linux Landlock still cannot restrict every metadata operation such as `chmod`, `chown`, extended attributes, or timestamps. It also does not provide bwrap's PID or network namespaces. The backend therefore reports `partial` filesystem enforcement, requires Landlock ABI 3 or newer so cross-directory rename and truncation are governed, and supports only `network: open`. It never claims that a partial Landlock profile is equivalent to bwrap.
## Operator contract
`tools.executionSandbox.backend` accepts `auto`, `bwrap`, or `landlock`.
- `bwrap` probes and uses only bwrap. Failure stops startup.
- `landlock` probes and uses only the bundled Landlock helper. `network: closed` and every remaining nonempty `maskedPaths` list are rejected before probing the helper or starting a payload.
- `auto` probes bwrap first. If bwrap is unusable and the requested policy has `network: open`, it probes Landlock and selects it when enforceable. A remaining nonempty mask list makes Landlock incompatible; fallback never drops masks. With `network: closed`, bwrap is the only compatible candidate; failure reports that Landlock cannot satisfy the network policy.
Backend selection happens once during runtime initialization. The resolved backend, enforcement level, and Landlock ABI are frozen into the active Config policy. A command-time setup failure never falls through to another backend and never replays the payload on the host.
The Landlock policy probe executes `/usr/bin/true` inside the boundary. Hosts without that executable fail the probe; helper capability alone does not make such a host usable.
Inspection and UI surfaces show requested and effective backends. Landlock is rendered with `partial` and its probed ABI. The documentation states the uncovered metadata and process/IPC boundaries. This visibility is required because `auto` may move from full bwrap enforcement to partial Landlock enforcement on a host where user namespaces or mounts are unavailable.
The model prompt uses the effective backend: Landlock describes partial content-write restrictions and EACCES refusals, including metadata and namespace limits; bwrap retains its read-only mount guidance.
## Bundled helper
Qwen Code ships `qwen-landlock-run` for Linux x64 and arm64 under `packages/core/vendor/landlock-run/<arch>-linux/`. The Apache-2.0 C11 source uses the stable raw Landlock UAPI and has no runtime library dependency because release binaries are statically linked with musl.
Before probing or launching the bundled helper, the host restores its mode to 0755 if packaging removed all executable bits. This applies to fresh and managed installs without a postinstall script. A read-only or permission-restricted install that cannot restore the mode fails before the payload starts; it never substitutes a host command. Explicit helper-path overrides are not chmod'ed. Helper lookup distinguishes source layout from the bundle directory by its trailing path components, so installation ancestor names do not change asset resolution.
The command contract is:
```text
qwen-landlock-run --probe
qwen-landlock-run [--status-fd <fd>] [--ro <path>]... [--rw <path>]... -- <argv>...
```
`--ro` grants read and execute rights. `--rw` grants every filesystem right handled by the negotiated ABI. All handled rights outside those roots are denied. File grants mask directory-only rights. Every missing or unopenable grant, unsupported ABI, ruleset error, or failed exec exits 125 with a `qwen-landlock-run: ` diagnostic and does not silently remove a requested grant.
The helper requires ABI 3. ABI 1 cannot grant cross-directory reparenting and ABI 2 cannot control truncation, so accepting those kernels would leave common content-write paths outside the stated policy. ABI 5's device-ioctl right is enabled when available. Newer kernel rights are not treated as full coverage; the backend remains partial until the product contract and helper are reviewed together.
Both relays initialize Node's shared output descriptors before spawning the helper or bwrap, preventing lazy stream setup from changing their flags after the child starts. The Landlock helper then restores blocking mode on open standard streams before exec, so inherited Node relay flags cannot truncate native payload output with `EAGAIN`. It sets `PR_SET_NO_NEW_PRIVS`, installs the ruleset, sets a parent-death signal, and then `exec`s the payload. Landlock restrictions survive exec and are inherited by descendants. The helper sends a small execution attestation to a dedicated status file descriptor; an unrestricted relay turns that wire plus the child status into the same confirmed/unconfirmed receipt used by bwrap. This preserves the no-blind-retry rule and temporary-directory retention behavior.
## Filesystem profile
Both backends retain broad host reads. Landlock grants read and execute below `/`, write access to `/dev/null` and the per-command scratch directory, and write access to the canonical workspace only for `workspace-write`. Runtime state and installation roots remain read-only. The helper opens rule roots with `O_PATH`, so rules bind to filesystem objects rather than trusting a later textual path lookup.
Landlock does not create bwrap's private `/dev` and `/proc` mounts. Its narrower write grants reject opening other device paths such as `/dev/full` or `/dev/tty` for writing and creating files in `/dev/shm`; programs that require these operations may fail. Use the private scratch directory exposed through `TMPDIR`, `TMP`, and `TEMP` for temporary files. This restriction concerns new pathname access, not the caller-provided standard streams that are already open.
Trusted review state remains outside the writable workspace under the merged migration. Landlock cannot hide paths under its broad read grant: both its probe and execution entrypoints reject every nonempty `maskedPaths` list. Bubblewrap continues to enforce explicit masks.
The unrestricted Node relay starts with a minimal environment. Payload variables travel through an exclusively created, mode-0600 control file, which the relay reads and removes before spawning the static helper. Variables such as `NODE_OPTIONS` therefore apply only after confinement to a Node payload, never to the unrestricted relay. The Landlock process receives the same sanitized payload environment as bwrap, with `TMPDIR`, `TMP`, and `TEMP` pointing at private scratch. The existing workspace/protected-root admission checks remain authoritative. File writes continue through the same worker and version check; shell and file paths dispatch through the runtime's resolved backend.
Landlock does not create a PID namespace. Host processes remain visible, while Landlock's domain comparison restricts ptrace-like access to less-confined processes. Pathname and abstract Unix sockets are not part of this first helper profile. `network: open` therefore retains host networking and reachable host services. Closed networking remains a bwrap-only promise until a separately reviewed seccomp or namespace design can cover the whole network vocabulary.
## Packaging and verification
The build script compiles one architecture at a time with a native `musl-gcc` or an explicit Zig musl target. The two committed binaries are copied by the existing core `vendor/` package and bundle paths. A dedicated Linux x64/arm64 workflow rebuilds the helper, compares it byte-for-byte with the committed binary, runs the functional probe, and checks read-only denial, workspace write allowance, outside-write denial, descendant inheritance, and launcher failure attribution.
Production-bundle acceptance covers read-only and workspace-write policies, network and mask refusal, literal argv and payload environment isolation, byte-exact stdin/stdout/stderr, streamed-input backpressure, slow consumers, EPIPE cleanup, negative controls, and no unconfined replay. Unit coverage exercises settings validation, exact backend selection order, incompatible-network refusal, probe parsing, asset resolution, helper argv, backend dispatch, status-wire parsing, diagnostics, and public `qwen sandbox` verification differences. Real Linux acceptance must run the exact committed helper on both architectures. macOS tests can validate selection and argv construction but do not establish kernel enforcement.
## Security and compatibility
- Selection is fail-closed. Neither probe failure nor launch failure produces an unconfined retry.
- `partial` is an enforcement fact, not a warning label that callers may reinterpret as full. It covers Landlock's documented gaps in metadata operations and the absence of namespace isolation.
- ABI 3 is the minimum. Kernels without enabled Landlock or with older ABIs are unusable.
- Network-closed policies and nonempty path-mask policies never select Landlock.
- Open descriptors retain their pre-Landlock rights. The relay streams host-backed regular-file and named-FIFO stdin through a pipe; it only directly shares sockets, character devices, or anonymous pipes. It preserves input bytes and backpressure without granting the payload a host-file stdin descriptor. Output standard streams remain explicit caller-provided destinations. The helper marks its execution-status descriptor close-on-exec.
- The helper's parent-death signal terminates the direct payload if its relay dies. Landlock has no PID-namespace lifetime boundary: detached descendants keep the inherited filesystem restrictions but rely on the existing runtime process cleanup rather than `--die-with-parent` semantics.
- Host reads, process visibility, and host Unix-socket reachability remain outside the first Landlock promise and are shown in documentation and verification output.
## Follow-up work
A future seccomp companion may make closed networking available without bwrap, but it requires its own syscall and compatibility design. Landlock's newer IPC scopes and ABI 9 pathname Unix-socket resolution, along with future filesystem rights, should be adopted only with exact-kernel tests and a deliberate enforcement-level decision. This PR does not change ACP/serve support, broaden writable roots, add per-command approval-derived policies, or change the whole-CLI Docker/Podman/Seatbelt paths.
@@ -0,0 +1,76 @@
# Landlock 工具执行后端
[English](2026-09-20-landlock-execution-backend.md) | [简体中文](2026-09-20-landlock-execution-backend.zh-CN.md)
## 状态与范围
本设计在 bwrap 交付建立的工具执行边界中加入 Landlock 文件系统后端。它基于已经合并到 main 的公开 bwrap CLI 切换和可信 review 状态迁移(#12267、#12491),不恢复整 CLI 沙箱。Shell 命令、终端 Shell 入口和现有文件 worker 仍按每次工具调用进入隔离,CLI、模型传输、认证和会话状态继续运行在宿主机上。
首个 Landlock 版本用于 Linux 主机上 bwrap 缺失或不可用时的回退。它约束路径内容和目录结构变更,但 Linux Landlock 目前仍不能限制 `chmod`、`chown`、扩展属性或时间戳等所有元数据操作,也不提供 bwrap 的 PID 或网络 namespace。因此该后端报告 `partial` 文件系统执行完整性,要求 Landlock ABI 3 或更高版本以覆盖跨目录重命名和截断,并且只支持 `network: open`。它不会宣称部分执行的 Landlock profile 与 bwrap 等价。
## 操作员契约
`tools.executionSandbox.backend` 接受 `auto`、`bwrap` 或 `landlock`。
- `bwrap` 只探测和使用 bwrap,失败时停止启动。
- `landlock` 只探测和使用随包发布的 Landlock helper;`network: closed` 和所有剩余非空 `maskedPaths` 列表都会在 helper 探测或 payload 启动前被拒绝。
- `auto` 首先探测 bwrap。如果 bwrap 不可用且请求策略为 `network: open`,则探测 Landlock,并在内核可执行时选择它。剩余非空 mask 列表意味着 Landlock 不兼容;回退绝不会丢弃 mask。对于 `network: closed`,bwrap 是唯一兼容候选;失败信息会说明 Landlock 无法满足网络策略。
后端选择在 runtime 初始化期间只执行一次。解析后的后端、执行完整性和 Landlock ABI 会冻结到活动 Config 策略中。命令执行阶段的设置失败不会再切换到另一个后端,也不会在宿主机上重放 payload。
Landlock 策略探测会在边界内执行 `/usr/bin/true`。缺少该可执行文件的主机会探测失败;仅有 helper 能力并不意味着该主机可用。
检查命令和 UI 会同时展示请求后端与实际后端。Landlock 会显示 `partial` 和已探测的 ABI。文档明确列出未覆盖的元数据及进程/IPC 边界。因为 `auto` 可能在用户 namespace 或 mount 不可用的主机上从完整的 bwrap 执行切换到部分 Landlock 执行,这种可见性是必要条件。
模型提示词使用实际生效的后端:Landlock 描述 partial 内容写入限制和 EACCES 拒绝,并说明元数据与 namespace 限制;bwrap 保留只读挂载指引。
## 随包 helper
Qwen Code 在 `packages/core/vendor/landlock-run/<arch>-linux/` 中为 Linux x64 和 arm64 发布 `qwen-landlock-run`。Apache-2.0 的 C11 源码直接使用稳定的 Landlock 原始 UAPI;发布二进制使用 musl 静态链接,因此没有运行时库依赖。
在探测或启动内置 helper 前,如果打包移除了全部执行权限位,宿主会将其权限恢复为 0755。这适用于全新安装和托管更新,无需 postinstall 脚本。只读或权限受限的安装若无法恢复权限,会在 payload 启动前失败,绝不替换为宿主命令。显式 helper 路径覆盖不会被 chmod。Helper 查找通过目录末尾的路径分量区分源码布局与 bundle 目录,因此安装位置的祖先目录名不会改变资源解析。
命令契约为:
```text
qwen-landlock-run --probe
qwen-landlock-run [--status-fd <fd>] [--ro <path>]... [--rw <path>]... -- <argv>...
```
`--ro` 授予读取和执行权限;`--rw` 授予协商 ABI 能处理的全部文件系统权限。已处理权限在这些根目录之外默认拒绝。文件 grant 会去除仅适用于目录的权限。任何缺失或无法打开的 grant、不支持的 ABI、ruleset 错误或 exec 失败都会以 125 退出,并输出带 `qwen-landlock-run: ` 前缀的诊断;helper 不会静默移除请求的 grant。
helper 要求 ABI 3。ABI 1 无法授予跨目录重新挂接,ABI 2 无法控制截断;接受这些内核会使常见内容写入路径脱离声明的策略。ABI 5 提供的设备 ioctl 权限会在可用时启用。更新内核新增的权限不会被当作完整覆盖;在产品契约和 helper 一起复核前,该后端始终保持 partial。
两个 relay 均在启动 helper 或 bwrap 前初始化 Node 的共享输出描述符,防止延迟的流初始化在子进程启动后改变其标志。随后,Landlock helper 在 exec 前恢复已打开标准流的阻塞模式,防止继承的 Node relay 标志使原生 payload 输出因 `EAGAIN` 被截断。它设置 `PR_SET_NO_NEW_PRIVS`、安装 ruleset、设置父进程退出信号,然后 `exec` payload。Landlock 限制会跨 exec 保留并由后代继承。helper 通过专用状态文件描述符发送小型执行回执;不受限的 relay 将该回执和子进程状态转换为 bwrap 使用的同一种 confirmed/unconfirmed 回执,从而保留禁止盲目重试及诊断临时目录保留行为。
## 文件系统 profile
两个后端都保留广泛的宿主读取能力。Landlock 对 `/` 以下授予读取和执行权限,对 `/dev/null` 与每条命令的私有 scratch 目录授予写权限;仅在 `workspace-write` 下对规范化 workspace 授予写权限。runtime 状态和安装目录保持只读。helper 使用 `O_PATH` 打开规则根目录,因此规则绑定到文件系统对象,而不是信任后续文本路径查找。
Landlock 不创建 bwrap 的私有 `/dev` 和 `/proc` 挂载。其更窄的写授权会拒绝以写入方式打开 `/dev/full`、`/dev/tty` 等其他设备路径,以及在 `/dev/shm` 中创建文件;依赖这些操作的程序可能失败。临时文件应使用 `TMPDIR`、`TMP` 和 `TEMP` 指向的私有 scratch 目录。这项限制针对新的路径访问,不针对调用方提供的已打开标准流。
可信 review 状态按已合并的迁移规则保留在可写 workspace 之外。Landlock 的广泛读取授权无法隐藏路径,因此其 probe 和执行入口都拒绝所有非空 `maskedPaths` 列表。Bubblewrap 继续执行显式 mask。
不受限的 Node relay 使用最小环境启动。Payload 变量通过独占创建、权限为 0600 的控制文件传递,relay 在启动静态 helper 前读取并删除该文件。因此 `NODE_OPTIONS` 等变量只会作用于隔离后的 Node payload,不会作用于不受限的 relay。Landlock 进程接收与 bwrap 相同的净化 payload 环境,其中 `TMPDIR`、`TMP` 和 `TEMP` 指向私有 scratch。现有 workspace/受保护根目录准入检查继续作为权威。文件写入仍通过同一个 worker 和版本检查;Shell 与文件路径通过 runtime 已解析的后端分发。
Landlock 不创建 PID namespace。宿主进程仍可见,而 Landlock 的 domain 比较会限制对权限更宽进程的 ptrace 类访问。首个 helper profile 不包含路径名或抽象 Unix socket 限制。因此 `network: open` 保留宿主网络和可达的宿主服务。在单独评审的 seccomp 或 namespace 设计能够覆盖完整网络语义之前,关闭网络仍是仅 bwrap 提供的承诺。
## 打包与验证
构建脚本使用原生 `musl-gcc` 或显式 Zig musl target,每次编译一个架构。两个提交的二进制由现有 core `vendor/` 包和 bundle 路径复制。专用 Linux x64/arm64 workflow 会重新构建 helper,与提交二进制逐字节比较,运行功能探测,并检查只读拒绝、workspace 写入允许、外部写入拒绝、后代继承及 launcher 失败归因。
生产 bundle 验收覆盖只读与 workspace-write 策略、网络及 mask 拒绝、字面 argv 和 payload 环境隔离、stdin/stdout/stderr 逐字节传输、流式输入背压、慢速消费者、EPIPE 清理、负向控制及禁止非隔离重放。单元测试覆盖设置校验、精确后端选择顺序、不兼容网络拒绝、probe 解析、资源解析、helper argv、后端分发、状态 wire 解析、诊断以及公开 `qwen sandbox` 的验证差异。真实 Linux 验收必须在两个架构上运行精确提交的 helper。macOS 测试可以验证选择与 argv 构造,但不能证明内核执行。
## 安全与兼容性
- 选择流程 fail-closed。probe 失败或启动失败都不会产生非隔离重试。
- `partial` 是执行事实,不能被调用方重新解释为 full 的普通警告。它覆盖 Landlock 文档明确的元数据操作缺口和缺少 namespace 隔离。
- 最低要求 ABI 3。未启用 Landlock 或 ABI 更旧的内核不可用。
- 网络关闭策略和非空路径 mask 策略绝不选择 Landlock。
- 已打开的描述符保留进入 Landlock 前的权限。Relay 将宿主普通文件和命名 FIFO 的 stdin 通过管道流式转发;仅直接共享 socket、字符设备或匿名管道。在保留输入字节和背压的同时,不向 payload 授予宿主文件 stdin 描述符。输出标准流仍是调用方显式提供的目标。Helper 将执行状态描述符标记为 close-on-exec。
- 如果 relay 退出,helper 的父进程退出信号会终止直接 payload。Landlock 不提供 PID namespace 生命周期边界:脱离的后代继续继承文件系统限制,但其清理由现有 runtime 进程清理机制负责,而不具备 `--die-with-parent` 语义。
- 宿主读取、进程可见性和宿主 Unix socket 可达性不属于首个 Landlock 承诺,并会在文档和验证输出中展示。
## 后续工作
未来可用 seccomp 伴随层在没有 bwrap 时提供关闭网络,但它需要独立的 syscall 与兼容性设计。Landlock 较新的 IPC scope、ABI 9 路径名 Unix socket 解析及未来文件系统权限,只能在精确内核测试及明确执行完整性决策后采用。本 PR 不改变 ACP/serve 支持,不扩大可写根目录,不加入由每次审批派生的策略,也不改变整 CLI Docker/Podman/Seatbelt 路径。
+1 -1
View File
@@ -11,7 +11,7 @@
## Tool execution sandbox
`tools.executionSandbox` is an operator-only Linux policy with required `filesystem: "read-only" | "workspace-write"` and `network: "open" | "closed"`, plus optional `backend: "auto" | "bwrap"`. System policy replaces the complete User/SystemDefaults object; Workspace values are ignored. Bare/safe modes retain the policy and changing it requires restart. See [Sandbox](../features/sandbox.md#linux-tool-execution-sandbox) for configuration, scope, unsupported integrations, verification and migration from whole-CLI bwrap.
`tools.executionSandbox` is an operator-only Linux policy with required `filesystem: "read-only" | "workspace-write"` and `network: "open" | "closed"`, plus optional `backend: "auto" | "bwrap" | "landlock"`. `auto` prefers bwrap and can fall back to partial Landlock filesystem enforcement only when command networking is open. System policy replaces the complete User/SystemDefaults object; Workspace values are ignored. Bare/safe modes retain the policy and changing it requires restart. See [Sandbox](../features/sandbox.md#linux-tool-execution-sandbox) for configuration, scope, unsupported integrations, verification and migration from whole-CLI bwrap.
## Configuration layers
+8 -4
View File
@@ -4,7 +4,7 @@ This document explains tool execution confinement on Linux and the existing whol
## Linux tool execution sandbox
On Linux, install `bwrap` (Bubblewrap) and enable the tool execution boundary in your **User** settings (`~/.qwen/settings.json`, or the directory selected by `QWEN_HOME`) or administrator **System** settings:
On Linux, install `bwrap` (Bubblewrap) for the strongest supported boundary, then enable the tool execution sandbox in your **User** settings (`~/.qwen/settings.json`, or the directory selected by `QWEN_HOME`) or administrator **System** settings:
```json
{
@@ -18,9 +18,13 @@ On Linux, install `bwrap` (Bubblewrap) and enable the tool execution boundary in
}
```
`filesystem` and `network` are required. `backend` defaults to `auto`; both `auto` and `bwrap` currently select bwrap. Landlock is not available in this release. Use `read-only` to deny workspace writes, or `workspace-write` to permit writes inside the current canonical workspace. Each command also gets private scratch space. Ordinary `includeDirectories` settings do not grant write access. Keep the workspace separate from the Qwen installation, user configuration and runtime state directories.
`filesystem` and `network` are required. `backend` defaults to `auto`, which tries bwrap first. When bwrap is unavailable and `network` is `open`, `auto` can use the bundled Landlock helper on kernels with Landlock ABI 3 or newer. Set `backend` to `bwrap` or `landlock` to require that exact implementation. Landlock cannot enforce `network: closed` or hide explicit masked paths. Either requirement rejects Landlock before any command starts; automatic fallback never discards a remaining path mask. Use `read-only` to deny workspace writes, or `workspace-write` to permit writes inside the current canonical workspace. Each command also gets private scratch space. Ordinary `includeDirectories` settings do not grant write access. Keep the workspace separate from the Qwen installation, user configuration and runtime state directories.
The CLI, model transport, authentication and session storage remain on the host. Shell, terminal `!`, prompt shell interpolation, Monitor, Read/Write/Edit and supported nested in-process Agent/Code Mode calls use the same runtime policy. `network: closed` blocks ordinary host/external IP connections from commands, while `open` shares the host network. Reads remain broad: this mode does not hide host secrets or promise to isolate pathname Unix sockets and host services. Approval and YOLO do not expand the filesystem or network policy.
The CLI, model transport, authentication and session storage remain on the host. Shell, terminal `!`, prompt shell interpolation, Monitor, Read/Write/Edit and supported nested in-process Agent/Code Mode calls use the same runtime policy. With bwrap, `network: closed` blocks ordinary host/external IP connections from commands, while `open` shares the host network. Reads remain broad: this mode does not hide host secrets or promise to isolate pathname Unix sockets and host services. Approval and YOLO do not expand the filesystem or network policy.
Landlock reports `partial` enforcement because it restricts pathname reads, writes and directory mutations but does not create PID or network namespaces and cannot currently restrict every metadata operation, including `chmod`, `chown`, extended attributes and timestamps. A Landlock command can see host processes and uses host networking. Detached descendants retain the Landlock filesystem restrictions but do not get bwrap's PID-namespace lifetime boundary. Open file descriptors retain their existing access. Qwen Code streams host-backed file and named-FIFO stdin through a pipe instead of passing those descriptors to the payload; sockets, character devices and anonymous pipes can be shared. Output standard streams remain the caller's explicit destinations. The execution-status descriptor closes on payload exec.
Landlock also has a narrower writable device surface: it grants writes to `/dev/null`, but does not create bwrap's private `/dev` and `/proc` mounts. Opening `/dev/full` or `/dev/tty` for writing and creating files in `/dev/shm` are denied, so programs requiring these operations may fail. Use the private scratch directory exposed through `TMPDIR`, `TMP`, and `TEMP` for temporary files. Already-open, caller-provided standard streams keep their existing access. Startup's Landlock policy probe requires `/usr/bin/true`; a host missing it fails the probe even if its kernel supports Landlock.
Workspace settings cannot enable, disable or modify this policy, even in a trusted project. Effective precedence is System over User over SystemDefaults, selecting a complete policy object. Values must be literals; environment substitutions, unknown fields and incomplete objects are rejected. `--bare` and `--safe-mode` retain operator confinement. Changes require a new runtime. A malformed operator settings file fails startup instead of resetting an unknown sandbox policy to empty settings.
@@ -37,7 +41,7 @@ qwen sandbox -- sh -c 'printf "confined command\n"'
qwen -y -p "Update the project and run its tests"
```
The report names the tool boundary, requested/effective backend, workspace, filesystem and command network policy. `--verify` checks workspace writes, denial against a file known to be writable on the host, private PID namespace identity and the selected network namespace. Backend setup errors fail before payload execution and never rerun the command on the host. The one-command subcommand forwards literal arguments and redirected stdin while preserving stdout and stderr byte-for-byte; use terminal `!` for interactive PTY commands.
The report names the tool boundary, requested/effective backend, enforcement level, Landlock ABI when applicable, workspace, filesystem and command network policy. `--verify` checks workspace writes, denial against a file known to be writable on the host, the expected private or shared PID namespace identity, and the selected network namespace. Backend setup errors fail before payload execution and never rerun the command on the host. The one-command subcommand forwards literal arguments and redirected stdin while preserving stdout and stderr byte-for-byte; use terminal `!` for interactive PTY commands.
### Migrating from whole-CLI bwrap
+1
View File
@@ -298,6 +298,7 @@ const codeModeHostBuild = esbuild.build({
const sandboxWorkersBuild = esbuild.build({
entryPoints: {
sandboxBwrapRelay: 'packages/core/src/sandbox/bwrap-relay.ts',
sandboxLandlockRelay: 'packages/core/src/sandbox/landlock-relay.ts',
sandboxFileWorker: 'packages/core/src/sandbox/file-worker.ts',
},
bundle: true,
+8 -4
View File
@@ -36,8 +36,8 @@ vi.mock('@qwen-code/qwen-code-core/sandbox/runtime-shell-policy.js', () => ({
params.shellExecutionSandbox,
probeShellSandbox: mocks.probe,
}));
vi.mock('@qwen-code/qwen-code-core/sandbox/bwrap-execution.js', () => ({
executeBwrap: mocks.execute,
vi.mock('@qwen-code/qwen-code-core/sandbox/execute-sandbox.js', () => ({
executeSandbox: mocks.execute,
}));
vi.mock('../utils/stdioHelpers.js', () => ({
writeStdoutLine: mocks.stdout,
@@ -70,7 +70,11 @@ beforeEach(() => {
mocks.settings.mockReturnValue({ merged: configured });
mocks.minimal.mockReturnValue({ merged: configured });
mocks.legacy.mockResolvedValue(undefined);
mocks.probe.mockResolvedValue(undefined);
mocks.probe.mockImplementation(async (policy: object) => ({
...policy,
effectiveBackend: 'bwrap',
enforcement: 'full',
}));
mocks.execute.mockResolvedValue({
result: Promise.resolve({ exitCode: 42, error: null, aborted: false }),
});
@@ -85,7 +89,7 @@ describe('qwen sandbox tool boundary', () => {
it('reports actual scope and probes without running a user payload', async () => {
await run();
expect(mocks.stdout.mock.calls.flat().join('\n')).toContain(
'Boundary: tools; backend: auto → bwrap',
'Boundary: tools; backend: auto → bwrap (full)',
);
expect(mocks.stdout.mock.calls.flat().join('\n')).toContain(
'traffic stay on the host',
+23 -11
View File
@@ -92,8 +92,8 @@ export const sandboxCommand: CommandModule = {
const { admitShellSandbox, probeShellSandbox } = await import(
'@qwen-code/qwen-code-core/sandbox/runtime-shell-policy.js'
);
const { executeBwrap } = await import(
'@qwen-code/qwen-code-core/sandbox/bwrap-execution.js'
const { executeSandbox } = await import(
'@qwen-code/qwen-code-core/sandbox/execute-sandbox.js'
);
const { sanitizeChildEnv } = await import(
'@qwen-code/qwen-code-core/utils/sanitize-child-env.js'
@@ -103,7 +103,7 @@ export const sandboxCommand: CommandModule = {
process.cwd(),
);
const candidate = createExecutionSandboxPolicy(selected, process.cwd());
const policy = admitShellSandbox(
const admitted = admitShellSandbox(
{
model: '',
debugMode: false,
@@ -114,14 +114,16 @@ export const sandboxCommand: CommandModule = {
Storage.getRuntimeBaseDir(),
Storage.getGlobalQwenDir(),
)!;
report(`Boundary: tools; backend: ${policy.requestedBackend} → bwrap`);
const policy = await probeShellSandbox(admitted, controller.signal);
report(
`Boundary: tools; backend: ${policy.requestedBackend} → ${policy.effectiveBackend} (${policy.enforcement}${policy.landlockAbi ? `, ABI ${policy.landlockAbi}` : ''})`,
);
report(
`Filesystem: ${policy.filesystem}; workspace: ${policy.workspace}`,
);
report(`Command network: ${policy.network}`);
report('Model, authentication and session traffic stay on the host.');
report('Host reads and pathname Unix sockets remain accessible.');
await probeShellSandbox(policy, controller.signal);
report('Backend probe: passed');
const env = Object.fromEntries(
Object.entries(sanitizeChildEnv(process.env)).filter(
@@ -141,7 +143,7 @@ export const sandboxCommand: CommandModule = {
process.stderr.on('error', handleOutputError);
try {
// env resolves PATH inside confinement and receives literal argv.
const handle = await executeBwrap(
const handle = await executeSandbox(
policy,
{
executable: '/usr/bin/env',
@@ -217,7 +219,7 @@ export const sandboxCommand: CommandModule = {
if (inside === 'allowed') fs.unlinkSync(process.argv[1]);
console.log(JSON.stringify({inside, outside:write(process.argv[2], 'w'), pid:fs.readlinkSync('/proc/self/ns/pid'), net:fs.readlinkSync('/proc/self/ns/net')}));
`;
const handle = await executeBwrap(
const handle = await executeSandbox(
policy,
{
executable: fs.realpathSync(process.execPath),
@@ -239,16 +241,26 @@ export const sandboxCommand: CommandModule = {
[
'workspace policy',
observed['inside'] ===
(policy.filesystem === 'workspace-write' ? 'allowed' : 'EROFS'),
(policy.filesystem === 'workspace-write'
? 'allowed'
: policy.effectiveBackend === 'landlock'
? 'EACCES'
: 'EROFS'),
],
[
'host-writable outside file denied',
observed['outside'] === 'EROFS' &&
observed['outside'] ===
(policy.effectiveBackend === 'landlock' ? 'EACCES' : 'EROFS') &&
fs.readFileSync(outside, 'utf8') === 'unchanged',
],
[
'private PID namespace',
typeof observed['pid'] === 'string' && observed['pid'] !== hostPid,
policy.effectiveBackend === 'landlock'
? 'shared PID namespace (Landlock boundary)'
: 'private PID namespace',
typeof observed['pid'] === 'string' &&
(policy.effectiveBackend === 'landlock'
? observed['pid'] === hostPid
: observed['pid'] !== hostPid),
],
[
'command network namespace',
@@ -6,7 +6,7 @@
import path from 'node:path';
import { Storage } from '@qwen-code/qwen-code-core/config/storage.js';
import { sandboxAsset } from '@qwen-code/qwen-code-core/sandbox/bwrap-execution.js';
import { sandboxAsset } from '@qwen-code/qwen-code-core/sandbox/sandbox-execution.js';
import type { ShellExecutionSandboxPolicy } from '@qwen-code/qwen-code-core/config/config.js';
import type { ExecutionSandboxSettings } from './execution-sandbox-settings.js';
@@ -16,7 +16,7 @@ export function createExecutionSandboxPolicy(
): ShellExecutionSandboxPolicy {
if (process.platform !== 'linux') {
throw new Error(
'tools.executionSandbox currently requires Linux with bwrap.',
'tools.executionSandbox requires Linux with a compatible bwrap or Landlock backend.',
);
}
return {
@@ -64,7 +64,6 @@ describe('operator execution sandbox policy', () => {
false,
[],
{},
{ ...restricted, backend: 'landlock' },
{ ...restricted, backend: ['auto'] },
{ ...restricted, network: '${MODE}' },
{ ...restricted, workspace: '/' },
@@ -73,6 +72,14 @@ describe('operator execution sandbox policy', () => {
'tools.executionSandbox',
);
});
it('accepts an explicit Landlock backend for capability validation at startup', () => {
expect(
parseExecutionSandboxSettings({
...writable,
backend: 'landlock',
}),
).toEqual({ ...writable, backend: 'landlock' });
});
it('requires complete objects even in a higher priority scope', () => {
expect(() =>
selectOperatorExecutionSandbox(
@@ -14,7 +14,7 @@ import {
} from './storage-paths-lite.js';
export interface ExecutionSandboxSettings {
backend?: 'auto' | 'bwrap';
backend?: 'auto' | 'bwrap' | 'landlock';
filesystem: 'read-only' | 'workspace-write';
network: 'open' | 'closed';
}
@@ -45,12 +45,13 @@ export function parseExecutionSandboxSettings(
!['open', 'closed'].includes(String(value.network)) ||
('backend' in value &&
value.backend !== 'auto' &&
value.backend !== 'bwrap') ||
value.backend !== 'bwrap' &&
value.backend !== 'landlock') ||
typeof value.filesystem !== 'string' ||
typeof value.network !== 'string'
) {
throw new InvalidExecutionSandboxConfigError(
'tools.executionSandbox requires literal filesystem (read-only | workspace-write), network (open | closed), and optional backend (auto | bwrap). Unknown fields and environment interpolation are not supported.',
'tools.executionSandbox requires literal filesystem (read-only | workspace-write), network (open | closed), and optional backend (auto | bwrap | landlock). Unknown fields and environment interpolation are not supported.',
);
}
return { ...(value as ExecutionSandboxSettings) };
+4 -1
View File
@@ -2829,7 +2829,10 @@ const SETTINGS_SCHEMA = {
required: ['filesystem', 'network'],
additionalProperties: false,
properties: {
backend: { type: 'string', enum: ['auto', 'bwrap'] },
backend: {
type: 'string',
enum: ['auto', 'bwrap', 'landlock'],
},
filesystem: {
type: 'string',
enum: ['read-only', 'workspace-write'],
@@ -287,12 +287,14 @@ describe('<Footer />', () => {
const { lastFrame } = renderWithWidth(180, createMockUIState(), {
getShellExecutionSandbox: () => ({
requestedBackend: 'bwrap',
effectiveBackend: 'bwrap',
enforcement: 'full',
filesystem: 'read-only',
network: 'closed',
}),
});
expect(lastFrame()).toContain(
'tools / bwrap → bwrap / read-only / command network: closed',
'tools / bwrap → bwrap (full) / read-only / command network: closed',
);
});
@@ -283,15 +283,18 @@ describe('OpenTuiFooter', () => {
it('shows the current runtime tool policy without an environment marker', () => {
const config = fakeConfig({
getShellExecutionSandbox: () =>
({ filesystem: 'read-only', network: 'closed' }) as ReturnType<
Config['getShellExecutionSandbox']
>,
({
filesystem: 'read-only',
network: 'closed',
effectiveBackend: 'bwrap',
enforcement: 'full',
}) as ReturnType<Config['getShellExecutionSandbox']>,
});
const { container } = render(
<OpenTuiFooter config={config} streaming={false} />,
);
expect(container.textContent).toContain(
'tools / auto → bwrap / read-only / command network: closed',
'tools / auto → bwrap (full) / read-only / command network: closed',
);
});
+3 -1
View File
@@ -226,12 +226,14 @@ describe('systemInfo', () => {
.fn()
.mockReturnValue({
requestedBackend: 'bwrap',
effectiveBackend: 'bwrap',
enforcement: 'full',
filesystem: 'read-only',
network: 'closed',
});
const info = await getExtendedSystemInfo(mockContext);
expect(info.sandboxEnv).toBe(
'tools / bwrap → bwrap / read-only / command network: closed',
'tools / bwrap → bwrap (full) / read-only / command network: closed',
);
expect(mockedExecFile).not.toHaveBeenCalled();
});
@@ -11,5 +11,9 @@ export function formatExecutionSandbox(
): string | undefined {
const policy = config?.getShellExecutionSandbox?.();
if (!policy) return undefined;
return `tools / ${policy.requestedBackend ?? 'auto'} → bwrap / ${policy.filesystem} / command network: ${policy.network}`;
const effective = policy.effectiveBackend ?? 'unresolved';
const enforcement = policy.enforcement
? ` (${policy.enforcement}${policy.landlockAbi ? `, ABI ${policy.landlockAbi}` : ''})`
: '';
return `tools / ${policy.requestedBackend ?? 'auto'} → ${effective}${enforcement} / ${policy.filesystem} / command network: ${policy.network}`;
}
+21
View File
@@ -17,6 +17,7 @@ const __dirname = path.dirname(__filename);
// Get the package root directory
const packageRoot = path.join(__dirname, '..');
const vendorDir = path.join(packageRoot, 'vendor', 'ripgrep');
const landlockVendorDir = path.join(packageRoot, 'vendor', 'landlock-run');
/**
* Remove quarantine attribute and set executable permissions on macOS/Linux
@@ -90,9 +91,29 @@ function setupRipgrepBinaries() {
}
}
function setupLandlockBinary() {
if (process.platform !== 'linux') return;
if (process.arch !== 'x64' && process.arch !== 'arm64') return;
const binary = path.join(
landlockVendorDir,
`${process.arch}-linux`,
'qwen-landlock-run',
);
if (!fs.existsSync(binary)) return;
try {
fs.chmodSync(binary, 0o755);
console.log(`✓ Set executable permissions on ${binary}`);
} catch (error) {
console.log(
`⚠ Could not set Landlock helper permissions: ${error.message || 'Unknown error'}`,
);
}
}
// Wrap the entire execution to ensure no errors escape to npm
try {
setupRipgrepBinaries();
setupLandlockBinary();
} catch {
// Last resort catch - never let errors block npm
console.log('⚠ Postinstall script encountered an unexpected error');
+14 -6
View File
@@ -3111,20 +3111,24 @@ describe('Server Config (config.ts)', () => {
});
it('keeps pure skill reads and registers only admitted tools after a successful probe', async () => {
const resolvedPolicy = {
...parameters().shellExecutionSandbox,
effectiveBackend: 'bwrap' as const,
enforcement: 'full' as const,
};
const probe = vi
.spyOn(sandboxPolicy, 'probeShellSandbox')
.mockResolvedValue();
.mockResolvedValue(resolvedPolicy);
try {
const config = new Config(parameters());
const admittedPolicy = config.getShellExecutionSandbox();
const refreshExtensions = vi.spyOn(
config.getExtensionManager(),
'refreshCache',
);
await config.initialize();
expect(probe).toHaveBeenCalledWith(
config.getShellExecutionSandbox(),
undefined,
);
expect(probe).toHaveBeenCalledWith(admittedPolicy, undefined);
expect(config.getShellExecutionSandbox()).toBe(resolvedPolicy);
expect(HookSystem).not.toHaveBeenCalled();
expect(maybeRunAutoSkillCurator).not.toHaveBeenCalled();
expect(refreshExtensions).not.toHaveBeenCalled();
@@ -3155,7 +3159,11 @@ describe('Server Config (config.ts)', () => {
it('omits user-interaction tools from the admitted headless registry', async () => {
const probe = vi
.spyOn(sandboxPolicy, 'probeShellSandbox')
.mockResolvedValue();
.mockResolvedValue({
...parameters().shellExecutionSandbox,
effectiveBackend: 'bwrap',
enforcement: 'full',
});
try {
const config = new Config({
...parameters(),
+11 -6
View File
@@ -240,7 +240,10 @@ 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 type {
ExecutionSandboxPolicy,
ResolvedExecutionSandboxPolicy,
} from '../sandbox/sandbox-execution.js';
import {
admitShellSandbox,
probeShellSandbox,
@@ -1596,9 +1599,7 @@ export interface ConfigParameters {
settingsWatcher?: { stopWatching(): void };
}
export interface ShellExecutionSandboxPolicy extends BwrapPolicy {
requestedBackend?: 'auto' | 'bwrap';
}
export type ShellExecutionSandboxPolicy = ExecutionSandboxPolicy;
export type TerminalImageRenderSupport =
| { available: true }
@@ -2601,8 +2602,9 @@ export function deriveConfig(
}
export class Config {
private readonly shellExecutionSandbox:
private shellExecutionSandbox:
| Readonly<ShellExecutionSandboxPolicy>
| Readonly<ResolvedExecutionSandboxPolicy>
| undefined;
private sessionId: string;
private sessionSourceType?: string;
@@ -3849,7 +3851,10 @@ export class Config {
): Promise<void> {
try {
if (this.shellExecutionSandbox)
await probeShellSandbox(this.shellExecutionSandbox, options?.signal);
this.shellExecutionSandbox = await probeShellSandbox(
this.shellExecutionSandbox,
options?.signal,
);
const activation = this.activateChatRecording();
this.sessionWriterActivationPromise = activation;
try {
+2
View File
@@ -416,6 +416,8 @@ export function getMainSessionBaseSystemPrompt(
declaredTools: config.getPromptToolSnapshot?.(),
executionSandboxFilesystem:
config.getShellExecutionSandbox?.()?.filesystem,
executionSandboxBackend:
config.getShellExecutionSandbox?.()?.effectiveBackend,
},
);
}
+36
View File
@@ -1118,6 +1118,42 @@ describe('main-session style: reminder decision matches prompt section', () => {
expect(prompt).toContain('workspace is read-only');
expect(prompt).not.toContain('# Outside of Sandbox');
});
it.each(['read-only', 'workspace-write'] as const)(
'describes resolved Landlock restrictions for %s',
(filesystem) => {
const config = {
...makeConfig({ interactive: false, acp: false }),
getShellExecutionSandbox: () => ({
filesystem,
workspace: '/workspace',
installation: '/installation',
state: '/state',
network: 'open' as const,
requestedBackend: 'auto' as const,
effectiveBackend: 'landlock' as const,
enforcement: 'partial' as const,
landlockAbi: 3,
}),
};
const prompt = getMainSessionBaseSystemPrompt(config);
expect(prompt).toContain('# Tool Execution Sandbox (Landlock, partial)');
expect(prompt).toContain(
`workspace is ${filesystem === 'workspace-write' ? 'writable' : 'read-only'}`,
);
expect(prompt).toContain('Treat EACCES as a possible sandbox refusal');
expect(prompt).toContain(
'report it to the user and name the refused path',
);
expect(prompt).toContain('Do NOT work around a refusal');
expect(prompt).toContain('metadata operations');
expect(prompt).toContain('does not create PID or network namespaces');
expect(prompt).not.toContain('EROFS');
expect(prompt).not.toContain('# Tool Execution Sandbox (bwrap)');
expect(prompt).not.toContain('# Outside of Sandbox');
},
);
});
describe('main-session style: project trust gate', () => {
+8
View File
@@ -271,6 +271,7 @@ export function getCustomSystemPrompt(
export interface PromptToolSurface {
declaredTools?: ReadonlySet<string>;
executionSandboxFilesystem?: 'read-only' | 'workspace-write';
executionSandboxBackend?: 'bwrap' | 'landlock';
}
/**
@@ -589,6 +590,13 @@ ${(function () {
const isGenericSandbox = !!process.env['SANDBOX']; // Check if SANDBOX is set to any non-empty value
if (executionSandboxFilesystem) {
if (surface?.executionSandboxBackend === 'landlock') {
return `
# Tool Execution Sandbox (Landlock, partial)
Shell commands and file mutations run under Landlock filesystem restrictions. The workspace is ${executionSandboxFilesystem === 'workspace-write' ? 'writable' : 'read-only'} for file content and directory changes; writes outside the admitted writable roots are denied. Enforcement is partial: metadata operations such as chmod, chown, extended attributes, and timestamps are not fully confined. Landlock does not create PID or network namespaces; host reads, process visibility, and reachable host services remain outside this boundary.
A refused pathname write can fail with 'Permission denied' (EACCES), which can also come from ordinary file permissions. Treat EACCES as a possible sandbox refusal: report it to the user and name the refused path. Do NOT work around a refusal by writing somewhere else, escalating privileges, or retrying the same write.
`;
}
return `
# Tool Execution Sandbox (bwrap)
Shell commands and file mutations are confined by a kernel-level sandbox. The host filesystem is mounted READ-ONLY outside the admitted workspace; the workspace is ${executionSandboxFilesystem === 'workspace-write' ? 'writable' : 'read-only'}, and command network access follows the operator policy. A write refused by a read-only mount fails with 'Read-only file system' (EROFS). 'Permission denied' (EACCES) can instead come from ordinary file permissions. Host reads and pathname Unix sockets remain accessible.
@@ -252,6 +252,31 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => {
expect(existsSync(path.join(process.cwd(), 'relative-tmp'))).toBe(false);
});
it('snapshots the admitted policy and payload before asynchronous setup', async () => {
const launch = mockLaunch({ state: 'confirmed', exitCode: 0 });
const admitted = policy();
const command = payload();
const pending = executeBwrap(
admitted,
command,
() => {},
new AbortController().signal,
);
Object.assign(admitted, { filesystem: 'read-only', network: 'open' });
command.executable = '/bin/false';
Object.assign(command, { args: ['--mutated'] });
const handle = await pending;
await handle.result;
const argv = launch.mock.calls[0][0].args;
expect(argv).toContain('--unshare-net');
expect(argv).toContain(realpathSync(workspace));
expect(argv).toContain(process.execPath);
expect(argv).toContain('--version');
expect(argv).not.toContain('/bin/false');
expect(argv).not.toContain('--mutated');
});
it('rejects an overlapping temporary root before launching or leaking control state', async () => {
process.env['TMPDIR'] = state;
const launch = vi.spyOn(ShellExecutionService, 'executeLaunch');
+66 -360
View File
@@ -5,102 +5,35 @@
*/
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,
isSignalTermination,
} from '../services/shellExecutionService.js';
import type {
ProcessLaunch,
ShellExecutionConfig,
ShellExecuteOptions,
ShellExecutionResult,
ShellOutputEvent,
ShellPostPromoteSettleInfo,
} from '../services/shellExecutionService.js';
import { sandboxStatusError, type BwrapStatus } from './bwrap-status.js';
import {
executeSandboxRelay,
sandboxAsset,
type ExecutionSandboxPolicy,
type SandboxExecutionHandle,
type SandboxExecutionResult,
} from './sandbox-execution.js';
const debugLogger = createDebugLogger('BWRAP_EXECUTION');
export interface BwrapPolicy {
workspace: string;
installation: string;
state: string;
maskedPaths?: readonly string[];
filesystem: 'read-only' | 'workspace-write';
network: 'open' | 'closed';
bwrapPath?: string;
}
export interface BwrapExecutionResult extends ShellExecutionResult {
sandboxStatus: BwrapStatus;
}
export interface BwrapExecutionHandle {
pid: number | undefined;
result: Promise<BwrapExecutionResult>;
settled: Promise<BwrapStatus>;
}
export function sandboxAsset(name: 'bwrap-relay' | 'file-worker'): string {
const sibling = path.join(
path.dirname(fileURLToPath(import.meta.url)),
`${name}.js`,
);
const bundled = path.join(
resolveBundleDir(import.meta.url),
name === 'bwrap-relay' ? 'sandboxBwrapRelay.js' : 'sandboxFileWorker.js',
);
const asset = existsSync(sibling) ? sibling : bundled;
if (!existsSync(asset))
throw new Error(
'Sandbox assets are missing. Run the build and bundle first.',
);
return realpathSync(asset);
}
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.');
const resolved = realpathSync(value);
if (!statSync(resolved).isDirectory())
throw new Error('Expected sandbox directory.');
return resolved;
};
export { sandboxAsset } from './sandbox-execution.js';
export type BwrapPolicy = ExecutionSandboxPolicy;
export type BwrapExecutionHandle = SandboxExecutionHandle;
export type BwrapExecutionResult = SandboxExecutionResult;
export async function executeBwrap(
policy: BwrapPolicy,
policy: ExecutionSandboxPolicy,
payload: ProcessLaunch,
onOutput: (event: ShellOutputEvent) => void,
signal: AbortSignal,
usePty = false,
config: ShellExecutionConfig = {},
options: ShellExecuteOptions = {},
): Promise<BwrapExecutionHandle> {
if (process.platform !== 'linux')
throw new Error('bwrap execution requires Linux.');
if (
!['read-only', 'workspace-write'].includes(policy.filesystem) ||
!['open', 'closed'].includes(policy.network)
) {
throw new Error('Unsupported sandbox policy.');
}
if (!path.isAbsolute(payload.executable) || !path.isAbsolute(payload.cwd))
throw new Error('Payload paths must be absolute.');
if (payload.stdin !== undefined && payload.inheritStdin)
throw new Error('Process stdin cannot be both piped and inherited.');
if (usePty && (payload.stdin !== undefined || payload.inheritStdin))
throw new Error('Process stdin requires pipe execution.');
const workspace = directory(policy.workspace);
const state = directory(policy.state);
): Promise<SandboxExecutionHandle> {
const relay = sandboxAsset('bwrap-relay');
const node = realpathSync(process.execPath);
const requestedBwrap = policy.bwrapPath ?? '/usr/bin/bwrap';
@@ -109,232 +42,52 @@ export async function executeBwrap(
const bwrap = existsSync(requestedBwrap)
? realpathSync(requestedBwrap)
: requestedBwrap;
const protectedRoots = [
directory(policy.installation),
state,
path.dirname(relay),
path.dirname(node),
path.dirname(bwrap),
...[
'/proc',
'/dev',
'/sys',
'/etc',
'/usr',
'/bin',
'/sbin',
'/lib',
'/lib64',
]
.filter(existsSync)
.map((value) => realpathSync(value)),
];
const checkWritable = (root: string) => {
if (
isSubpath(root, realpathSync(os.homedir())) ||
protectedRoots.some((protectedRoot) => overlaps(root, protectedRoot))
) {
throw new Error('Writable directory overlaps a protected root.');
}
};
// 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 (!isSubpath(workspace, cwd))
throw new Error('Payload cwd must be inside the workspace.');
const executable = payload.executable;
const args = [...payload.args];
const env = Object.fromEntries(
Object.entries(payload.env).filter(([key]) => !isInternalSecretEnvVar(key)),
);
const stdin = Buffer.isBuffer(payload.stdin)
? Buffer.from(payload.stdin)
: payload.stdin;
const inheritStdin = payload.inheritStdin;
const filesystem = policy.filesystem;
const network = policy.network;
const control = await mkdtemp(path.join(state, 'sandbox-control-'));
let scratch: string | undefined;
const cleanup = async () => {
const results = await Promise.allSettled([
rm(control, { recursive: true, force: true }),
...(scratch ? [rm(scratch, { recursive: true, force: true })] : []),
]);
for (const result of results) {
if (result.status === 'rejected')
debugLogger.warn(
'Sandbox temporary directory cleanup failed',
result.reason,
);
}
};
try {
const requestedScratchRoot = os.tmpdir();
const scratchRoot =
path.isAbsolute(requestedScratchRoot) && existsSync(requestedScratchRoot)
? realpathSync(requestedScratchRoot)
: realpathSync('/tmp');
if (
isSubpath(workspace, scratchRoot) ||
protectedRoots.some((protectedRoot) =>
isSubpath(protectedRoot, scratchRoot),
)
)
throw new Error(
`Temporary root ${scratchRoot} overlaps the workspace or a protected root.`,
);
scratch = await mkdtemp(path.join(scratchRoot, 'qwen-sandbox-'));
scratch = directory(scratch);
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',
'/',
'/',
'--unshare-pid',
'--proc',
'/proc',
'--dev',
'/dev',
'--die-with-parent',
];
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 });
return executeSandboxRelay(
policy,
payload,
[path.dirname(relay), path.dirname(node), path.dirname(bwrap)],
({
workspace,
cwd,
executable,
args,
filesystem,
network,
scratch,
statusPath,
payloadEnvPath,
maskedPaths,
env,
stdin,
inheritStdin,
}) => {
const bwrapArgs = [
'--ro-bind',
'/',
'/',
'--unshare-pid',
'--proc',
'/proc',
'--dev',
'/dev',
'--die-with-parent',
];
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 (!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');
let complete!: (status: BwrapStatus) => void;
const settled = new Promise<BwrapStatus>((resolve) => {
complete = resolve;
});
let finalizing: Promise<BwrapStatus> | undefined;
const finalize = (info: {
signal: number | NodeJS.Signals | null;
aborted?: boolean;
exitCode: number | null;
error?: unknown;
}) =>
(finalizing ??= (async () => {
let status: BwrapStatus = { state: 'unconfirmed' };
// The relay creates the receipt file (O_EXCL) before spawning bwrap,
// so its existence attests the relay got as far as the spawn call.
let receiptExisted = false;
let receiptParsed = false;
if (info.aborted || isSignalTermination(info.signal))
status = { state: 'interrupted' };
else {
try {
const text = await readFile(statusPath, 'utf8');
// The file existing at all attests the relay got past its
// O_EXCL create — i.e. as far as the bwrap spawn call.
receiptExisted = true;
const record = JSON.parse(text) as Record<string, unknown>;
receiptParsed = true;
if (
!info.error &&
record['state'] === 'confirmed' &&
record['exitCode'] === info.exitCode &&
typeof record['exitCode'] === 'number' &&
Number.isInteger(record['exitCode']) &&
record['exitCode'] >= 0 &&
record['exitCode'] <= 255
)
status = { state: 'confirmed', exitCode: record['exitCode'] };
else if (record['state'] === 'interrupted')
status = { state: 'interrupted' };
else {
// Preserve the attestation exactly as written: an absent or
// non-boolean field means "unknown", never "did not run"
// (PR #12067 review, round 2).
const attested = record['payloadExitObserved'];
status = {
state: 'unconfirmed',
...(typeof attested === 'boolean'
? { payloadExitObserved: attested }
: {}),
};
}
} catch {
/* Missing/partial receipt never proves that the payload did not run. */
}
}
// Retain the dirs unless the payload provably did not run. Two
// positive proofs allow cleanup: the receipt attests no payload
// exit record (spawn failure, or a wire showing the payload never
// got past exec — missing bwrap or payload binary), or the receipt
// file is absent, which means the relay died before its O_EXCL
// create and therefore before spawning bwrap. Everything else —
// an attested exit record, an unreadable receipt, or an
// unattested unconfirmed — is unknown and retains (PR #12067
// review: the coarse key leaked a dir pair per pre-exec failure,
// while collapsing "unknown" into "did not run" inverts the
// fail-safe for a retry-deciding caller).
const attestedNoExec =
receiptExisted &&
receiptParsed &&
status.state === 'unconfirmed' &&
status.payloadExitObserved === false;
const relayDiedBeforeSpawn = !receiptExisted;
const retain =
status.state === 'unconfirmed' &&
!attestedNoExec &&
!relayDiedBeforeSpawn;
if (retain) {
debugLogger.warn(
'Sandbox termination is unconfirmed; retaining temporary directories',
{ control, scratch },
);
} else {
await cleanup();
}
complete(status);
return status;
})());
const handle = await ShellExecutionService.executeLaunch(
{
if (network === 'closed') bwrapArgs.push('--unshare-net');
bwrapArgs.push('--chdir', cwd, '--', executable, ...args);
return {
executable: node,
args: [
relay,
@@ -353,59 +106,12 @@ export async function executeBwrap(
},
stdin,
inheritStdin,
},
onOutput,
signal,
usePty,
config,
{
...options,
postPromote: {
onData: options.postPromote?.onData,
onSettle: (info: ShellPostPromoteSettleInfo) => {
void finalize(info)
.then((status) => {
// The specific transport/spawn error outranks the generic
// status-derived one: an unconfirmed run caused by a relay
// spawn failure should surface the spawn error, not the
// catch-all "could not be confirmed" (PR #12067 review).
const error = info.error ?? sandboxStatusError(status);
options.postPromote?.onSettle?.({ ...info, error });
})
.catch((settleError: unknown) => {
// The caller's postPromote.onSettle throws here, one await
// past the service's own try/catch guard — log instead of
// discarding (PR #12067 review).
debugLogger.warn(
`post-promote settle chain failed: ${settleError instanceof Error ? settleError.message : String(settleError)}`,
);
});
},
},
},
);
return {
pid: handle.pid,
settled,
result: handle.result.then(
async (result) => {
const sandboxStatus: BwrapStatus = result.promoted
? { state: 'running' }
: await finalize(result);
return {
...result,
error: result.error ?? sandboxStatusError(sandboxStatus) ?? null,
sandboxStatus,
};
},
async (error: unknown) => {
await finalize({ signal: null, exitCode: null, error });
throw error;
},
),
};
} catch (error) {
await cleanup();
throw error;
}
};
},
onOutput,
signal,
usePty,
config,
options,
);
}
+4
View File
@@ -62,6 +62,10 @@ if (input.isFIFO()) {
shareInput = false;
}
}
// Initialize Node's shared output descriptors before bwrap restores blocking
// mode; lazy initialization after spawn would race and re-enable O_NONBLOCK.
void process.stdout;
void process.stderr;
const child = spawn(bwrap, ['--json-status-fd', '3', ...args], {
stdio: [shareInput ? 'inherit' : 'pipe', 'inherit', 'inherit', 'pipe'],
env,
+9 -39
View File
@@ -4,50 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
export type BwrapStatus =
| { state: 'confirmed'; exitCode: number }
| {
state: 'unconfirmed';
/**
* True when the status wire carried a well-formed bwrap exit-code
* record — i.e. the payload got past exec and ran to an exit, so the
* run may genuinely have happened and its dirs are worth retaining.
* False is a positive attestation that the payload never executed
* (a bwrap spawn failure, or a wire with no exit-code record).
* Absent means unknown — an oversized or partial wire, or a
* non-spawn relay failure — and must never be read as "did not run".
*/
payloadExitObserved?: boolean;
}
| { state: 'interrupted' | 'running' };
import {
MAX_STATUS_BYTES,
sandboxStatusError,
type SandboxStatus,
} from './sandbox-status.js';
export const MAX_STATUS_BYTES = 16 * 1024;
export function sandboxStatusError(status: BwrapStatus): Error | undefined {
if (status.state === 'unconfirmed') {
// Only a positive no-exec attestation gets the definitive message;
// unknown stays on the cautious "may have run" wording so a
// retry-deciding caller is never told the payload did not run without
// evidence (PR #12067 review, round 2).
if (status.payloadExitObserved === false) {
return new Error(
'Sandbox payload did not run: setup failed before execution (bubblewrap or the payload binary may be missing).',
);
}
return new Error(
'Sandbox execution status could not be confirmed. The command may have run; do not automatically retry it.',
);
}
if (status.state === 'interrupted') {
return new Error('Sandbox execution was interrupted.');
}
return undefined;
}
export type BwrapStatus = SandboxStatus;
export { MAX_STATUS_BYTES, sandboxStatusError };
export type { SandboxStatus };
export function parseBwrapStatus(
wire: string,
exitCode: number | null,
): BwrapStatus {
): SandboxStatus {
if (Buffer.byteLength(wire) > MAX_STATUS_BYTES || !wire.endsWith('\n')) {
return { state: 'unconfirmed' };
}
@@ -0,0 +1,60 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const executeBwrap = vi.hoisted(() => vi.fn());
const executeLandlock = vi.hoisted(() => vi.fn());
vi.mock('./bwrap-execution.js', () => ({ executeBwrap }));
vi.mock('./landlock-execution.js', () => ({ executeLandlock }));
import { executeSandbox } from './execute-sandbox.js';
const policy = {
workspace: '/workspace',
installation: '/installation',
state: '/state',
filesystem: 'workspace-write' as const,
network: 'open' as const,
};
const payload = {
executable: '/bin/true',
args: [],
cwd: '/workspace',
env: {},
};
describe('sandbox backend dispatch', () => {
beforeEach(() => vi.clearAllMocks());
it.each([
['bwrap', executeBwrap],
['landlock', executeLandlock],
] as const)(
'dispatches only to the resolved %s backend',
async (backend, run) => {
run.mockResolvedValue({ pid: 1, result: Promise.resolve({}) });
await executeSandbox(
{ ...policy, effectiveBackend: backend, enforcement: 'partial' },
payload,
() => {},
new AbortController().signal,
);
expect(run).toHaveBeenCalledOnce();
expect(
backend === 'bwrap' ? executeLandlock : executeBwrap,
).not.toHaveBeenCalled();
},
);
it('rejects an unresolved policy without launching or falling back', async () => {
await expect(
executeSandbox(policy, payload, () => {}, new AbortController().signal),
).rejects.toThrow('backend was not resolved');
expect(executeBwrap).not.toHaveBeenCalled();
expect(executeLandlock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type {
ProcessLaunch,
ShellExecutionConfig,
ShellExecuteOptions,
ShellOutputEvent,
} from '../services/shellExecutionService.js';
import { executeBwrap } from './bwrap-execution.js';
import { executeLandlock } from './landlock-execution.js';
import type {
ExecutionSandboxPolicy,
SandboxExecutionHandle,
} from './sandbox-execution.js';
export function executeSandbox(
policy: Readonly<ExecutionSandboxPolicy>,
payload: ProcessLaunch,
onOutput: (event: ShellOutputEvent) => void,
signal: AbortSignal,
usePty = false,
config: ShellExecutionConfig = {},
options: ShellExecuteOptions = {},
): Promise<SandboxExecutionHandle> {
if (policy.effectiveBackend === 'bwrap') {
return executeBwrap(
policy,
payload,
onOutput,
signal,
usePty,
config,
options,
);
}
if (policy.effectiveBackend === 'landlock') {
return executeLandlock(
policy,
payload,
onOutput,
signal,
usePty,
config,
options,
);
}
return Promise.reject(new Error('Sandbox backend was not resolved.'));
}
@@ -6,13 +6,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { writeSandboxFile } from './file-worker-client.js';
import { executeBwrap } from './bwrap-execution.js';
import type { BwrapExecutionResult } from './bwrap-execution.js';
import { executeSandbox } from './execute-sandbox.js';
import type { SandboxExecutionResult } from './sandbox-execution.js';
import { readSandboxWriteRequest } from './file-worker-protocol.js';
import { Readable } from 'node:stream';
vi.mock('./bwrap-execution.js', () => ({
executeBwrap: vi.fn(),
vi.mock('./execute-sandbox.js', () => ({
executeSandbox: vi.fn(),
}));
vi.mock('./sandbox-execution.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./sandbox-execution.js')>()),
sandboxAsset: () => '/installed/file-worker.js',
}));
const policy = {
@@ -21,6 +24,8 @@ const policy = {
state: '/state',
filesystem: 'workspace-write' as const,
network: 'closed' as const,
effectiveBackend: 'bwrap' as const,
enforcement: 'full' as const,
};
const request = {
operation: 'write' as const,
@@ -29,11 +34,11 @@ const request = {
content: Buffer.from([0, 0xff, 10]),
};
function result(
overrides: Partial<BwrapExecutionResult> = {},
overrides: Partial<SandboxExecutionResult> = {},
reply = '{"ok":true}',
diagnostics = '',
) {
vi.mocked(executeBwrap).mockImplementation(async (...args) => {
vi.mocked(executeSandbox).mockImplementation(async (...args) => {
args[2]({ type: 'data', chunk: reply, stream: 'stdout' });
if (diagnostics)
args[2]({ type: 'data', chunk: diagnostics, stream: 'stderr' });
@@ -50,7 +55,7 @@ function result(
executionMethod: 'child_process',
sandboxStatus: { state: 'confirmed', exitCode: 0 },
...overrides,
} as BwrapExecutionResult),
} as SandboxExecutionResult),
};
});
}
@@ -61,10 +66,10 @@ describe('sandbox file worker client', () => {
});
it('uses only pipe stdin and accepts a confirmed successful reply', async () => {
await writeSandboxFile(policy, request, new AbortController().signal);
const launch = vi.mocked(executeBwrap).mock.calls[0][1];
const launch = vi.mocked(executeSandbox).mock.calls[0][1];
expect(launch.args).toEqual(['/installed/file-worker.js']);
expect(launch.env).toEqual({ PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' });
expect(vi.mocked(executeBwrap).mock.calls[0][6]).toEqual({
expect(vi.mocked(executeSandbox).mock.calls[0][6]).toEqual({
streamStdout: true,
});
expect(
@@ -117,7 +122,7 @@ describe('sandbox file worker client', () => {
it('forwards the caller abort signal and stays on the pipe transport', async () => {
const signal = new AbortController().signal;
await writeSandboxFile(policy, request, signal);
const call = vi.mocked(executeBwrap).mock.calls[0];
const call = vi.mocked(executeSandbox).mock.calls[0];
// The caller's signal is the only cancellation path for a sandboxed
// write; swapping it for a fresh controller lets a cancelled write run
// to completion and commit after the caller gave up (PR #12067 review).
@@ -141,7 +146,7 @@ describe('sandbox file worker client', () => {
await expect(
writeSandboxFile(policy, request, new AbortController().signal),
).rejects.toMatchObject({ code });
expect(executeBwrap).toHaveBeenCalledTimes(1);
expect(executeSandbox).toHaveBeenCalledTimes(1);
},
);
it.each([
@@ -158,7 +163,7 @@ describe('sandbox file worker client', () => {
await expect(
writeSandboxFile(policy, request, new AbortController().signal),
).rejects.toThrow();
expect(executeBwrap).toHaveBeenCalledTimes(1);
expect(executeSandbox).toHaveBeenCalledTimes(1);
},
);
it('rejects read-only and pre-aborted requests before launching', async () => {
@@ -174,6 +179,6 @@ describe('sandbox file worker client', () => {
await expect(writeSandboxFile(policy, request, ac.signal)).rejects.toThrow(
'stopped',
);
expect(executeBwrap).not.toHaveBeenCalled();
expect(executeSandbox).not.toHaveBeenCalled();
});
});
@@ -4,8 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { executeBwrap, sandboxAsset } from './bwrap-execution.js';
import type { BwrapPolicy } from './bwrap-execution.js';
import { executeSandbox } from './execute-sandbox.js';
import {
sandboxAsset,
type ExecutionSandboxPolicy,
} from './sandbox-execution.js';
import {
encodeSandboxWriteRequest,
MAX_FILE_HEADER_BYTES,
@@ -13,7 +16,7 @@ import {
} from './file-worker-protocol.js';
export async function writeSandboxFile(
policy: BwrapPolicy,
policy: ExecutionSandboxPolicy,
request: SandboxWriteRequest,
signal: AbortSignal,
): Promise<void> {
@@ -29,7 +32,7 @@ export async function writeSandboxFile(
let diagnostics = '';
const appendBounded = (current: string, chunk: string) =>
(current + chunk).slice(-MAX_FILE_HEADER_BYTES);
const handle = await executeBwrap(
const handle = await executeSandbox(
policy,
{
executable: process.execPath,
@@ -0,0 +1,305 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import {
chmodSync,
existsSync,
mkdtempSync,
mkdirSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ShellExecutionService } from '../services/shellExecutionService.js';
import type { ProcessLaunch } from '../services/shellExecutionService.js';
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<typeof import('node:fs')>()),
}));
const executeSandboxRelay = vi.hoisted(() => vi.fn());
vi.mock('./sandbox-execution.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./sandbox-execution.js')>()),
executeSandboxRelay,
sandboxAsset: () => '/installed/sandboxLandlockRelay.js',
}));
import {
executeLandlock,
landlockRunnerPath,
probeLandlock,
} from './landlock-execution.js';
describe('Landlock execution adapter', () => {
let root: string;
let runner: string;
let policy: {
workspace: string;
installation: string;
state: string;
filesystem: 'workspace-write';
network: 'open';
landlockPath?: string;
};
beforeEach(() => {
vi.restoreAllMocks();
executeSandboxRelay.mockReset();
root = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'landlock-test-')));
for (const name of ['workspace', 'installation', 'state'])
mkdirSync(path.join(root, name));
runner = path.join(root, 'installation', 'qwen-landlock-run');
writeFileSync(runner, 'fixture');
policy = {
workspace: path.join(root, 'workspace'),
installation: path.join(root, 'installation'),
state: path.join(root, 'state'),
filesystem: 'workspace-write',
network: 'open',
landlockPath: runner,
};
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
it('resolves source and packaged helper locations by target architecture', () => {
const coreRoot = fileURLToPath(new URL('../../', import.meta.url));
for (const arch of ['x64', 'arm64'] as const) {
const runnerPath = landlockRunnerPath('linux', arch);
expect(runnerPath).toBe(
path.join(
coreRoot,
'vendor',
'landlock-run',
`${arch}-linux`,
'qwen-landlock-run',
),
);
expect(existsSync(runnerPath)).toBe(true);
}
expect(() => landlockRunnerPath('darwin', 'arm64')).toThrow(
'does not support darwin/arm64',
);
});
it('does not treat an installation ancestor as the source sandbox directory', async () => {
const bundleRoot = path.join(root, 'src', 'sandbox-review', 'dist');
vi.resetModules();
vi.doMock('../utils/bundlePaths.js', () => ({
resolveBundleDir: () => bundleRoot,
}));
try {
const bundled = await import('./landlock-execution.js');
expect(bundled.landlockRunnerPath('linux', 'arm64')).toBe(
path.join(
bundleRoot,
'vendor',
'landlock-run',
'arm64-linux',
'qwen-landlock-run',
),
);
} finally {
vi.doUnmock('../utils/bundlePaths.js');
vi.resetModules();
}
});
it.skipIf(process.platform === 'win32')(
'restores missing executable bits only on the bundled helper',
async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux');
vi.spyOn(fs, 'realpathSync').mockReturnValue(runner);
chmodSync(runner, 0o644);
const launch = vi
.spyOn(ShellExecutionService, 'executeLaunch')
.mockRejectedValue(new Error('probe reached'));
await expect(
probeLandlock(policy, new AbortController().signal),
).rejects.toThrow('probe reached');
expect(statSync(runner).mode & 0o777).toBe(0o644);
await expect(
probeLandlock(
{ ...policy, landlockPath: undefined },
new AbortController().signal,
),
).rejects.toThrow('probe reached');
expect(statSync(runner).mode & 0o777).toBe(0o755);
expect(launch).toHaveBeenCalledTimes(2);
},
);
it.skipIf(process.platform === 'win32')(
'fails before probing when the bundled helper cannot be made executable',
async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux');
vi.spyOn(fs, 'realpathSync').mockReturnValue(runner);
chmodSync(runner, 0o644);
vi.spyOn(fs, 'chmodSync').mockImplementation(() => {
throw new Error('EROFS');
});
const launch = vi.spyOn(ShellExecutionService, 'executeLaunch');
await expect(
probeLandlock(
{ ...policy, landlockPath: undefined },
new AbortController().signal,
),
).rejects.toThrow('EROFS');
expect(statSync(runner).mode & 0o777).toBe(0o644);
expect(launch).not.toHaveBeenCalled();
expect(executeSandboxRelay).not.toHaveBeenCalled();
},
);
it('accepts only the helper capability report and requires open networking', async () => {
executeSandboxRelay.mockResolvedValue({
result: Promise.resolve({
output: '',
exitCode: 0,
error: null,
aborted: false,
sandboxStatus: { state: 'confirmed', exitCode: 0 },
}),
});
vi.spyOn(ShellExecutionService, 'executeLaunch').mockResolvedValue({
pid: 1,
result: Promise.resolve({
output: '{"abi":6,"enforcement":"partial"}\n',
rawOutput: Buffer.alloc(0),
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 1,
executionMethod: 'child_process',
}),
});
await expect(
probeLandlock(policy, new AbortController().signal),
).resolves.toEqual({ abi: 6, enforcement: 'partial' });
expect(executeSandboxRelay).toHaveBeenCalledOnce();
await expect(
probeLandlock(
{ ...policy, network: 'closed' },
new AbortController().signal,
),
).rejects.toThrow('cannot enforce network: closed');
executeSandboxRelay.mockResolvedValueOnce({
result: Promise.resolve({
output: '',
exitCode: 1,
error: null,
aborted: false,
sandboxStatus: { state: 'unconfirmed' },
}),
});
await expect(
probeLandlock(policy, new AbortController().signal),
).rejects.toThrow('Landlock policy probe failed');
});
it.each([['/workspace/secret'], [''], [' ']])(
'rejects a nonempty mask list before capability probing or launching: %j',
async (mask) => {
const launch = vi.spyOn(ShellExecutionService, 'executeLaunch');
const masked = { ...policy, maskedPaths: [mask] };
await expect(
probeLandlock(masked, new AbortController().signal),
).rejects.toThrow('cannot enforce masked paths');
await expect(
executeLandlock(
masked,
{
executable: '/bin/true',
args: [],
cwd: policy.workspace,
env: {},
},
() => {},
new AbortController().signal,
),
).rejects.toThrow('cannot enforce masked paths');
expect(launch).not.toHaveBeenCalled();
expect(executeSandboxRelay).not.toHaveBeenCalled();
},
);
it('launches the helper through the relay with read and write grants', async () => {
let relayLaunch!: ProcessLaunch;
executeSandboxRelay.mockImplementation(
async (_policy, _payload, _roots, createLaunch) => {
relayLaunch = createLaunch({
workspace: policy.workspace,
cwd: policy.workspace,
executable: '/bin/sh',
args: ['-c', 'printf ok'],
filesystem: 'workspace-write',
network: 'open',
scratch: '/tmp/qwen-landlock-test',
statusPath: '/state/status.json',
payloadEnvPath: '/state/payload-env.json',
env: {
PATH: '/payload/bin',
NODE_OPTIONS: '--require /payload/hook.js',
},
stdin: undefined,
inheritStdin: true,
});
return {} as never;
},
);
await executeLandlock(
policy,
{
executable: '/bin/sh',
args: ['-c', 'printf ok'],
cwd: policy.workspace,
env: { PATH: '/usr/bin:/bin' },
},
() => {},
new AbortController().signal,
);
expect(relayLaunch.env).toEqual({
PATH: '/usr/bin:/bin',
LANG: 'C.UTF-8',
PWD: policy.workspace,
TERM: 'xterm-256color',
});
expect(relayLaunch.inheritStdin).toBe(true);
expect(relayLaunch.args).toEqual([
'/installed/sandboxLandlockRelay.js',
String(process.pid),
'/state/status.json',
'/state/payload-env.json',
realpathSync(runner),
'--status-fd',
'3',
'--ro',
'/',
'--rw',
'/dev/null',
'--rw',
'/tmp/qwen-landlock-test',
'--rw',
policy.workspace,
'--',
'/bin/sh',
'-c',
'printf ok',
]);
});
});
@@ -0,0 +1,205 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { chmodSync, existsSync, realpathSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type {
ProcessLaunch,
ShellExecutionConfig,
ShellExecuteOptions,
ShellOutputEvent,
} from '../services/shellExecutionService.js';
import { ShellExecutionService } from '../services/shellExecutionService.js';
import { resolveBundleDir } from '../utils/bundlePaths.js';
import {
executeSandboxRelay,
sandboxAsset,
type ExecutionSandboxPolicy,
type SandboxExecutionHandle,
} from './sandbox-execution.js';
const moduleFile = fileURLToPath(import.meta.url);
const moduleDirectory = resolveBundleDir(import.meta.url);
export function landlockRunnerPath(
platform = process.platform,
arch = process.arch,
): string {
if (platform !== 'linux' || !['x64', 'arm64'].includes(arch)) {
throw new Error(`Landlock does not support ${platform}/${arch}.`);
}
const inSourceSandbox =
['landlock-execution.ts', 'landlock-execution.js'].includes(
path.basename(moduleFile),
) && moduleDirectory.endsWith(`${path.sep}src${path.sep}sandbox`);
const levelsUp = !inSourceSandbox ? 0 : moduleFile.endsWith('.ts') ? 2 : 3;
return path.join(
moduleDirectory,
...Array<string>(levelsUp).fill('..'),
'vendor',
'landlock-run',
`${arch}-linux`,
'qwen-landlock-run',
);
}
function resolveRunner(policy: ExecutionSandboxPolicy): string {
const requested = policy.landlockPath ?? landlockRunnerPath();
if (!path.isAbsolute(requested))
throw new Error('Landlock helper path must be absolute.');
if (!existsSync(requested))
throw new Error(`Landlock helper is missing: ${requested}`);
const runner = realpathSync(requested);
// npm archives strip executable bits from files outside the bin entries.
if (
policy.landlockPath === undefined &&
(statSync(runner).mode & 0o111) === 0
)
chmodSync(runner, 0o755);
return runner;
}
export async function probeLandlock(
policy: ExecutionSandboxPolicy,
signal: AbortSignal,
): Promise<{ abi: number; enforcement: 'partial' }> {
if (policy.network !== 'open') {
throw new Error('Landlock cannot enforce network: closed.');
}
if (policy.maskedPaths?.length)
throw new Error('Landlock cannot enforce masked paths.');
const runner = resolveRunner(policy);
const handle = await ShellExecutionService.executeLaunch(
{
executable: runner,
args: ['--probe'],
cwd: policy.workspace,
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' },
},
() => {},
signal,
false,
{},
);
const result = await handle.result;
if (result.error || result.aborted || result.exitCode !== 0) {
throw new Error(
`Landlock capability probe failed: ${result.error?.message ?? (result.output || `exit ${result.exitCode}`)}`,
);
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(result.output.trim()) as Record<string, unknown>;
} catch {
throw new Error('Landlock capability probe returned an invalid report.');
}
if (
typeof parsed['abi'] !== 'number' ||
!Number.isInteger(parsed['abi']) ||
parsed['abi'] < 3 ||
parsed['enforcement'] !== 'partial'
) {
throw new Error('Landlock capability probe returned an invalid report.');
}
const policyProbe = await executeLandlock(
policy,
{
executable: '/usr/bin/true',
args: [],
cwd: policy.workspace,
env: { PATH: '/usr/bin:/bin' },
},
() => {},
signal,
);
const policyResult = await policyProbe.result;
if (
policyResult.sandboxStatus.state !== 'confirmed' ||
policyResult.sandboxStatus.exitCode !== 0 ||
policyResult.error ||
policyResult.aborted
) {
throw new Error(
`Landlock policy probe failed: ${policyResult.error?.message || policyResult.output || policyResult.sandboxStatus.state}`,
);
}
return { abi: parsed['abi'], enforcement: 'partial' };
}
export async function executeLandlock(
policy: ExecutionSandboxPolicy,
payload: ProcessLaunch,
onOutput: (event: ShellOutputEvent) => void,
signal: AbortSignal,
usePty = false,
config: ShellExecutionConfig = {},
options: ShellExecuteOptions = {},
): Promise<SandboxExecutionHandle> {
if (policy.network !== 'open')
throw new Error('Landlock cannot enforce network: closed.');
if (policy.maskedPaths?.length)
throw new Error('Landlock cannot enforce masked paths.');
const runner = resolveRunner(policy);
const relay = sandboxAsset('landlock-relay');
const node = realpathSync(process.execPath);
return executeSandboxRelay(
policy,
payload,
[path.dirname(runner), path.dirname(relay), path.dirname(node)],
({
workspace,
cwd,
executable,
args,
filesystem,
scratch,
statusPath,
payloadEnvPath,
env,
stdin,
inheritStdin,
}) => {
const runnerArgs = [
'--status-fd',
'3',
'--ro',
'/',
'--rw',
'/dev/null',
'--rw',
scratch,
];
if (filesystem === 'workspace-write') runnerArgs.push('--rw', workspace);
runnerArgs.push('--', executable, ...args);
return {
executable: node,
args: [
relay,
String(process.pid),
statusPath,
payloadEnvPath,
runner,
...runnerArgs,
],
cwd,
env: {
PATH: '/usr/bin:/bin',
LANG: 'C.UTF-8',
PWD: cwd,
TERM: env['TERM'] || 'xterm-256color',
},
stdin,
inheritStdin,
};
},
onOutput,
signal,
usePty,
config,
options,
);
}
+121
View File
@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import {
closeSync,
constants,
createReadStream,
fstatSync,
openSync,
readFileSync,
readlinkSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import type { Readable } from 'node:stream';
import { parseLandlockStatus } from './landlock-status.js';
import { MAX_STATUS_BYTES } from './sandbox-status.js';
const [parentPid, statusPath, payloadEnvPath, runner, ...args] =
process.argv.slice(2);
if (process.ppid !== Number(parentPid)) process.exit(1);
const parentWatch = setInterval(() => {
if (process.ppid !== Number(parentPid)) process.exit(1);
}, 100);
parentWatch.unref();
const fd = openSync(
statusPath,
constants.O_WRONLY |
constants.O_CREAT |
constants.O_EXCL |
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 input = fstatSync(0);
let shareInput = input.isSocket() || input.isCharacterDevice();
if (input.isFIFO()) {
try {
shareInput = readlinkSync('/proc/self/fd/0').startsWith('pipe:');
} catch {
shareInput = false;
}
}
// Initialize Node's shared output descriptors before the helper restores blocking
// mode; lazy initialization after spawn would race and re-enable O_NONBLOCK.
void process.stdout;
void process.stderr;
const child = spawn(runner, args, {
stdio: [shareInput ? 'inherit' : 'pipe', 'inherit', 'inherit', 'pipe'],
env,
});
if (!shareInput && child.stdin) {
// Host-backed descriptors can bypass filesystem policy through fd operations.
// Copy their bytes through a relay-owned pipe instead of sharing the fd.
const sink = child.stdin;
const source = createReadStream('', { fd: 0, autoClose: false });
source.on('error', () => sink.end());
sink.on('error', () => source.destroy());
child.on('close', () => source.destroy());
source.pipe(sink);
}
let wire = '';
let bytes = 0;
let failed = false;
// Only a Landlock helper spawn error proves the payload never ran. A status-stream
// transport error or a wire overflow can happen after the payload has
// executed, so those must stay unattested (PR #12067 review, round 2).
let spawnFailed = false;
const statusStream = child.stdio[3] as Readable;
statusStream.on('data', (chunk: Buffer) => {
bytes += chunk.length;
if (bytes <= MAX_STATUS_BYTES) wire += chunk.toString('utf8');
else failed = true;
});
statusStream.on('error', () => {
failed = true;
});
child.on('error', () => {
failed = true;
spawnFailed = true;
});
child.on('close', (code, signal) => {
clearInterval(parentWatch);
// A spawn failure of Landlock helper itself means the payload provably never ran —
// attest that explicitly so the finalizer can clean up instead of
// retaining the dirs for inspection. Every other failure mode leaves the
// field absent: absence of evidence is not evidence of absence.
const status = signal
? { state: 'interrupted' }
: failed
? spawnFailed
? { state: 'unconfirmed', payloadExitObserved: false }
: { state: 'unconfirmed' }
: parseLandlockStatus(wire, code);
writeFileSync(fd, JSON.stringify(status));
closeSync(fd);
if (signal) process.kill(process.pid, signal);
else process.exitCode = code ?? 1;
});
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { parseLandlockStatus } from './landlock-status.js';
describe('Landlock execution receipts', () => {
it.each([0, 1, 42, 125, 255])(
'confirms a prepared helper after the payload exits %s',
(code) => {
expect(
parseLandlockStatus('{"state":"prepared","abi":3}\n', code),
).toEqual({ state: 'confirmed', exitCode: code });
},
);
it('positively identifies empty and explicit pre-exec failures', () => {
expect(parseLandlockStatus('', 125)).toEqual({
state: 'unconfirmed',
payloadExitObserved: false,
});
expect(
parseLandlockStatus(
'{"state":"prepared","abi":6}\n{"state":"exec-failed","abi":6}\n',
125,
),
).toEqual({ state: 'unconfirmed', payloadExitObserved: false });
});
it.each([
['{"state":"prepared","abi":3}', 0],
['{"state":"prepared","abi":2}\n', 0],
['{"state":"prepared","abi":3}\n{}\n', 0],
['x'.repeat(16 * 1024 + 1), 0],
])('keeps malformed or partial evidence unknown', (wire, code) => {
expect(parseLandlockStatus(wire, code)).toEqual({ state: 'unconfirmed' });
});
});
@@ -0,0 +1,51 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { MAX_STATUS_BYTES, type SandboxStatus } from './sandbox-status.js';
export function parseLandlockStatus(
wire: string,
exitCode: number | null,
): SandboxStatus {
if (
Buffer.byteLength(wire) > MAX_STATUS_BYTES ||
(wire !== '' && !wire.endsWith('\n'))
) {
return { state: 'unconfirmed' };
}
if (wire === '') return { state: 'unconfirmed', payloadExitObserved: false };
try {
const records = wire
.trim()
.split('\n')
.map((line) => JSON.parse(line) as Record<string, unknown>);
const prepared = records[0];
if (
prepared?.['state'] !== 'prepared' ||
typeof prepared['abi'] !== 'number' ||
!Number.isInteger(prepared['abi']) ||
prepared['abi'] < 3
) {
return { state: 'unconfirmed' };
}
if (records[1]?.['state'] === 'exec-failed' && records.length === 2) {
return { state: 'unconfirmed', payloadExitObserved: false };
}
if (
records.length === 1 &&
typeof exitCode === 'number' &&
Number.isInteger(exitCode) &&
exitCode >= 0 &&
exitCode <= 255
) {
return { state: 'confirmed', exitCode };
}
} catch {
/* Malformed launcher evidence cannot establish whether exec happened. */
}
return { state: 'unconfirmed' };
}
@@ -22,7 +22,9 @@ import {
} from './runtime-shell-policy.js';
const executeBwrap = vi.hoisted(() => vi.fn());
const probeLandlock = vi.hoisted(() => vi.fn());
vi.mock('./bwrap-execution.js', () => ({ executeBwrap }));
vi.mock('./landlock-execution.js', () => ({ probeLandlock }));
describe('runtime shell policy admission', () => {
let root: string;
@@ -273,7 +275,12 @@ describe('runtime shell policy admission', () => {
sandboxStatus: { state: 'confirmed', exitCode: 0 },
}),
});
await probeShellSandbox(admit(params, root)!);
const resolved = await probeShellSandbox(admit(params, root)!);
expect(resolved).toMatchObject({
effectiveBackend: 'bwrap',
enforcement: 'full',
});
expect(Object.isFrozen(resolved)).toBe(true);
expect(executeBwrap).toHaveBeenCalledWith(
expect.anything(),
{
@@ -294,6 +301,100 @@ describe('runtime shell policy admission', () => {
expect(executeBwrap).toHaveBeenCalledTimes(2);
});
it('falls back from bwrap to Landlock only for an open-network policy', async () => {
vi.stubGlobal(
'process',
Object.create(process, { platform: { value: 'linux' } }),
);
params.shellExecutionSandbox = {
...params.shellExecutionSandbox!,
network: 'open',
};
executeBwrap.mockRejectedValue(new Error('user namespaces unavailable'));
probeLandlock.mockResolvedValue({ abi: 6, enforcement: 'partial' });
const resolved = await probeShellSandbox(admit(params, root)!);
expect(resolved).toMatchObject({
effectiveBackend: 'landlock',
enforcement: 'partial',
landlockAbi: 6,
});
expect(resolved).not.toHaveProperty('requestedBackend');
expect(probeLandlock).toHaveBeenCalledOnce();
});
it('does not weaken an explicit backend or closed-network policy', async () => {
vi.stubGlobal(
'process',
Object.create(process, { platform: { value: 'linux' } }),
);
executeBwrap.mockRejectedValue(new Error('bwrap unavailable'));
params.shellExecutionSandbox = {
...params.shellExecutionSandbox!,
requestedBackend: 'bwrap',
network: 'open',
};
await expect(probeShellSandbox(admit(params, root)!)).rejects.toThrow(
'bwrap unavailable',
);
expect(probeLandlock).not.toHaveBeenCalled();
params.shellExecutionSandbox = {
...params.shellExecutionSandbox!,
requestedBackend: 'landlock',
network: 'closed',
};
await expect(probeShellSandbox(admit(params, root)!)).rejects.toThrow(
'cannot enforce network: closed',
);
expect(probeLandlock).not.toHaveBeenCalled();
});
it('uses an explicit Landlock backend without probing bwrap', async () => {
vi.stubGlobal(
'process',
Object.create(process, { platform: { value: 'linux' } }),
);
params.shellExecutionSandbox = {
...params.shellExecutionSandbox!,
requestedBackend: 'landlock',
network: 'open',
};
probeLandlock.mockResolvedValue({ abi: 3, enforcement: 'partial' });
await expect(
probeShellSandbox(admit(params, root)!),
).resolves.toMatchObject({
effectiveBackend: 'landlock',
enforcement: 'partial',
landlockAbi: 3,
});
expect(executeBwrap).not.toHaveBeenCalled();
});
it('propagates caller cancellation without trying another backend', async () => {
vi.stubGlobal(
'process',
Object.create(process, { platform: { value: 'linux' } }),
);
params.shellExecutionSandbox = {
...params.shellExecutionSandbox!,
network: 'open',
};
const controller = new AbortController();
executeBwrap.mockImplementation(async () => {
controller.abort(new Error('caller stopped'));
throw new Error('bwrap stopped');
});
await expect(
probeShellSandbox(admit(params, root)!, controller.signal),
).rejects.toThrow('caller stopped');
expect(probeLandlock).not.toHaveBeenCalled();
});
it('aborts a hung capability probe after ten seconds', async () => {
vi.stubGlobal(
'process',
@@ -12,6 +12,7 @@ import type {
ShellExecutionSandboxPolicy,
} from '../config/config.js';
import { isSubpath, realpathNearestExisting } from '../utils/paths.js';
import type { ResolvedExecutionSandboxPolicy } from './sandbox-execution.js';
export function assertShellSandboxCwd(
policy: Readonly<ShellExecutionSandboxPolicy>,
@@ -78,13 +79,16 @@ export function admitShellSandbox(
!['read-only', 'workspace-write'].includes(policy.filesystem) ||
!['open', 'closed'].includes(policy.network) ||
(policy.requestedBackend !== undefined &&
!['auto', 'bwrap'].includes(policy.requestedBackend))
!['auto', 'bwrap', 'landlock'].includes(policy.requestedBackend))
) {
throw new Error('Unsupported shell sandbox policy.');
}
if (
policy.bwrapPath !== undefined &&
(typeof policy.bwrapPath !== 'string' || !path.isAbsolute(policy.bwrapPath))
[policy.bwrapPath, policy.landlockPath].some(
(value) =>
value !== undefined &&
(typeof value !== 'string' || !path.isAbsolute(value)),
)
) {
throw new Error('Invalid tool execution sandbox paths.');
}
@@ -128,6 +132,7 @@ export function admitShellSandbox(
? { requestedBackend: policy.requestedBackend }
: {}),
...(policy.bwrapPath ? { bwrapPath: policy.bwrapPath } : {}),
...(policy.landlockPath ? { landlockPath: policy.landlockPath } : {}),
});
assertShellSandboxCwd(admitted, params.targetDir);
assertShellSandboxCwd(admitted, params.cwd ?? process.cwd());
@@ -137,36 +142,81 @@ export function admitShellSandbox(
export async function probeShellSandbox(
policy: Readonly<ShellExecutionSandboxPolicy>,
signal: AbortSignal = new AbortController().signal,
): Promise<void> {
): Promise<Readonly<ResolvedExecutionSandboxPolicy>> {
signal.throwIfAborted();
if (process.platform !== 'linux')
throw new Error('bwrap execution requires Linux.');
throw new Error('Tool execution sandbox 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)}`,
);
const requested = policy.requestedBackend ?? 'auto';
const failures: string[] = [];
if (requested === 'auto' || requested === 'bwrap') {
try {
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(
result.error?.message || result.output || result.sandboxStatus.state,
);
}
return Object.freeze({
...policy,
effectiveBackend: 'bwrap',
enforcement: 'full',
});
} catch (error) {
signal.throwIfAborted();
failures.push(
`bwrap: ${error instanceof Error ? error.message : String(error)}`,
);
if (requested === 'bwrap') {
throw new Error(`Sandbox capability probe failed: ${failures[0]}`);
}
}
}
if (requested === 'auto' || requested === 'landlock') {
if (policy.network === 'closed') {
failures.push('landlock: cannot enforce network: closed');
} else {
try {
const { probeLandlock } = await import('./landlock-execution.js');
const result = await probeLandlock(
policy,
AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
);
return Object.freeze({
...policy,
effectiveBackend: 'landlock',
enforcement: result.enforcement,
landlockAbi: result.abi,
});
} catch (error) {
signal.throwIfAborted();
failures.push(
`landlock: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
throw new Error(`Sandbox capability probe failed: ${failures.join('; ')}`);
}
@@ -7,12 +7,12 @@
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 { executeSandbox } from './execute-sandbox.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('./execute-sandbox.js', () => ({ executeSandbox: vi.fn() }));
vi.mock('./runtime-shell-policy.js', () => ({
assertShellSandboxCwd: vi.fn(),
}));
@@ -27,6 +27,8 @@ describe('runtime shell dispatch', () => {
state: '/state',
filesystem: 'workspace-write' as const,
network: 'closed' as const,
effectiveBackend: 'bwrap' as const,
enforcement: 'full' as const,
};
const runtime = (enabled = true, sessionId = 'runtime-session') =>
({
@@ -67,7 +69,7 @@ describe('runtime shell dispatch', () => {
{},
options,
);
expect(executeBwrap).not.toHaveBeenCalled();
expect(executeSandbox).not.toHaveBeenCalled();
});
it.each([false, true])(
@@ -92,7 +94,7 @@ describe('runtime shell dispatch', () => {
policy,
'/workspace/sub',
);
const args = vi.mocked(executeBwrap).mock.calls[0];
const args = vi.mocked(executeSandbox).mock.calls[0];
expect(args[0]).toBe(policy);
expect(args[1]).toMatchObject({
executable: '/bin/bash',
@@ -133,8 +135,8 @@ describe('runtime shell dispatch', () => {
false,
),
).rejects.toThrow('outside');
expect(executeBwrap).not.toHaveBeenCalled();
vi.mocked(executeBwrap).mockRejectedValueOnce(new Error('setup failed'));
expect(executeSandbox).not.toHaveBeenCalled();
vi.mocked(executeSandbox).mockRejectedValueOnce(new Error('setup failed'));
await expect(
executeRuntimeShell(
runtime(),
+2 -2
View File
@@ -16,7 +16,7 @@ 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 { executeSandbox } from './execute-sandbox.js';
import { assertShellSandboxCwd } from './runtime-shell-policy.js';
export function executeRuntimeShell(
@@ -60,7 +60,7 @@ export function executeRuntimeShell(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
);
return executeBwrap(
return executeSandbox(
policy,
{ executable: '/bin/bash', args: ['-c', command], cwd, env },
onOutput,
@@ -0,0 +1,411 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { existsSync, 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 {
ShellExecutionService,
isSignalTermination,
} from '../services/shellExecutionService.js';
import type {
ProcessLaunch,
ShellExecutionConfig,
ShellExecuteOptions,
ShellExecutionResult,
ShellOutputEvent,
ShellPostPromoteSettleInfo,
} from '../services/shellExecutionService.js';
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 { sandboxStatusError, type SandboxStatus } from './sandbox-status.js';
const debugLogger = createDebugLogger('SANDBOX_EXECUTION');
export type ExecutionSandboxBackend = 'bwrap' | 'landlock';
export type ExecutionSandboxEnforcement = 'full' | 'partial';
export interface ExecutionSandboxPolicy {
workspace: string;
installation: string;
state: string;
maskedPaths?: readonly string[];
filesystem: 'read-only' | 'workspace-write';
network: 'open' | 'closed';
requestedBackend?: 'auto' | ExecutionSandboxBackend;
effectiveBackend?: ExecutionSandboxBackend;
enforcement?: ExecutionSandboxEnforcement;
landlockAbi?: number;
bwrapPath?: string;
landlockPath?: string;
}
export interface ResolvedExecutionSandboxPolicy extends ExecutionSandboxPolicy {
effectiveBackend: ExecutionSandboxBackend;
enforcement: ExecutionSandboxEnforcement;
}
export interface SandboxExecutionResult extends ShellExecutionResult {
sandboxStatus: SandboxStatus;
}
export interface SandboxExecutionHandle {
pid: number | undefined;
result: Promise<SandboxExecutionResult>;
settled: Promise<SandboxStatus>;
}
export function sandboxAsset(
name: 'bwrap-relay' | 'landlock-relay' | 'file-worker',
): string {
const sibling = path.join(
path.dirname(fileURLToPath(import.meta.url)),
`${name}.js`,
);
const bundledNames = {
'bwrap-relay': 'sandboxBwrapRelay.js',
'landlock-relay': 'sandboxLandlockRelay.js',
'file-worker': 'sandboxFileWorker.js',
} as const;
const bundled = path.join(
resolveBundleDir(import.meta.url),
bundledNames[name],
);
const asset = existsSync(sibling) ? sibling : bundled;
if (!existsSync(asset)) {
throw new Error(
'Sandbox assets are missing. Run the build and bundle first.',
);
}
return realpathSync(asset);
}
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.');
const resolved = realpathSync(value);
if (!statSync(resolved).isDirectory())
throw new Error('Expected sandbox directory.');
return resolved;
};
interface SandboxLaunchContext {
workspace: string;
cwd: string;
executable: string;
args: string[];
filesystem: ExecutionSandboxPolicy['filesystem'];
network: ExecutionSandboxPolicy['network'];
scratch: string;
statusPath: string;
payloadEnvPath: string;
maskedPaths: readonly string[];
env: Record<string, string>;
stdin: string | Buffer | undefined;
inheritStdin: boolean | undefined;
}
export async function executeSandboxRelay(
policy: ExecutionSandboxPolicy,
payload: ProcessLaunch,
runnerProtectedRoots: string[],
createLaunch: (context: SandboxLaunchContext) => ProcessLaunch,
onOutput: (event: ShellOutputEvent) => void,
signal: AbortSignal,
usePty = false,
config: ShellExecutionConfig = {},
options: ShellExecuteOptions = {},
): Promise<SandboxExecutionHandle> {
if (process.platform !== 'linux')
throw new Error('Tool execution sandbox requires Linux.');
if (
!['read-only', 'workspace-write'].includes(policy.filesystem) ||
!['open', 'closed'].includes(policy.network)
) {
throw new Error('Unsupported sandbox policy.');
}
if (!path.isAbsolute(payload.executable) || !path.isAbsolute(payload.cwd))
throw new Error('Payload paths must be absolute.');
if (payload.stdin !== undefined && payload.inheritStdin)
throw new Error('Process stdin cannot be both piped and inherited.');
if (usePty && (payload.stdin !== undefined || payload.inheritStdin))
throw new Error('Process stdin requires pipe execution.');
const workspace = directory(policy.workspace);
const state = directory(policy.state);
const protectedRoots = [
directory(policy.installation),
state,
...runnerProtectedRoots,
...[
'/proc',
'/dev',
'/sys',
'/etc',
'/usr',
'/bin',
'/sbin',
'/lib',
'/lib64',
]
.filter(existsSync)
.map((value) => realpathSync(value)),
];
const checkWritable = (root: string) => {
if (
isSubpath(root, realpathSync(os.homedir())) ||
protectedRoots.some((protectedRoot) => overlaps(root, protectedRoot))
) {
throw new Error('Writable directory overlaps a protected root.');
}
};
// 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 (!isSubpath(workspace, cwd))
throw new Error('Payload cwd must be inside the workspace.');
const executable = payload.executable;
const args = [...payload.args];
const env = Object.fromEntries(
Object.entries(payload.env).filter(([key]) => !isInternalSecretEnvVar(key)),
);
const stdin = Buffer.isBuffer(payload.stdin)
? Buffer.from(payload.stdin)
: payload.stdin;
const inheritStdin = payload.inheritStdin;
const filesystem = policy.filesystem;
const network = policy.network;
const control = await mkdtemp(path.join(state, 'sandbox-control-'));
let scratch: string | undefined;
const cleanup = async () => {
const results = await Promise.allSettled([
rm(control, { recursive: true, force: true }),
...(scratch ? [rm(scratch, { recursive: true, force: true })] : []),
]);
for (const result of results) {
if (result.status === 'rejected')
debugLogger.warn(
'Sandbox temporary directory cleanup failed',
result.reason,
);
}
};
try {
const requestedScratchRoot = os.tmpdir();
const scratchRoot =
path.isAbsolute(requestedScratchRoot) && existsSync(requestedScratchRoot)
? realpathSync(requestedScratchRoot)
: realpathSync('/tmp');
if (
isSubpath(workspace, scratchRoot) ||
protectedRoots.some((protectedRoot) =>
isSubpath(protectedRoot, scratchRoot),
)
)
throw new Error(
`Temporary root ${scratchRoot} overlaps the workspace or a protected root.`,
);
scratch = await mkdtemp(path.join(scratchRoot, 'qwen-sandbox-'));
scratch = directory(scratch);
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 statusPath = path.join(control, 'status.json');
let complete!: (status: SandboxStatus) => void;
const settled = new Promise<SandboxStatus>((resolve) => {
complete = resolve;
});
let finalizing: Promise<SandboxStatus> | undefined;
const finalize = (info: {
signal: number | NodeJS.Signals | null;
aborted?: boolean;
exitCode: number | null;
error?: unknown;
}) =>
(finalizing ??= (async () => {
let status: SandboxStatus = { state: 'unconfirmed' };
// The relay creates the receipt file (O_EXCL) before spawning the backend,
// so its existence attests the relay got as far as the spawn call.
let receiptExisted = false;
let receiptParsed = false;
if (info.aborted || isSignalTermination(info.signal))
status = { state: 'interrupted' };
else {
try {
const text = await readFile(statusPath, 'utf8');
// The file existing at all attests the relay got past its
// O_EXCL create — i.e. as far as the backend spawn call.
receiptExisted = true;
const record = JSON.parse(text) as Record<string, unknown>;
receiptParsed = true;
if (
!info.error &&
record['state'] === 'confirmed' &&
record['exitCode'] === info.exitCode &&
typeof record['exitCode'] === 'number' &&
Number.isInteger(record['exitCode']) &&
record['exitCode'] >= 0 &&
record['exitCode'] <= 255
)
status = { state: 'confirmed', exitCode: record['exitCode'] };
else if (record['state'] === 'interrupted')
status = { state: 'interrupted' };
else {
// Preserve the attestation exactly as written: an absent or
// non-boolean field means "unknown", never "did not run"
// (PR #12067 review, round 2).
const attested = record['payloadExitObserved'];
status = {
state: 'unconfirmed',
...(typeof attested === 'boolean'
? { payloadExitObserved: attested }
: {}),
};
}
} catch {
/* Missing/partial receipt never proves that the payload did not run. */
}
}
// Retain the dirs unless the payload provably did not run. Two
// positive proofs allow cleanup: the receipt attests no payload
// exit record (spawn failure, or a wire showing the payload never
// got past exec — missing backend or payload binary), or the receipt
// file is absent, which means the relay died before its O_EXCL
// create and therefore before spawning the backend. Everything else —
// an attested exit record, an unreadable receipt, or an
// unattested unconfirmed — is unknown and retains (PR #12067
// review: the coarse key leaked a dir pair per pre-exec failure,
// while collapsing "unknown" into "did not run" inverts the
// fail-safe for a retry-deciding caller).
const attestedNoExec =
receiptExisted &&
receiptParsed &&
status.state === 'unconfirmed' &&
status.payloadExitObserved === false;
const relayDiedBeforeSpawn = !receiptExisted;
const retain =
status.state === 'unconfirmed' &&
!attestedNoExec &&
!relayDiedBeforeSpawn;
if (retain) {
debugLogger.warn(
'Sandbox termination is unconfirmed; retaining temporary directories',
{ control, scratch },
);
} else {
await cleanup();
}
complete(status);
return status;
})());
const launch = createLaunch({
workspace,
cwd,
executable,
args,
filesystem,
network,
scratch,
statusPath,
payloadEnvPath,
maskedPaths,
env,
stdin,
inheritStdin,
});
const handle = await ShellExecutionService.executeLaunch(
launch,
onOutput,
signal,
usePty,
config,
{
...options,
postPromote: {
onData: options.postPromote?.onData,
onSettle: (info: ShellPostPromoteSettleInfo) => {
void finalize(info)
.then((status) => {
// The specific transport/spawn error outranks the generic
// status-derived one: an unconfirmed run caused by a relay
// spawn failure should surface the spawn error, not the
// catch-all "could not be confirmed" (PR #12067 review).
const error = info.error ?? sandboxStatusError(status);
options.postPromote?.onSettle?.({ ...info, error });
})
.catch((settleError: unknown) => {
// The caller's postPromote.onSettle throws here, one await
// past the service's own try/catch guard — log instead of
// discarding (PR #12067 review).
debugLogger.warn(
`post-promote settle chain failed: ${settleError instanceof Error ? settleError.message : String(settleError)}`,
);
});
},
},
},
);
return {
pid: handle.pid,
settled,
result: handle.result.then(
async (result) => {
const sandboxStatus: SandboxStatus = result.promoted
? { state: 'running' }
: await finalize(result);
return {
...result,
error: result.error ?? sandboxStatusError(sandboxStatus) ?? null,
sandboxStatus,
};
},
async (error: unknown) => {
await finalize({ signal: null, exitCode: null, error });
throw error;
},
),
};
} catch (error) {
await cleanup();
throw error;
}
}
@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export type SandboxStatus =
| { state: 'confirmed'; exitCode: number }
| {
state: 'unconfirmed';
/**
* True when backend evidence proves the payload exited. False proves
* setup failed before exec. Absence remains unknown and must not be
* interpreted as proof that the payload did not run.
*/
payloadExitObserved?: boolean;
}
| { state: 'interrupted' | 'running' };
export const MAX_STATUS_BYTES = 16 * 1024;
export function sandboxStatusError(status: SandboxStatus): Error | undefined {
if (status.state === 'unconfirmed') {
if (status.payloadExitObserved === false) {
return new Error(
'Sandbox payload did not run: setup failed before execution (the sandbox runner or payload binary may be missing).',
);
}
return new Error(
'Sandbox execution status could not be confirmed. The command may have run; do not automatically retry it.',
);
}
if (status.state === 'interrupted') {
return new Error('Sandbox execution was interrupted.');
}
return undefined;
}
+13 -11
View File
@@ -18,9 +18,9 @@ import {
} from 'vitest';
const mockShellExecutionService = vi.hoisted(() => vi.fn());
const mockExecuteBwrap = vi.hoisted(() => vi.fn());
vi.mock('../sandbox/bwrap-execution.js', () => ({
executeBwrap: mockExecuteBwrap,
const mockExecuteSandbox = vi.hoisted(() => vi.fn());
vi.mock('../sandbox/execute-sandbox.js', () => ({
executeSandbox: mockExecuteSandbox,
}));
vi.mock('../sandbox/runtime-shell-policy.js', () => ({
assertShellSandboxCwd: vi.fn(),
@@ -322,7 +322,7 @@ describe('ShellTool', () => {
filesystem: 'workspace-write',
network: 'closed',
});
mockExecuteBwrap.mockResolvedValue({
mockExecuteSandbox.mockResolvedValue({
pid: 12345,
result: Promise.resolve({
rawOutput: Buffer.alloc(0),
@@ -348,7 +348,7 @@ describe('ShellTool', () => {
.type,
).toBe('exec');
await invocation.execute(new AbortController().signal);
expect(mockExecuteBwrap).toHaveBeenCalledOnce();
expect(mockExecuteSandbox).toHaveBeenCalledOnce();
expect(mockFileSystemService.readTextFile).not.toHaveBeenCalled();
expect(mockFileSystemService.writeTextFile).not.toHaveBeenCalled();
expect(mockShellExecutionService).not.toHaveBeenCalled();
@@ -375,16 +375,16 @@ describe('ShellTool', () => {
is_background: true,
})
.execute(new AbortController().signal);
expect(mockExecuteBwrap).toHaveBeenCalledTimes(3);
expect(mockExecuteBwrap.mock.calls[0][1].args).toEqual([
expect(mockExecuteSandbox).toHaveBeenCalledTimes(3);
expect(mockExecuteSandbox.mock.calls[0][1].args).toEqual([
'-c',
'git commit -m test',
]);
expect(mockExecuteBwrap.mock.calls[1][1].args).toEqual([
expect(mockExecuteSandbox.mock.calls[1][1].args).toEqual([
'-c',
'gh pr create --title test --body test',
]);
expect(mockExecuteBwrap.mock.calls[2][1].args).toEqual([
expect(mockExecuteSandbox.mock.calls[2][1].args).toEqual([
'-c',
'gh pr create --title background --body background',
]);
@@ -402,13 +402,15 @@ describe('ShellTool', () => {
on: vi.fn(),
destroy,
} as unknown as fs.WriteStream);
mockExecuteBwrap.mockRejectedValueOnce(new Error('sandbox setup failed'));
mockExecuteSandbox.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(mockExecuteSandbox.mock.calls[0][4]).toBe(false);
expect(destroy).toHaveBeenCalledOnce();
expect(fs.rmSync).toHaveBeenCalledWith(
expect.stringMatching(/\.output$/),
Binary file not shown.
@@ -0,0 +1,315 @@
/*
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <unistd.h>
struct landlock_ruleset_attr {
uint64_t handled_access_fs;
};
struct landlock_path_beneath_attr {
uint64_t allowed_access;
int32_t parent_fd;
} __attribute__((packed));
#define LANDLOCK_CREATE_RULESET_VERSION (1U << 0)
#define LANDLOCK_RULE_PATH_BENEATH 1
#define LL_FS_EXECUTE (UINT64_C(1) << 0)
#define LL_FS_WRITE_FILE (UINT64_C(1) << 1)
#define LL_FS_READ_FILE (UINT64_C(1) << 2)
#define LL_FS_READ_DIR (UINT64_C(1) << 3)
#define LL_FS_REMOVE_DIR (UINT64_C(1) << 4)
#define LL_FS_REMOVE_FILE (UINT64_C(1) << 5)
#define LL_FS_MAKE_CHAR (UINT64_C(1) << 6)
#define LL_FS_MAKE_DIR (UINT64_C(1) << 7)
#define LL_FS_MAKE_REG (UINT64_C(1) << 8)
#define LL_FS_MAKE_SOCK (UINT64_C(1) << 9)
#define LL_FS_MAKE_FIFO (UINT64_C(1) << 10)
#define LL_FS_MAKE_BLOCK (UINT64_C(1) << 11)
#define LL_FS_MAKE_SYM (UINT64_C(1) << 12)
#define LL_FS_REFER (UINT64_C(1) << 13)
#define LL_FS_TRUNCATE (UINT64_C(1) << 14)
#define LL_FS_IOCTL_DEV (UINT64_C(1) << 15)
#define LL_ABI1_MASK (LL_FS_REFER - 1)
#define MIN_ABI 3L
#define MAX_KNOWN_ABI 5L
#define EXIT_LAUNCHER_FAILURE 125
#ifndef __NR_landlock_create_ruleset
#define __NR_landlock_create_ruleset 444
#define __NR_landlock_add_rule 445
#define __NR_landlock_restrict_self 446
#endif
struct command_line {
int probe;
int status_fd;
const char **read_only;
size_t read_only_count;
const char **read_write;
size_t read_write_count;
char **command;
};
static int fatal(const char *message) {
fprintf(stderr, "qwen-landlock-run: %s: %s\n", message, strerror(errno));
return EXIT_LAUNCHER_FAILURE;
}
static int fatal_text(const char *message) {
fprintf(stderr, "qwen-landlock-run: %s\n", message);
return EXIT_LAUNCHER_FAILURE;
}
static int parse_fd(const char *value, int *result) {
char *end = NULL;
errno = 0;
long parsed = strtol(value, &end, 10);
if (errno != 0 || end == value || *end != '\0' || parsed < 3 ||
parsed > INT_MAX) {
return fatal_text("--status-fd requires an open descriptor >= 3");
}
*result = (int)parsed;
return 0;
}
static int parse_arguments(int argc, char **argv, struct command_line *parsed) {
parsed->status_fd = -1;
parsed->read_only = calloc((size_t)argc, sizeof(*parsed->read_only));
parsed->read_write = calloc((size_t)argc, sizeof(*parsed->read_write));
if (parsed->read_only == NULL || parsed->read_write == NULL) {
return fatal_text("out of memory");
}
for (int index = 1; index < argc;) {
const char *argument = argv[index];
if (strcmp(argument, "--probe") == 0) {
if (argc != 2) return fatal_text("--probe takes no other arguments");
parsed->probe = 1;
return 0;
}
if (strcmp(argument, "--status-fd") == 0) {
if (index + 1 >= argc || parsed->status_fd >= 0) {
return fatal_text("invalid --status-fd");
}
int code = parse_fd(argv[index + 1], &parsed->status_fd);
if (code != 0) return code;
index += 2;
continue;
}
if (strcmp(argument, "--ro") == 0 || strcmp(argument, "--rw") == 0) {
if (index + 1 >= argc) return fatal_text("grant requires a path");
if (strcmp(argument, "--ro") == 0) {
parsed->read_only[parsed->read_only_count++] = argv[index + 1];
} else {
parsed->read_write[parsed->read_write_count++] = argv[index + 1];
}
index += 2;
continue;
}
if (strcmp(argument, "--") == 0) {
parsed->command = &argv[index + 1];
break;
}
return fatal_text("unknown argument");
}
if (parsed->command == NULL || parsed->command[0] == NULL) {
return fatal_text("missing -- <argv> command");
}
if (parsed->status_fd >= 0 && fcntl(parsed->status_fd, F_GETFD) < 0) {
return fatal("invalid status descriptor");
}
return 0;
}
static uint64_t access_mask(long abi) {
uint64_t mask = LL_ABI1_MASK | LL_FS_REFER | LL_FS_TRUNCATE;
if (abi >= 5) mask |= LL_FS_IOCTL_DEV;
return mask;
}
static int add_path_rule(int ruleset_fd, const char *path, uint64_t access) {
int path_fd = open(path, O_PATH | O_CLOEXEC);
if (path_fd < 0) return fatal("cannot open grant path");
struct stat metadata;
if (fstat(path_fd, &metadata) != 0) {
int saved = errno;
close(path_fd);
errno = saved;
return fatal("cannot inspect grant path");
}
if (!S_ISDIR(metadata.st_mode)) {
access &= LL_FS_EXECUTE | LL_FS_WRITE_FILE | LL_FS_READ_FILE |
LL_FS_TRUNCATE | LL_FS_IOCTL_DEV;
}
struct landlock_path_beneath_attr rule = {
.allowed_access = access,
.parent_fd = path_fd,
};
int result = (int)syscall(__NR_landlock_add_rule, ruleset_fd,
LANDLOCK_RULE_PATH_BENEATH, &rule, 0);
int saved = errno;
close(path_fd);
if (result != 0) {
errno = saved;
return fatal("cannot add grant rule");
}
return 0;
}
static int install_ruleset(const struct command_line *parsed, long *abi_out) {
long abi = syscall(__NR_landlock_create_ruleset, NULL, 0,
LANDLOCK_CREATE_RULESET_VERSION);
if (abi < MIN_ABI) {
if (abi < 0) return fatal("Landlock is unavailable");
return fatal_text("Landlock ABI 3 or newer is required");
}
long negotiated = abi < MAX_KNOWN_ABI ? abi : MAX_KNOWN_ABI;
uint64_t handled = access_mask(negotiated);
struct landlock_ruleset_attr ruleset = {.handled_access_fs = handled};
int ruleset_fd = (int)syscall(__NR_landlock_create_ruleset, &ruleset,
sizeof(ruleset), 0);
if (ruleset_fd < 0) return fatal("cannot create Landlock ruleset");
const uint64_t read_access =
LL_FS_EXECUTE | LL_FS_READ_FILE | LL_FS_READ_DIR;
for (size_t index = 0; index < parsed->read_only_count; index++) {
int code = add_path_rule(ruleset_fd, parsed->read_only[index],
read_access & handled);
if (code != 0) {
close(ruleset_fd);
return code;
}
}
for (size_t index = 0; index < parsed->read_write_count; index++) {
int code = add_path_rule(ruleset_fd, parsed->read_write[index], handled);
if (code != 0) {
close(ruleset_fd);
return code;
}
}
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) {
int saved = errno;
close(ruleset_fd);
errno = saved;
return fatal("cannot set no_new_privs");
}
if (syscall(__NR_landlock_restrict_self, ruleset_fd, 0) != 0) {
int saved = errno;
close(ruleset_fd);
errno = saved;
return fatal("cannot enforce Landlock ruleset");
}
close(ruleset_fd);
*abi_out = abi;
return 0;
}
static int write_all(int fd, const char *text) {
size_t remaining = strlen(text);
while (remaining > 0) {
ssize_t written = write(fd, text, remaining);
if (written < 0) {
if (errno == EINTR) continue;
return -1;
}
text += written;
remaining -= (size_t)written;
}
return 0;
}
static int report_status(int fd, const char *state, long abi) {
if (fd < 0) return 0;
char record[128];
int length = snprintf(record, sizeof(record),
"{\"state\":\"%s\",\"abi\":%ld}\n", state, abi);
if (length < 0 || (size_t)length >= sizeof(record) ||
write_all(fd, record) != 0) {
return fatal("cannot write execution status");
}
return 0;
}
static int make_stdio_blocking(void) {
for (int fd = STDIN_FILENO; fd <= STDERR_FILENO; fd++) {
int flags = fcntl(fd, F_GETFL);
if (flags < 0) {
if (errno == EBADF) continue;
return fatal("cannot inspect standard stream");
}
if ((flags & O_NONBLOCK) != 0 &&
fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) != 0) {
return fatal("cannot restore blocking standard stream");
}
}
return 0;
}
int main(int argc, char **argv) {
struct command_line parsed = {0};
int code = parse_arguments(argc, argv, &parsed);
if (code != 0) return code;
if (parsed.probe) {
const char *root = "/";
parsed.read_only = &root;
parsed.read_only_count = 1;
long abi = 0;
code = install_ruleset(&parsed, &abi);
if (code != 0) return code;
printf("{\"abi\":%ld,\"enforcement\":\"partial\"}\n", abi);
return 0;
}
/* Node relays can leave inherited streams nonblocking. Native payloads
* expect blocking stdio and may otherwise lose bytes on EAGAIN. */
code = make_stdio_blocking();
if (code != 0) return code;
pid_t parent = getppid();
if (prctl(PR_SET_PDEATHSIG, SIGKILL) != 0) {
return fatal("cannot set parent-death signal");
}
if (getppid() != parent) return fatal_text("parent exited during setup");
long abi = 0;
code = install_ruleset(&parsed, &abi);
if (code != 0) return code;
if (parsed.status_fd >= 0 &&
fcntl(parsed.status_fd, F_SETFD, FD_CLOEXEC) != 0) {
return fatal("cannot close status descriptor on exec");
}
code = report_status(parsed.status_fd, "prepared", abi);
if (code != 0) return code;
execvp(parsed.command[0], parsed.command);
int saved = errno;
(void)report_status(parsed.status_fd, "exec-failed", abi);
errno = saved;
return fatal("exec failed");
}
Binary file not shown.
@@ -1327,7 +1327,8 @@
"type": "string",
"enum": [
"auto",
"bwrap"
"bwrap",
"landlock"
]
},
"filesystem": {
+69
View File
@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { chmodSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const arch = process.argv[2];
if (!['x64', 'arm64'].includes(arch)) {
console.error('usage: node scripts/build_landlock_run.mjs <x64|arm64>');
process.exit(2);
}
const source = path.join(
root,
'packages/core/vendor/landlock-run/src/qwen-landlock-run.c',
);
const output = path.join(
root,
`packages/core/vendor/landlock-run/${arch}-linux/qwen-landlock-run`,
);
mkdirSync(path.dirname(output), { recursive: true });
const compiler = process.env['CC'] || 'musl-gcc';
const compilerArgs =
path.basename(compiler) === 'zig'
? [
'cc',
'-target',
arch === 'x64' ? 'x86_64-linux-musl' : 'aarch64-linux-musl',
]
: [];
const result = spawnSync(
compiler,
[
...compilerArgs,
'-std=c11',
'-Os',
'-static',
'-s',
'-Wall',
'-Wextra',
'-Werror',
'-o',
output,
source,
],
{ cwd: root, stdio: 'inherit' },
);
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
const header = readFileSync(output).subarray(0, 20);
const expectedMachine = arch === 'x64' ? 62 : 183;
if (
header.length < 20 ||
!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) ||
header[4] !== 2 ||
header[5] !== 1 ||
header.readUInt16LE(18) !== expectedMachine
) {
unlinkSync(output);
throw new Error(`Compiler did not produce a Linux ${arch} ELF.`);
}
chmodSync(output, 0o755);
+4 -2
View File
@@ -75,6 +75,7 @@ const DIST_REQUIRED_PATHS = [
'codeModeHost.js',
'execution-worker.js',
'sandboxBwrapRelay.js',
'sandboxLandlockRelay.js',
'sandboxFileWorker.js',
'chunks',
'vendor',
@@ -90,10 +91,11 @@ const DIST_ALLOWED_ENTRIES = new Set([
// sit next to cli.js so `new URL('./fzfWorker.js', ...)` resolves at runtime.
'fzfWorker.js',
'codeModeHost.js',
// bwrap sandbox relay + confined file worker; esbuild emits them as
// Sandbox relays + confined file worker; esbuild emits them as
// standalone entries that sandboxAsset() resolves from the bundle dir at
// execution time (packages/core/src/sandbox/bwrap-execution.ts).
// execution time (packages/core/src/sandbox/sandbox-execution.ts).
'sandboxBwrapRelay.js',
'sandboxLandlockRelay.js',
'sandboxFileWorker.js',
'chunks',
'vendor',
+4
View File
@@ -69,6 +69,9 @@ function verifyBundleArtifacts(rootDir, distDir) {
const requiredPaths = [
path.join(distDir, 'cli.js'),
path.join(distDir, 'execution-worker.js'),
path.join(distDir, 'sandboxBwrapRelay.js'),
path.join(distDir, 'sandboxLandlockRelay.js'),
path.join(distDir, 'sandboxFileWorker.js'),
path.join(distDir, 'vendor'),
path.join(distDir, 'bundled', 'qc-helper', 'docs'),
// The Web Shell ships with the published package ("Web Shell out of the
@@ -346,6 +349,7 @@ function writeDistPackageJson(rootDir, distDir) {
'fzfWorker.js',
'codeModeHost.js',
'sandboxBwrapRelay.js',
'sandboxLandlockRelay.js',
'sandboxFileWorker.js',
'chunks',
'vendor',
+2
View File
@@ -237,6 +237,8 @@ async function verify() {
workspace,
installation,
state,
effectiveBackend: 'bwrap',
enforcement: 'full',
filesystem: 'workspace-write',
network: 'closed',
...options,
+6 -1
View File
@@ -785,7 +785,11 @@ describe('standalone release packaging', () => {
packageScript,
);
expect(requiredBlock).not.toBeNull();
for (const asset of ['sandboxBwrapRelay.js', 'sandboxFileWorker.js']) {
for (const asset of [
'sandboxBwrapRelay.js',
'sandboxLandlockRelay.js',
'sandboxFileWorker.js',
]) {
expect(allowedBlock[1]).toContain(`'${asset}'`);
expect(npmOnlyBlock[1]).not.toContain(`'${asset}'`);
expect(requiredBlock[1]).toContain(`'${asset}'`);
@@ -5195,6 +5199,7 @@ function ensureMinimalDist({
writeFileSync(path.join(distPath, 'cli.js'), 'console.log("qwen");\n');
writeFileSync(path.join(distPath, 'codeModeHost.js'), 'export {};\n');
writeFileSync(path.join(distPath, 'sandboxBwrapRelay.js'), 'export {};\n');
writeFileSync(path.join(distPath, 'sandboxLandlockRelay.js'), 'export {};\n');
writeFileSync(path.join(distPath, 'sandboxFileWorker.js'), 'export {};\n');
if (includeCliEntry) {
writeFileSync(path.join(distPath, 'cli-entry.js'), 'import "./cli.js";\n');
@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
describe('bundled Landlock helpers', () => {
it.each([
['x64', 62],
['arm64', 183],
])('ships an executable Linux ELF for %s', (arch, machine) => {
const binary = path.join(
root,
'packages/core/vendor/landlock-run',
`${arch}-linux`,
'qwen-landlock-run',
);
const header = readFileSync(binary).subarray(0, 20);
expect([...header.subarray(0, 4)]).toEqual([0x7f, 0x45, 0x4c, 0x46]);
expect(header[4]).toBe(2);
expect(header[5]).toBe(1);
expect(header.readUInt16LE(18)).toBe(machine);
if (process.platform !== 'win32') {
expect(statSync(binary).mode & 0o111).not.toBe(0);
}
});
});
+34 -28
View File
@@ -704,35 +704,38 @@ describe('package asset scripts', () => {
).toBe(false);
});
it.each(['execution-worker.js', 'export-transcript-document.css'])(
'fails packaging when the published %s is missing',
(missingArtifact) => {
const rootDir = createFixtureRoot();
createBundleArtifacts(rootDir);
rmSync(path.join(rootDir, 'dist', missingArtifact));
stubConsole();
vi.spyOn(console, 'error').mockImplementation(() => {});
// verifyBundleArtifacts reports with console.error + process.exit(1), not a
// throw, so the exit has to become one to keep the rest of the suite alive.
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit(1)');
});
it.each([
'execution-worker.js',
'sandboxBwrapRelay.js',
'sandboxLandlockRelay.js',
'sandboxFileWorker.js',
'export-transcript-document.css',
])('fails packaging when the published %s is missing', (missingArtifact) => {
const rootDir = createFixtureRoot();
createBundleArtifacts(rootDir);
rmSync(path.join(rootDir, 'dist', missingArtifact));
stubConsole();
vi.spyOn(console, 'error').mockImplementation(() => {});
// verifyBundleArtifacts reports with console.error + process.exit(1), not a
// throw, so the exit has to become one to keep the rest of the suite alive.
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit(1)');
});
expect(() =>
preparePackage({ rootDir, requireNativeAudioCapture: false }),
).toThrow('process.exit(1)');
expect(exit).toHaveBeenCalledWith(1);
expect(
console.error.mock.calls
.map(([message]) => String(message))
.some(
(message) =>
message.includes('Required package artifact not found') &&
message.includes(missingArtifact),
),
).toBe(true);
},
);
expect(() =>
preparePackage({ rootDir, requireNativeAudioCapture: false }),
).toThrow('process.exit(1)');
expect(exit).toHaveBeenCalledWith(1);
expect(
console.error.mock.calls
.map(([message]) => String(message))
.some(
(message) =>
message.includes('Required package artifact not found') &&
message.includes(missingArtifact),
),
).toBe(true);
});
it.each(['manifest.webmanifest', 'sw.js'])(
'rejects a published shell missing %s',
@@ -1560,6 +1563,9 @@ describe('package asset scripts', () => {
function createBundleArtifacts(rootDir) {
writeFile(rootDir, 'dist/cli.js', '');
writeFile(rootDir, 'dist/execution-worker.js', '');
writeFile(rootDir, 'dist/sandboxBwrapRelay.js', '');
writeFile(rootDir, 'dist/sandboxLandlockRelay.js', '');
writeFile(rootDir, 'dist/sandboxFileWorker.js', '');
mkdirSync(path.join(rootDir, 'dist', 'vendor'), { recursive: true });
mkdirSync(path.join(rootDir, 'dist', 'bundled', 'qc-helper', 'docs'), {
recursive: true,