ScienceDiscovery
English GitHub

沙箱执行:services/runner

Runner 是无 root 的代码执行器:Agent 的常规执行统一使用 run_shell,包括 python -m、Python 文件与 Rscript,在 bubblewrap + seccomp 沙箱内访问当前 Agent×Runner 的 Workspace。每次调用启动新进程,不跨调用继承 cwd、export 或解释器内存。沙箱网络默认 none;见 §3.1。Runner 同时管理 micromamba 科学环境,默认监听回环 127.0.0.1:4311,由 API 调用。

1. 源码结构

文件 作用
server.ts HTTP 路由、Bearer + HMAC 鉴权、Workspace 准入与执行队列、启动预检
executor.ts 一次性沙箱执行:bwrap 参数组装、配额与超时、工作区快照
execution-manager.ts 受管理 Shell 的生命周期、状态/日志/取消与已提交 Workspace 回执
kernel-manager.ts、shell-session-manager.ts、session-env-profile.ts 旧内部基础组件;HTTP 执行不再启动持久 worker,也不注入历史 profile
environment-store.ts 科学环境 provisioning:micromamba、目录 catalog、命名环境原地更新与 revision 记录
seccomp.ts x86_64/aarch64 seccomp BPF(拒绝同一类高风险 syscall,EPERM),baseline、network 与无 egress 的 NPU 兼容 profile 按宿主架构写入 .sciencediscovery-data/runner-runtime/seccomp-*.bpf
egress-gateway.ts 沙箱网络访问的宿主侧出口:按 policy revision 复用的 UDS HTTP 服务,域名允许列表与地址分类
egress-bridge.ts 沙箱内 TCP→UDS 桥接脚本、宿主解释器探测与 bwrap 绑定参数
request-auth.ts HMAC-SHA256(token + 时间戳 + body SHA256),30 秒新鲜度窗口

2. HTTP 面

执行请求带 executionId 幂等(60 秒内重复 → 409)。

3. 沙箱构造

启动预检要求 bwrap 支持:--cap-drop --die-with-parent --new-session --seccomp --unshare-all --unshare-user,并实际运行一次探针。

沙箱形态有两处会被环境拒绝,都由运行时实测决定,并按「先定 /proc,再定 --disable-userns」的顺序判定, 避免两者互相误判。检测实现见 packages/sandbox-capability,按二进制路径缓存;launcher 的 probeSandbox 与 runner 共用同一结论,避免出现「预检通过但工具全挂」。

其一,/proc 的提供方式。 默认 --proc /proc,让沙箱拥有自己的 procfs,只看得见自己的进程。 Docker 默认的 readonlyPaths / maskedPaths 会让内核拒绝在沙箱自己的 pid 命名空间里挂载新的 procfs (报 Can't mount proc on /newroot/proc: Operation not permitted)。此时自动回退为 --ro-bind /proc /proc 并打印 warning:执行仍可进行,但沙箱看见的是容器的进程列表。官方 Compose 通过 systempaths=unconfined 保住默认的强形态;回退不是默认,也不应改用 privileged 消除。

探测结果 结论 行为
能新建 procfs new 使用 --proc /proc
新建被拒但 bind 可用 bind 改用 --ro-bind /proc /proc 并告警

其二,--disable-userns(禁止嵌套 userns)。 是否追加同样由实测决定,而不是看版本号或 --help: 该选项的实现是往 user.max_user_namespaces 写值,因此在 LXC 和把 /proc/sys 挂成只读的容器里, 即使 bwrap ≥ 0.8 认识该选项,写入也会失败并让整个 launch 中止。检测方式是先用带该选项的最小沙箱探一次, 失败再用不带该选项的最小沙箱探一次(两次都在上面已定好的 /proc 形态上进行),从而区分三种情况:

探测结果 结论 行为
带选项即可启动 supported 追加 --disable-userns
旧版 bwrap 不认识该选项 option-unknown 省略并告警,提示升级 bubblewrap
认识但环境拒绝写 sysctl option-rejected 省略并告警,说明只读 /proc/sys
不带选项也起不来 sandbox-unusable 沙箱整体不可用,预检告警

两处降级都只减少对应的那一项,其余隔离(命名空间、seccomp、挂载白名单)不受影响, 常规 Shell(包括其中启动的 Python/R)与旧 ephemeral 语言端点共用同一结论。

核心参数(buildSandboxLaunch,同时产出注入的 env 映射与 cwd 用于溯源):

--die-with-parent --new-session
--unshare-all --unshare-user [--disable-userns]  # 全命名空间隔离(含网络);实测可用时才禁止嵌套 userns
--cap-drop ALL
--ro-bind /usr /usr(+ /bin /lib /lib64 symlink、/dev、--tmpfs /tmp)
--proc /proc | --ro-bind /proc /proc              # 默认新建 procfs;被拒时回退为 bind 并告警
--ro-bind /dev/null /usr/bin/{python3*,R,Rscript}   # 启用科学环境时屏蔽宿主解释器
--ro-bind <所选环境前缀> /opt/science-env          # 科学环境只读挂载
--bind <Agent×Runner 工作区> /workspace --chdir /workspace|<本次显式 cwd>
--clearenv --setenv HOME /tmp --setenv PATH …(Python 另加 PYTHONNOUSERSITE=1;不注入历史 profile)
--seccomp 3                                          # BPF 过滤器经 fd 3 传入

旧同步端点在客户端断开时 abort。受管理 Shell Execution 不因客户端等待期限或断开而自动停止,取消须走 Execution 管理端点。

3.1 沙箱网络访问

沙箱网络访问是系统设置里的策略,由 API 在创建 Permission Epoch 时快照进 epoch(networkPolicy + networkAccess,含内容派生的 revision),Runner 按该快照决定沙箱形态。它与「网络代理」设置无关:后者管的是 API / Gateway / MCP 自身的出站,不影响沙箱代码。

模式 沙箱形态
none(默认) 与历史行为完全一致:--unshare-all、无 --share-net、基线 seccomp 拒绝全部 socket 系统调用,不挂通道、不注入出站 env
domain-allowlist 仍然 --unshare-all 且不加 --share-net。沙箱唯一的出口是挂载进来的 Unix domain socket

domain-allowlist 的数据面:

沙箱进程(独立 netns,无网卡)
  └─ HTTP_PROXY=http://127.0.0.1:18118
       └─ egress bridge(沙箱内,监听沙箱自己的回环)
            └─ /run/sciencediscovery/egress.sock(bind-mount)
                 └─ egress gateway(Runner 进程内,与 Runner 同用户)
                      └─ 按域名允许列表放行 → 公网

要点:

3.2 沙箱内的 Ascend NPU

选中的昇腾芯片会被交进沙箱。这份文档早先写的是做不到——因为在 bwrap namespace 里探测会报 Container ID verify failed (session ct_id=0; device ct_id=...)。那次测量是在宿主整个 /dev 都可见的情况下做的,这个报错是驱动在那种情况下的正常反应,而不是设备直通的限制:进入 mount namespace 后,驱动按调用者 /dev 里可见的卡枚举,且是 all-or-nothing,只要有一张卡被别的租户占着,整次调用就对所有卡失败。只暴露选中的芯片就不再满足这个条件,910B3 上沙箱内的 npu-smi info 与 MindSpore 都能正常跑。

launch 具体做的事:

哪些芯片可选由逐芯片的真实探针决定,不看宿主清单:用一个与真实执行同形态的一次性沙箱只绑那一颗芯片,在里面跑 npu-smi info。宿主会把沙箱根本打不开的卡报成健康,所以只有探针通过的芯片才能勾选;而且每次执行前会对它点名的芯片重探一遍——期间被别的租户占走的芯片会让执行以卡号明确失败,而不是在框架深处报一个不指名的错。支持范围是昇腾 910 系列,其他芯片会列出但拒绝,理由里写明芯片名。

机器状态优先通过驱动自带的 DCMI 接口读取(用宿主 Python,不编译任何东西),不可用时回退到 npu-smi info -m 加 npu-smi info。设备身份一律用 chip logic id(即 /dev/davinciN 的 N),不用卡号,因为一张卡可能带不止一颗计算 die。

3.3 Ascend NPU Broker(可选的宿主执行)

与上面那条路径相互独立,Runner 仍然提供按需开启的宿主 NPU Broker,用于不属于普通 Agent 执行的白名单宿主作业:SCIENCE_AGENT_NPU_BROKER=1 时才暴露 run_npu_job,只接受白名单里的 workloadId 并以 shell: false 启动固定 entrypoint,作业子进程在宿主 namespace 中运行。设计背景见 Ascend NPU 宿主 Broker。

4. 执行模型与配额

如何查看 / 修改配额

# 查看 API 上传限额与 runner 回退值
curl -s http://127.0.0.1:4310/health | jq '.workspace, .runner.maxWorkspaceBytes, .runner.maxFileBytes, .runner.maxOutputBytes'

# 查看/修改持久化系统配额(对新执行立即生效)
curl -s -H "authorization: Bearer $TOKEN" http://127.0.0.1:4310/api/quota-settings
curl -s -X PUT -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
  http://127.0.0.1:4310/api/quota-settings \
  -d '{"runnerMaxWorkspaceBytes":10737418240,"runnerMaxOutputBytes":1073741824,"uploadMaxFileBytes":1073741824,"uploadMaxRequestBytes":10737418240}'

也可在 Web → Settings → Quotas 中调整(Upload per file / Upload per request / Workspace total / Execution output,单位均为 GiB;勾选 Unlimited = 0)。上传区提示与 /health.workspace 使用同一套持久化值。

环境变量(需重启对应服务;首次播种系统设置初始值):

变量 默认 含义
SCIENCE_AGENT_MAX_WORKSPACE_BYTES 10737418240(10 GiB) 执行侧工作区总量;0 = 不限
SCIENCE_AGENT_MAX_OUTPUT_BYTES 1073741824(1 GiB) 执行输出保留预算;0 = 不截断
SCIENCE_AGENT_WORKSPACE_MAX_BYTES 10737418240 API 上传累计工作区上限;0 = 不限
SCIENCE_AGENT_WORKSPACE_UPLOAD_MAX_FILE_BYTES 1073741824 上传单文件上限;0 = 不限
SCIENCE_AGENT_WORKSPACE_UPLOAD_MAX_REQUEST_BYTES 10737418240 单次 multipart 请求体上限;0 = 不限

5. 语言运行时

入口 执行进程
常规 run_shell 新建严格模式 Bash,可启动所选环境的 Python 模块/文件、Rscript 等工具
旧 Python HTTP 端点 ephemeral python3 -I -
旧 R HTTP 端点 ephemeral R --vanilla --slave

解释器来自宿主 /usr/bin 或科学环境 /opt/science-env/bin。

6. 科学环境

7. 执行生命周期与迁移

HTTP /execute、/execute-shell、/shell-executions 在创建 Workspace 或启动代码之前拒绝 kernelMode=persistent。一次性授权也不再静默降级该请求。省略此字段或使用 ephemeral;长任务使用受管理 Shell Execution。

持久解释器可能在一次调用返回后留下线程或子进程,越过 Workspace 写锁与快照提交边界继续写入。Linux ephemeral 执行在最终快照和释放租约前结束整个 Bubblewrap PID namespace。受管理后台 Execution 则在真实负载运行期间持续持有写入权;它不是可跨调用复用的交互式 Shell。

历史 Session profile 不再注入。每次显式选择 cwd 与环境,需要的 export 和命令写在同一个脚本中。Python/R 内存不跨调用保留;Notebook 式共享内存不属于本次实现。

相关文档