跳到主要内容

工作区隔离能力

Open ACE 为本地交互工作区(聊天 WebUI、文件接口、会话历史)提供版本化的多用户 隔离能力契约。管理员与可信接入方应通过 API 查询实际生效的能力,而不是依赖 README 声明或客户端传入的 capability 布尔值。本文档说明契约语义、部署要求、 reason code 对照与已知缺口。关联 issue:#3374(os_user)、#3378(sandboxed)、#3431(约束与本机 gVisor 容器)、#3438(本机 Kata 容器)。

本文是参考文档。要选择并配置一种隔离方式,请先看管理员指南 WORKSPACE_ISOLATION。

1. 能力契约字段​

查询端点:GET /api/workspace/isolation-capabilities(需要登录会话)

{
"local_workspace_multi_user": "supported | unsupported",
"backend": "shared | plain | bwrap | local-gvisor | local-kata | opensandbox:<tier>",
"isolation_level": "none | os_user | sandboxed",
"enforced": ["identity", "filesystem", "environment", "process"],
"unsupported": ["resources", "network_egress", "kernel"],
"reasons": [{"code": "...", "message": "..."}],
"entry_points": {
"webui": "enforced",
"filesystem_api": "enforced",
"session_history": "enforced",
"terminal": "remote_machine_scope",
"vscode": "remote_machine_scope",
"autonomous": "separate_contract"
},
"entry_point_details": {
"filesystem_api": {
"status": "enforced",
"scope": "local_workspace",
"covered_by_isolation_level": true,
"operations": [
{"name": "upload", "roots": ["home"],
"symlink_policy": "never_followed_with_elevated_privilege"},
{"name": "browse", "roots": ["home", "shared_projects"],
"symlink_policy": "resolved_then_rejected_if_outside"}
],
"access_control": ["home_subtree_lock", "shared_project_acl", "os_account_dac",
"nofollow_fd_descent"],
"boundary": "...",
"limitations": [],
"residuals": [{"code": "shared_project_roots_are_cross_user_by_design", "message": "..."}]
}
},
"policy_revision": "2026-09-26.3",
"install_method": "package | docker | dev | unknown",
"available_backends": {"local-kata": {"available": false, "reason": "install_method_docker"}, "...": {"available": true}}
}
  • local_workspace_multi_user:本地交互工作区多用户隔离是否受支持。
  • backend:即 workspace.isolation.backend 的名字(#3446),快照报告管理员配置的 backend——plain (每用户独立 WebUI 进程,以该用户的 OS 账户运行)、bwrap(#3431:同上,且每个进程运行在 systemd scope + bubblewrap 约束内,见 §5.1)、shared(共享单实例)、local-gvisor(#3431:sandboxed 等级, WebUI 运行于本机 gVisor 容器,见 §5.2)、local-kata(#3438:sandboxed 等级,WebUI 运行于本机 Kata 容器,见 §5.3)或 opensandbox:<tier>(#3378:sandboxed 等级,WebUI 运行于该 tier 的 OpenSandbox pod, 见 §6)。配置了但未就绪的 backend 报告为 supported: false 并给出原因,绝不会被替换成别的 backend; 报告的等级是本机验证到的等级。
  • install_method / available_backends:本部署的安装方式(OPENACE_INSTALL_METHOD),以及每个 backend 在这里到底能否使用(不能时为 install_method_docker / platform_unsupported)。所配置 backend 的就绪 状态见 reasons。
  • isolation_level / enforced / unsupported:见下节。
  • reasons:unsupported 时的机器可读原因(可能为空)。
  • entry_point_details(#3410 新增,与 entry_points 同生共死):每个入口的机器 可读细节,让接入方能区分「满足所声明等级」「由其它机制治理」「真实缺口」, 而不必解析散文。字段:
    • status:与 entry_points 中同名键完全一致(单测钉住);
    • scope:local_workspace / remote_machine / separate_contract;
    • covered_by_isolation_level:该入口是否被本快照声明的 isolation_level 覆盖;
    • operations[]:{name, roots, symlink_policy}——逐操作声明可达根集合与 符号链接策略(同一入口内并不统一,见 §4);
    • access_control[]:实际强制该入口的机制代号;
    • limitations[]:真实缺口/未覆盖范围,参与准入判定(enforced 入口恒为空);
    • residuals[]:已知且被接受的性质(如共享项目根按设计跨用户),不参与准入判定。
  • 未知 status 或未评审过的 policy_revision 必须按拒绝处理(fail closed): 本契约会新增状态词,接入方不得把未知值当作通过。
  • entry_points:各入口的隔离覆盖状态(§4)。仅在 supported 快照中输出—— 入口矩阵描述的是多用户隔离的覆盖面,unsupported(单用户/未验证/降级)部署 不携带该矩阵,避免"无隔离"与"webui: enforced"同帧自相矛盾。该矩阵为静态 审计结论,随 policy_revision 版本化;各入口与代码的 conformance 绑定为 后续工作(§8)。
  • policy_revision:契约语义版本;推导逻辑或入口矩阵变化时递增。

2. 隔离等级语义​

等级序:none < os_user < sandboxed(required_isolation 请求参数与 下限 workspace.isolation.level 均按此序比较,只能抬高不能降低)。

等级语义
none所有本地交互会话共享同一服务账户运行,无用户间文件/环境/进程隔离(单用户轻量模式,by design)
os_user每用户独立系统账户与 HOME/TMP/XDG;WebUI 进程以该用户 UID 经 sudo -u 启动;子进程环境为显式白名单(真实模型 API key 永不进入,以代理 token 替代);文件接口应用层 home 子树锁 + OS 权限
sandboxed每用户 WebUI 运行在拥有独立内核边界的沙箱里:OpenSandbox pod(#3378,§6)或本机容器(#3431/#3438,§5.2、§5.3)。以下为 pod 形态:每用户 WebUI 进程运行于 OpenSandbox pod(#3378):每实例独立 pod、digest-pinned 且在 image_allowlist 的专属 webui 镜像、deny-default 出口、恒有资源边界;身份为 per-instance token secret(非 OS 账户,实例销毁即失效);浏览器经控制面本地端口代理访问。部署要求与诚实边界见 §6

边界声明:os_user 共享宿主内核,没有命名空间隔离、没有网络出口策略—— "分目录/更换 HOME"不构成强运行时隔离。resources、network_egress 与 kernel 三个维度对普通 os_user 始终列在 unsupported(交互 WebUI 无 per-task cgroup,仅实例数上限与空闲清理;无出口策略;共享宿主内核)。约束模式(§5.1)会补上 resources 与 network_egress。sandboxed 等级的维度语义与验证边界见 §5.2、§5.3 与 §6.1。autonomous 任务的资源与沙箱策略沿用独立的 #2022 sandbox 契约(sandbox_effective_policy),见 SANDBOX_BACKENDS。

平台边界:仅 Linux 部署可申报 os_user。macOS 跳过系统用户创建,Open ACE 无法建立/验证身份映射;Windows 强制单实例。两者契约均为 unsupported/platform_unsupported。

3. Reason Code 对照​

四套代码,职责不同:

3.1 契约 reasons[](部署级:为什么整体 unsupported)​

code含义
webui_disabledWebUI 管理器未启用,无交互工作区运行时
platform_unsupported非 Linux 平台(Windows/macOS/其他)
isolation_backend_sharedworkspace.isolation.backend 为 shared(单用户轻量模式,预期状态而非缺陷)
launch_path_degradedWebUI 启动路径无法承载按用户实例(message 括注 §3.3 的具体原因,如 dev 目录模式/包装器缺失);契约与 /user-url 门闸看同一条路径,不会互相矛盾
launch_path_unverified冷 worker 上 manager 尚未初始化、探针未运行:等级为 provisional(部署形态达标),manager 初始化后自动消除;按 revision 缓存/灰度的接入方应同时检查该码

3.2 门闸 error_code(请求级:user-url?required_isolation=... 的拒绝)​

code含义
invalid_isolation_levelrequired_isolation 取值非法(合法值:none/os_user/sandboxed)
isolation_level_unsupported请求等级超过部署实际支持等级,拒绝以弱隔离静默启动
identity_mapping_missing用户在数据库中无 system_account 映射;显式要求隔离时不做 username 静默回退(仅 os_user 链;sandboxed 的身份是 per-instance token,不查 OS 账户)
per_user_launch_unavailable启动路径无法以该用户 UID 运行(message 内嵌 §3.3 的原因码;仅 os_user 链)

OpenSandbox pod 形态下,sandboxed 请求的门闸是能力探测本身(本机容器形态 §5.2/§5.3 仍走 os_user 身份链):等级不满足时按 §3.4 的探测码 (优先)或 isolation_level_unsupported 拒绝,不产生 identity_mapping_missing/per_user_launch_unavailable。

易混对照:launch_path_unverified(部署级,"冷 worker 尚未探针,等级 provisional") vs identity_mapping_missing(用户级,门闸拒绝"这个用户没有身份映射")。

3.3 探针原因码(嵌在 per_user_launch_unavailable 的 message 中,或作为部署级 launch_path_degraded 的括注)​

code含义
platform_unsupported非 Linux/macOS 平台
webui_executable_missing找不到 qwen-code-webui 可执行文件
current_user_unresolved服务进程自身 UID 无法解析
dev_directory_mode_shared_accountdev 目录模式以服务用户运行 node,不切换 UID
launch_wrapper_missingopenace-webui-launch 包装器未安装或不可执行(sudo 路径的真实前置)
sudo_unavailable无 sudo 二进制
privileged_system_account目标系统账户 uid 为 0(如映射到 root)
reserved_system_account目标系统账户 uid < 1000(系统保留段)

约束模式与本机容器模式另有各自的 confinement_* 原因码,见 §5.1、§5.2、§5.3。

3.4 sandboxed 探测与运行期原因码(#3378,opensandbox backend)​

sandboxed 等级的探测是零 pod、配置面 fail-closed 的(不创建 pod 即可 判定);以下前 8 个为探测级 reason(命中即拒绝),后 4 个为快照级(出现在契约 reasons[] 中、不阻止申报),最后 2 个为运行期错误码(启动器抛出,非探测码)。

code级别含义修复动作
sandbox_backend_unconfigured探测sandbox-backends.json 缺失、不可解析或未配置提供/修复后端配置(见 SANDBOX_BACKENDS §3)
sandbox_tier_missing探测workspace.isolation.tier(或后端 default_tier)在 endpoints 中无对应条目修正 isolation.tier 或在 endpoints 补齐该 tier
webui_image_missing探测该 tier 未配置 webui_image配置含 qwen-code-webui 的镜像(参考构建:scripts/docker/webui-sandbox.Dockerfile)
webui_image_not_pinned探测webui_image 非 digest-pinned(name@sha256:<64 hex>)改用 digest 引用——tag 可被重指向,会架空白名单
webui_image_not_allowed探测webui_image 不在 image_allowlist将镜像加入 image_allowlist,或换用已在列的镜像
sandbox_api_key_missing探测该 tier 的 api_key_env 指定的环境变量在本进程为空——创建 pod 的第一个 API 调用就会失败;契约与启动路径不得对同一台主机给出矛盾结论(#3375 原则)在运行 web 进程的环境中设置该 API key(sandbox-backends.json 的 api_key_env 字段)
sandbox_multi_process_unsupported探测存在另一个持有新鲜心跳的 web 进程(心跳根不可读/不可判定时同样视为存在——无法证明本进程是独苗):per-instance token secret 与实例管理是进程内存态,跨副本时约 2/3 的 token 校验会随机 401;出货的 k8s manifest(3 副本)默认即此形态,opensandbox backend 在那里报告为 unsupported,启动一律被拒绝单 web 进程部署(如 docker-compose 单副本)才可使用 opensandbox;多副本形态使用 OS 账户类 backend,或等待多副本实例管理支持(follow-up)
sandbox_proxy_unreachable探测workspace.webui_callback_url 未设置;或该 URL 在该 tier 出口策略下不可达(loopback;sidecar tier 不在 egress_allow_hosts;CNI tier 为私网/集群内地址)设置 webui_callback_url;sidecar tier 将控制面主机名加入 egress_allow_hosts;CNI tier 保证公网可达
sandbox_runtime_unverified快照静态视图:仅配置面验证通过;kernel/network_egress 待首个 pod boot probe 确认(memo 在控制面重启、同 tier probe 失败或条目超过 1h 后回退到该状态)无需修复;首次成功启动 pod 后自动升级
sandbox_launch_unverified快照冷 worker(manager 尚未初始化、沙箱启动链路未在本进程演练过):等级为 provisional,manager 初始化后自动消除(对齐 os_user 的 launch_path_unverified 先例)无需修复;首次工作区活动后消失
sandbox_runtime_kata_negative_only快照首 pod probe 通过,但 kernel 仅负向验证(Kata 只能排除 gVisor,无法与未隔离 runc 区分):kernel 保持 unsupported无需修复;换 gVisor tier 可获得 kernel 正向验证
sandbox_runtime_egress_negative_only快照首 pod probe 通过,但出口仅负向验证(gVisor/CNI tier 的集群级 deny-default 对照——证明拒绝路径存在,不能证明放行生效):network_egress 保持 unsupported;仅 sidecar tier 的 /policy 实读才升级(T-M)无需修复;需要申报 network_egress 时使用 sidecar attestation tier
sandbox_create_failed运行期create 请求被拒、create 失败或 boot probe 失败(probe 失败会立即销毁 pod)查看 message 内嵌原因(含 provider probe 码透传)
sandbox_endpoint_unresolved运行期pod 的 3100 端点经 GET /sandboxes/{id}/endpoints/3100 解析失败——网关无法为未声明端口应答(外部假设,集群端验证归 #3379)检查网关对未声明端口的应答行为;该通路未经真实集群验证

4. 入口覆盖矩阵​

入口状态scope覆盖范围与证据
webui(聊天工具)enforcedlocal_workspace每用户独立实例/UID/端口/token;停止与 token 撤销按 user_id 隔离
filesystem_apienforcedlocal_workspace八个 /api/fs 操作逐个声明 roots 与符号链接策略(见下表与 entry_point_details);所有请求路径先 realpath,落到声明根之外即拒;upload/download/delete-file/search 在 root 进程下自 home 根逐段 O_NOFOLLOW 以目录 fd 下降后再写入(renameat)、读取、删除(unlinkat)或遍历(fwalk),包安装非 root 形态则委托给以目标账户身份探测/写入/删除的 wrapper(#3410);create-directory 与 check-path 同可创建集;单文件路径锁与 browse 同口径(逐 base home 根);<base>/<account> 0700
session_historyenforcedlocal_workspace应用层所有权闸门 + 租户 fail-closed
terminalremote_machine_scoperemote_machine远端机器能力:本入口不分配本地路径/账户/令牌;治理方为 machine assignment ACL + 会话所有者 + 租户(#3376)。本地隔离等级不覆盖远端机器自身的用户隔离
vscoderemote_machine_scoperemote_machine同上(code-server);owner=请求者(#3376 VSCodeOwnerStore),proxy/WS 同闸门
autonomousseparate_contractseparate_contract沿用 #2022 sandbox 契约,不在本契约范围

filesystem_api 逐操作边界(即 entry_point_details.filesystem_api.operations, 同一入口内并不统一,这正是单句散文无法如实描述的原因):

操作roots符号链接策略
browsehome, shared_projectsresolved_then_rejected_if_outside
check-pathhome, shared_projects, workspace_root, workspace_root_first_level_non_homeresolved_then_rejected_if_outside
create-directory同 check-pathresolved_then_rejected_if_outside
homehomenot_applicable
uploadhomenever_followed_with_elevated_privilege
downloadhomenever_followed_with_elevated_privilege
delete-filehomenever_followed_with_elevated_privilege
searchhomenever_followed_with_elevated_privilege

两个策略词的含义:

  • resolved_then_rejected_if_outside:请求路径先 realpath,落到该行 roots 之外即拒。 browse/check-path/create-directory 对有 system_account 的用户以目标账户身份执行 (sudo -u),因此其后的访问受该账户自身权限约束。
  • never_followed_with_elevated_privilege:在上一条的基础上,home 根之下的任何 符号链接都不会被权限高于目标账户的进程跟随。root 进程先以 fstat 断言 home 根 属于目标账户,再自 home 根逐段 O_NOFOLLOW 打开目录 fd,写入/读取/删除/遍历都 相对该 fd 完成(遍历时跳过符号链接本身);单用户进程本身就是该账户;包安装非 root 形态委托给 openace-write-as/openace-rm,二者在校验用户与路径前缀之后的每一次 文件系统探测、写入与删除都经 runuser 以目标账户身份执行。

workspace_root_first_level_non_home 指 base dir 的一级子目录且不是任何用户的 home 根(_check_path_rejection_reason 规则 3);写入口不包含 shared_projects—— browse/check-path 可读共享项目,但单文件写/下载仍限本人 home,放宽属于新功能。

部署前提(filesystem_api: enforced 的证据边界):

  1. workspace base dir 必须 root 所有且非用户可写(docker-entrypoint.sh 以 root mkdir -p 建为 root:root 0755,/home 为 chmod 755)。单文件操作的信任锚是 realpath(<base>/<account>),其下不以高于目标账户的权限跟随任何符号链接, root 分支另以 fstat 断言该锚属于目标账户。账户目录本身会被解析,但不能借此 把 home 挪到任意卷:请求路径先 realpath,再与按配置原样的 base 前缀比较, 解析后落在所有已配置 base 之外的 home 会被直接拒绝。要挂载大卷,请让 <base>/<account> 指向某个已配置 base 之内的位置,或把卷路径加入 WORKSPACE_BASE_DIR。
  2. 包安装非 root 多用户形态必须重装 openace-write-as 与 openace-rm(≥ 本次 版本,重跑 scripts/install-central/package-method/install.sh 即可):该形态下 web 进程无法穿越 0700 home,上传与删除由这两个 wrapper 以目标账户身份完成; 符号链接/目录拒绝(exit 5 / exit 6)由路由翻译成 400。若已安装的 wrapper 版本 过旧(缺少能力标记 openace-write-as-capability: symlink-refusal=1 或 openace-rm-capability: account-scoped=1),路由直接 fail-closed 拒绝对应的 上传或删除(500,错误信息给出重装指引)——因此 enforced 声明对所有部署都 成立:要么 wrapper 以目标账户执行,要么操作根本不发生。
  3. <base>/<account> 为 0700:Docker 形态下新目录以 0700 创建,既有卷在容器启动 (entrypoint)与登录供给(root 进程,经不跟随符号链接的描述符)时收敛到 0700, 且只改属于该账户的目录。包安装形态的 /home/<account> 权限由 useradd (HOME_MODE)决定;自定义 base 时目录由 openace-mkdir 以目标账户身份按默认 umask 创建,服务账户无权修改,请运维自行将既有账户目录设为 0700。
  4. 三项已声明 residual(共享项目根按设计跨用户、包安装形态的 wrapper 前提、未映射 system_account 的用户以 web 进程身份在 <base>/<username> 内工作)在 entry_point_details.filesystem_api.residuals 中机器可读,不参与准入判定。 未映射用户的用户名若恰是另一用户的 system_account,root 进程下该用户没有 任何 home 根(所有 /api/fs 操作都会被拒)——两者会指向同一个 OS 账户与目录。 名为 shared 的账户(无论来自 system_account 还是用户名,后者也可能由 SSO / 组织同步产生)同样没有 home 根:<base>/shared 是共享项目命名空间根,不是 home。
  5. os_user 共享宿主内核:resources / network_egress / kernel 三维度仍 unsupported(§2 边界声明)。

sandboxed 部署下的矩阵取值(#3378 起):矩阵按等级 输出——webui 随 sandboxed 等级进入 pod(enforced);terminal/vscode/ filesystem_api 输出 sandboxed_entry_not_wired(执行体仍在控制面宿主上, 未接线到用户的沙箱实例),对 sandboxed 用户的 /fs host 树亦不可用——其文件在 pod 内,由 webui 自带的 in-pod 文件浏览承载。这三个入口在 sandboxed 快照中保留各自 原有的 limitations(terminal/vscode 的远端范围说明)并追加 sandboxed_entry_not_wired;filesystem_api 的 boundary 改为说明未接线,且不再 携带只描述 host 树的 residuals。session_history 仍 enforced (per-pod 快照存储);autonomous 仍 separate_contract。os_user/none 快照的 矩阵取值见上表(#3410 重标定);本机容器形态(§5.2、§5.3)沿用 os_user 矩阵,因为其 home 是宿主目录。 filesystem_api 在 sandboxed 下与 os_user 下的取值 不同是刻意的——本地强制不等于已接线到 pod。

5. 多用户模式部署要求​

契约是否报告 supported/os_user 由启动路径就绪探针判定(§3.3:WebUI 解析、 非 dev 目录模式、openace-webui-launch 包装器、sudo),不再以 Docker 布局为 先决条件——包安装形态(scripts/install-central/package-method,sudo -u 与 wrapper 齐备)同样可以验证并强制 os_user。

两种参考部署:

  1. Docker 多用户:docker-compose.multi-user.yml 叠加(root + OPENACE_ALLOW_ROOT_MULTI_USER=1 + WORKSPACE_BASE_DIR=/workspace + WORKSPACE_ISOLATION_BACKEND=plain,entrypoint 会在生成的配置中写入 workspace.isolation {"level": "os_user", "backend": "plain"}),镜像内置 useradd 与 wrapper,系统账户自动供给;
  2. 包安装多用户:installer 的多用户路径(非 root 运行 + /home 布局),写入同样的 isolation 配置块,探针健康即报告 os_user;每用户系统账户需已存在(getpwnam 可解析,uid ≥ 1000)。

共同要求:sudo/sudoers 中受限的 openace-webui-launch 包装器;无内核特殊要求 (os_user 等级即 OS 账户边界);生产基线密钥见 compose 注释。

探针降级(dev 目录模式/缺 wrapper 等)时契约如实返回 launch_path_degraded unsupported——不会静默降级到共享账户后宣称支持;由于 workspace.isolation.level 就是下限,此时每一次 启动都会被结构化拒绝,直到启动路径修复。

安装方式决定下面的 backend 能否使用:Docker 安装只支持 shared、plain 与 opensandbox;bwrap 与本机容器(§5.1–§5.3)需要 Linux 主机上的包安装,在其他安装方式下启动时即被拒绝(#3446)。见 WORKSPACE_ISOLATION 中的安装方式对照表。

5.1 Backend bwrap:受约束的 OS 账户(#3431)​

workspace.isolation {"level": "os_user", "backend": "bwrap"} 让每个 OS 账户 WebUI 在启动时被约束, 仍属 os_user 等级(共享宿主内核),但 resources 与 network_egress 两个维度 变为 enforced,backend 报告 bwrap:

层机制效果
资源systemd-run --scope(MemoryMax/MemorySwapMax=0/CPUQuota/TasksMax)cgroup v2 硬限制,含 fork bomb 上限
身份setpriv --reuid/--regid --init-groups --no-new-privs,能力集与 bounding set 清空只带账户自己的附加组(systemd-run --scope --uid 会保留调用者即 root 的 group 0,故不用它)
文件系统bubblewrap:宿主根只读、/tmp /var/tmp /run 与 workspace base 为空 tmpfs仅本人 home 与 <base>/shared 被绑回;其他用户 home 不可见(不只是拒绝访问)
网络bubblewrap --unshare-net(仅 loopback) + 宿主侧出口代理唯一出路是代理;代理只放行 host:port 白名单(webui_callback_url 的主机:端口 + isolation.egress_allow,只取服务端配置,绝不取请求 Host 头),其它一律 403;判定记入 /var/log/openace-webui/<uid>.egress.log(root 所有 0600,由 root 打开后把描述符交给宿主侧进程——沙箱内进程无法打开、替换或截断它;同一账户在宿主上的其它进程理论上可附着到持有描述符的宿主侧进程,不在防护范围内)。日志有界且可能被汇总:ALLOW/FAIL 判定从不丢弃(超出每秒 50 行或其单独的字节上限时,按所匹配的白名单条目汇总计数写出——条目集合有限;主机在匹配与记录前统一规范化,含空白/控制字符的主机及非短大写方法按 BAD 拒绝),DENY/BAD 另有每秒 50 行与每次启动 8 MiB 的上限(拒绝洪泛无法掩盖放行记录);每个客户端字段截断到 256 字符,超过 8 MiB 的旧日志在启动时轮转为 .1
入口反向隧道沙箱内只向宿主侧 socket 主动外连(保持少量空闲隧道,浏览器连接到来时配对);socket 目录以只读方式绑入沙箱,宿主侧从不跟随沙箱可写的路径;同时处理的浏览器连接数取 TasksMax / 8(上限 256),单个来源地址至多占一半;双向静默 300 秒的连接会被关闭——匿名客户端不能耗尽 scope 的任务配额

配置项(config.json 的 workspace):

键默认说明
limits.memory4Gsystemd MemoryMax(容器类 backend:--memory)
limits.cpu_percent200百分比,CPUQuota(容器类 backend:--cpus)
limits.tasks512TasksMax(容器类 backend:--pids-limit / --ulimit nproc)
egress_allow[]额外放行的 host:port(支持 *.domain:port、[ipv6]:port)

webui_callback_url 为必填:未设置时 API 地址会取自请求的 Host 头(用户可控), 白名单就会被用户改写,因此探针报告 confinement_callback_url_missing。

账户限制:目标账户须 uid ≥ 1000,且不得属于特权组(sudo/wheel/admin/adm/ shadow/disk/docker/lxd/incus/incus-admin/libvirt/kvm/systemd-journal/ staff/lpadmin 或 gid 0;名单式,站点特有的特权组请用 denied_groups 追加)—— 沙箱内的文件访问仍按真实附加组判定,这些组会被带进沙箱。策略文件可用 denied_groups 追加组名、用 bases 限定允许的 workspace base(如 ["/home"])。 WebUI 可执行文件(解析符号链接后)、它所在的整个 npm node_modules 树(也包含它启动的 qwen CLI),以及策略 path 中的每个目录都须为 root 所有且非组/其他人可写,否则拒绝启动 (#!/usr/bin/env node 通过该 PATH 找 node)——即 npm 全局前缀必须归 root。

部署要求(包安装形态;Docker 形态无 systemd,启用后按下列原因码 fail closed):

  • Linux + systemd(cgroup v2)、bubblewrap ≥ 0.8(需要 --disable-userns)、setpriv(util-linux);
  • 非特权 user namespace 可用。Ubuntu 24.04+ 的 AppArmor 默认限制它:加载发行版自带的 bwrap-userns-restrict profile(与 Codex/Claude Code 的要求相同);
  • installer 安装 /usr/local/bin/openace-webui-confine、根属主策略文件 /etc/openace/webui-confine.json(列出允许以用户身份启动的 WebUI 可执行文件与沙箱内 PATH),以及 sudoers 规则 <service> ALL=(root) NOPASSWD: /usr/local/bin/openace-webui-confine launch *;
  • WebUI 运行时须遵循 HTTP(S)_PROXY:Node 22.21+ 在 NODE_USE_ENV_PROXY=1(wrapper 已设置)下对 fetch 生效;不遵循代理的请求没有网络(fail closed)。

fail-closed:配置了约束但宿主不满足时,启动路径探针报告 launch_path_degraded, 括注下列原因码之一,不会静默以未约束方式启动: confinement_platform_unsupported、confinement_wrapper_missing、 confinement_bwrap_missing、confinement_setpriv_missing、 confinement_systemd_unavailable、confinement_userns_unavailable、 confinement_bwrap_too_old、confinement_policy_invalid、confinement_callback_url_missing、 confinement_check_failed。 dev 目录模式与"服务账户即目标账户"两种无法约束的启动形态在启动时直接拒绝。冷 worker(manager 未初始化、 探针未跑)只报告普通 os_user 维度。

诚实声明:

  • 威胁模型:约束保护的是"沙箱内的 WebUI/agent 与其它本地用户",不防御被攻破的 服务账户——它仍持有 sudoers 中的其它权限(以任意用户运行 git 等)。启用约束后重跑 installer,会去掉可启动未约束 WebUI 的 openace-webui-launch 规则(纵深防御)。
  • kernel 仍 unsupported:与宿主共享内核;需要内核隔离请用 sandboxed 等级。
  • WebUI 的 --token-secret 仍在其命令行上(qwen-code-webui 只接受命令行参数,既有行为)。
  • 出口代理按客户端给出的主机名与端口判定,不检查 TLS(与 Claude Code 沙箱代理的已知 局限相同);放行宽泛域名即留下外带通道。白名单主机名解析到的地址不再二次校验。
  • 宿主侧入口转发仍监听 0.0.0.0:<port>(与未约束时的暴露面相同),依赖 WebUI token。
  • 沙箱内任意进程都能占满反向隧道池,让本用户自己的 WebUI 不可达——影响只限该用户自己的 工作区(沙箱本来就能替换自己的 WebUI),属自我拒绝服务。
  • 验收:scripts/webui_confine_acceptance.py(需一次性 Linux 主机, CONFINE_ACCEPTANCE_DISPOSABLE=1)。

5.2 Backend local-gvisor:本机 gVisor 容器(#3431 Option 2)​

不需要 Kubernetes 的 sandboxed 等级:每个用户的 WebUI 运行在本机 Docker 容器里,运行时为 gVisor(runsc)。与 §5.1 共用同一个 root 入口、宿主侧进程与出口代理,只是沙箱从 bubblewrap 换成了 gVisor 容器;身份仍是该用户的 OS 账户,home 仍是宿主上的 <base>/<account>。

维度机制
kernelgVisor 用户态内核;readiness 探针在容器内读取 /proc/version,正向确认是 gVisor
资源docker run --memory/--memory-swap/--cpus + --ulimit nproc=<tasks_max>(gVisor 内核按 uid 强制的进程数上限);--pids-limit 在 gVisor 下限制的是 sentry 的宿主线程,取 max(tasks_max, 256)
文件系统只读镜像根 + --tmpfs /tmp;只挂入本人 home、<base>/shared 与日志目录;--user <uid>:<gid> + 账户附加组(--group-add)
进程/权限--cap-drop ALL、--security-opt no-new-privileges、--init
网络--network none;出入口与 §5.1 相同:反向隧道 + 只放行白名单 host:port 的出口代理(经只读挂入的 UNIX socket,需 runsc --host-uds=open)
环境WebUI 环境经容器 stdin 传入,不出现在命令行,docker inspect 也看不到

快照:isolation_level = sandboxed、backend = local-gvisor、七个维度全部 enforced。 与 OpenSandbox pod 形态(§6)的区别是有意的:

  • 入口矩阵沿用 os_user 的那一份——home 是宿主目录,/api/fs 等入口仍然 enforced;
  • /user-url 门闸仍走 OS 账户链(system_account 映射、账户检查);
  • 启动形态仍是"每用户本地进程"形态,绝不路由到 OpenSandbox pod 启动器(pod 形态按 opensandbox:* backend 判定,而不是按等级)。

部署要求(Linux 包安装形态,单机 Docker 即可):

  1. Docker + gVisor,并注册一个专用运行时(/etc/docker/daemon.json):
    {"runtimes": {"runsc-openace": {"path": "/usr/bin/runsc", "runtimeArgs": ["--host-uds=open"]}}}
    --host-uds=open 只允许连接宿主 socket(不能创建),且只作用于使用该运行时的容器;容器能连接的 是挂入目录(home、shared、日志目录、只读 socket 目录)中已存在且权限允许的 socket——与 §5.1 的 bubblewrap 形态一致。
  2. WebUI 镜像:用 scripts/docker/webui-sandbox.Dockerfile 构建(含 node、python3 与固定版本的 qwen-code-webui),并在策略文件里按内容固定:
    {"webui": ["/usr/bin/qwen-code-webui"], "docker": "/usr/bin/docker",
    "local-gvisor": {"image": "sha256:<64 hex>", "runtime": "runsc-openace"}}
    分节以 backend 命名(#3446),docker 由各容器类 backend 共用;#3446 之前的 container 分节会被拒绝, 错误信息给出转换写法。image 必须是 name@sha256:<64 hex> 或本地镜像 ID sha256:<64 hex>(tag 可被改指,拒绝);webui 列出的是镜像内的路径;installer 重跑会保留这些分节。
  3. workspace.isolation {"level": "sandboxed", "backend": "local-gvisor"},workspace.webui_callback_url 必填(同 §5.1);可用 isolation.container_webui 改镜像内 WebUI 路径(默认 /usr/bin/qwen-code-webui)。

要求 fs.protected_symlinks=1(Debian/Ubuntu/RHEL 默认):日志目录按路径由 docker(root)挂载, 该设置阻止账户在粘滞的 /tmp 中用符号链接引导挂载源;未满足时报 confinement_symlinks_unprotected。

readiness:manager 通过 sudo -n openace-webui-confine launch --probe --backend local-gvisor(root)检查——docker CLI 为 root 所控、运行时已注册、镜像已在本地(启动时不拉取)、容器内内核为 gVisor、容器能连上只读挂入的 宿主 UNIX socket。成功结果缓存 1 小时,失败 30 秒。原因码:confinement_docker_unavailable、 confinement_runtime_missing、confinement_image_missing、confinement_kernel_unverified、 confinement_runtime_host_uds_disabled、confinement_symlinks_unprotected、confinement_policy_invalid、 confinement_check_failed (以及 §5.1 共有的 confinement_platform_unsupported、confinement_callback_url_missing、 confinement_wrapper_missing)。

生命周期:root 启动进程在容器存活期间一直作为父进程——manager 对 sudo 发 SIGTERM → 转发给 docker CLI → 容器 → WebUI 退出、--rm 删除容器;manager 升级为 SIGKILL(sudo 不转发)时,root 启动进程 发现自己被重新挂接,按 root 所有的 cid 文件中记录的 id 对该容器执行 docker rm -f;宿主侧进程退出时, root 启动进程同样删除容器。同一端口上遗留的旧容器在启动前被移除,运行目录在结束时清理。若宿主侧进程意外消失,容器内的看门狗约 30 秒后停止 WebUI,--rm 删除容器。

诚实声明:

  • 本节只覆盖 gVisor(runsc);Kata 见 §5.3。
  • 与 §5.1 相同:出口代理不检查 TLS;不防御被攻破的服务账户;宿主侧入口仍监听 0.0.0.0:<port>。
  • 镜像内容(node 版本、工具链)即 agent 可用的工具集,属产品决定。

5.3 Backend local-kata:本机 Kata 容器(#3438)​

与 §5.2 相同的本机容器形态,运行时换成 Kata Containers:每个 WebUI 跑在一台轻量虚拟机里, 拥有自己的 guest 内核。快照:isolation_level = sandboxed、backend = local-kata、 七个维度全部 enforced;入口矩阵、身份门闸、本地启动形态与 §5.2 一致。

与 §5.2 的差别:

项Kata 形态
内核隔离硬件虚拟化(KVM);guest 内核与宿主不同
出入口通道Kata guest 经 virtio-fs 看到的宿主 UNIX socket 无法连接(实测 ECONNREFUSED),因此不挂 socket 目录;入口与出口的所有连接以帧的形式复用在容器的 stdin/stdout 上(每条流独立的信用窗口,慢流不阻塞通道)。容器仍是 --network none,不建网桥、不写防火墙规则。宿主只打开 INGRESS 流、容器只打开 EGRESS 流,容器同时打开的流(含仍在运行的出口连接)有上限;任何违反帧协议的行为都会断开整个通道:宿主侧进程随即退出,root 启动进程据此删除容器(fail closed,不依赖容器配合)
看门狗宿主每 5 秒发一次心跳;容器侧 30 秒收不到任何帧、或 stdin 结束,即停止 WebUI
任务上限--pids-limit 与 --ulimit nproc 都作用在 guest 内,取 tasks_max(无 gVisor 的 256 下限)

部署要求:

  1. 有 /dev/kvm 的 Linux 主机(物理机,或开启嵌套虚拟化的虚拟机)。
  2. Kata ≥ 3.32.0:Docker 29 会在 OCI spec 里带上 time namespace,更早的 Kata 报 invalid namespace type(kata-containers#13082 修复,首发于 3.32.0)。
  3. 运行时:把 containerd-shim-kata-v2 以 root 所有安装到标准系统 bin 目录(如 /usr/local/bin,它也在 dockerd 的 PATH 上)即可直接用 io.containerd.kata.v2,无需修改 daemon.json 或重启 Docker;也可以在 daemon.json 注册一个名字。策略文件中有一个 local-kata 分节:
    {"webui": ["/usr/bin/qwen-code-webui"], "docker": "/usr/bin/docker",
    "local-kata": {"image": "sha256:<64 hex>", "runtime": "io.containerd.kata.v2"}}
    用 shim 名时,探针只在标准系统 bin 目录中查找 shim,并要求它为 root 所控。
  4. 嵌套虚拟化下 guest 启动较慢(三层嵌套实测约 70 秒),Kata 默认的 dial_timeout = 45 不够,需要在 /etc/kata-containers/configuration.toml 中调大(例如 180);物理机上默认值即可。
  5. workspace.isolation {"level": "sandboxed", "backend": "local-kata"},其余同 §5.2。

readiness:sudo -n openace-webui-confine launch --probe --backend local-kata(root)——除 §5.2 的检查外: /dev/kvm 存在;用 Kata 运行时启动一个探针容器,并在宿主侧正向确认:docker 报告的 State.Pid 是 hypervisor 进程(qemu-system-* / cloud-hypervisor / firecracker / stratovirt),其父进程是 为这个容器 id 启动的 containerd-shim-kata-v2(runc 下 State.Pid 是容器自己的 init 进程); guest 的 uname -r 与宿主不同;stdio 通道能往返。这弥补了 §6.4 所述 pod 形态下"Kata 只能确认不是 gVisor"的单向判定。guest 启动最多等待 180 秒,整个探针最坏约 7 分钟(manager 给 8 分钟)。新增原因码:confinement_kvm_unavailable、 confinement_channel_failed;其余同 §5.2。

诚实声明:

  • 吞吐:stdio 通道比网桥 TCP 慢。三层嵌套实测 stdio 双向各约 1.7 MB/s,同环境网桥 TCP 约 11.4 MB/s;对 WebUI 与 LLM 流式响应足够,经出口代理的大文件下载会明显变慢。这是为了不给 guest 任何网络接口、不在宿主上管理防火墙规则而做的取舍。
  • 内存:--memory 决定 guest 虚拟机的大小,Kata 自身还有额外开销(默认 guest 内存等),宿主上看到的 占用高于该值。
  • 其余同 §5.2。

6. Backend opensandbox:部署要求与诚实声明​

workspace.isolation {"level": "sandboxed", "backend": "opensandbox"} = WebUI 进程运行于 OpenSandbox pod:每用户每实例一个独立 pod、 digest-pinned 且在 image_allowlist 的专属 webui 镜像、deny-default 出口;浏览器 经控制面本地端口代理访问(端口形态,无路径重写假设);身份为 per-instance token secret(实例销毁即失效,无 OS 账户/sudo 链)。探测不检查平台——pod 在 远端集群。pod 形态只用于这个 backend(#3446):不会因配置了 webui_image 而被推断出来;探测失败时快照为 unsupported 并给出探测原因,不会回落到 OS 账户形态。

POLICY_REVISION 2026-09-12.1 的变更:sandboxed 等级与零 pod 探测入词表; 新增 kernel 维度(os_user 如实申报 unsupported——共享宿主内核);user-url 门闸增加 sandboxed 分支(按探测 reason 拒绝,不再走 OS 账户链)。

POLICY_REVISION 2026-09-12.2 的变更(全部变化):

  • T-B 闸门语义(已被 #3446 取代,backend 改为显式配置):启动形态曾是满足 max(下限, 请求) 的最强已验证 形态,因此配置了 webui_image 就会把默认启动切到 pod。自 #3446 起,启动形态只取决于 workspace.isolation.backend。
  • 探测 reason 词表变化:新增 sandbox_multi_process_unsupported(存在 另一个持有新鲜心跳的 web 进程,或心跳根不可读无法证明独苗时,拒绝申报 sandboxed——per-instance secret 与实例管理是进程内存态);移除 sandbox_proxy_token_ttl_too_short(短 TTL 不是部署缺陷:pod 寿命在 create 与每次 renew 时被钳制到 LLM 凭证失效之前,抬高 TTL 反而连带拉长 本地 webui 凭据寿命)。
  • T-M 仅 sidecar 升级 egress:network_egress 只在 sidecar attestation tier 的 /policy 实读时升级 enforced;gVisor/CNI tier 的集群级 deny-default 对照是负向验证(证明拒绝路径存在,不能证明放行生效),保持 unsupported + sandbox_runtime_egress_negative_only。
  • T-L memo 失败撤销 + 1h TTL:boot-probe memo 不再只写不读——同 tier 的 pod probe 失败即撤销该 tier 的升级(最新证据优先);memo 条目带 时间戳,超过 1h 视为过期回退 unverified。回退条件因此是"控制面重启、 同 tier probe 失败、或条目超过 1h"三者之一,不再仅重启。

6.1 维度表​

维度sandboxed 静态(配置面探测通过后)sandboxed 首 pod probe 后os_user(对照)
identityenforcedenforcedenforced
filesystemenforcedenforcedenforced
environmentenforcedenforcedenforced
processenforcedenforcedenforced
resourcesenforced(create body 恒携带 resourceLimits,默认 4Gi/2CPU 亦为边界)enforcedunsupported
kernelunsupported(sandbox_runtime_unverified)gVisor:enforced(/proc/version 正向识别);Kata:保持 unsupported(sandbox_runtime_kata_negative_only,仅负向)unsupported(共享宿主内核)
network_egressunsupported(同 kernel reason)仅 sidecar attestation tier:enforced(egress sidecar /policy 实读);gVisor/CNI tier:保持 unsupported(sandbox_runtime_egress_negative_only——CNI 默认拒绝只是负向对照,证明拒绝路径存在,不能证明放行真的生效)unsupported

静态 enforced 五维的依据是配置事实(独立 pod、镜像白名单、恒有资源边界), 不是 per-pod 验证;kernel/network_egress 只有 per-pod boot probe 能证。probe 结果为进程内 memo——控制面重启、同 tier probe 失败、或条目超过 1h 后回退 unverified,这是有意的诚实契约(T-L:失败即撤销、条目带 1h 时效——最新 证据优先,长驻进程不依赖数小时前的探测结论)。

6.2 部署要求​

  1. endpoints.<tier>.webui_image:digest-pinned 且出现在 image_allowlist。 校验发生在能力探测而非配置 parse——坏值只降级 sandboxed 等级,不影响 autonomous 任务共用的后端配置。参考构建:scripts/docker/webui-sandbox.Dockerfile (node + qwen-code-webui + 非 root uid 1000,监听 0.0.0.0:3100);生产镜像 必须自行 digest-pin 并入白名单。
  2. workspace.webui_callback_url 必须设置:探测以它为 LLM proxy 回连 URL (每请求 host 只在启动时可知,静态探测需要稳定 URL)。sidecar tier 须将 控制面主机名加入该 tier 的 egress_allow_hosts;CNI tier 须公网可达 (loopback/私网/集群内地址会被拒)。
  3. proxy token TTL 无部署前置(T-F):pod 寿命由启动器钳制——create 时 min(WebUI token TTL, 生效 proxy token TTL),每次 renew 再钳到 min(now + WebUI token TTL, token 过期时刻)(UTC;token 不可解码/为空时 钳到 now,即随失效凭证一起终止)。短 TTL 只缩短 pod 寿命,不会让 pod 活过 其 LLM 凭证;不需要为申报 sandboxed 抬高 OPENACE_PROXY_TOKEN_TTL_WEBUI_MINUTES(抬高会连带拉长本地 webui 凭据 寿命)。
  4. HTTPS:沿用外部反代的端口段映射(/webui/<port>,见 NGINX) ——sandboxed 形态的浏览器端口是本地代理端口,取自同一 port_range (默认 3100-3200)。
  5. workspace.isolation.tier 可选:指定交互 pod 落在哪个 endpoint tier,缺省用后端 default_tier。pod 的 身份是其 per-instance token,因此门闸按 sandboxed 分支放行 sandboxed 要求,不走 OS 账户链(身份映射、 sudo 启动)。
  6. webui 入口与 pod 3100 端点可达性未经真实集群端到端验证(#3379;运行期 失败以 sandbox_endpoint_unresolved 结构化浮出,不会静默挂死)。

6.3 TTL 链​

环节数值来源
WebUI token TTL24h(默认)OPENACE_WEBUI_TOKEN_TTL_SECONDS(86400)
pod create timeoutmin(WebUI token TTL, 生效 proxy token TTL)启动器 launch 时计算
pod renew 目标min(now(UTC) + WebUI token TTL, proxy token 过期时刻)——pod 至多活到其 LLM 凭证失效;token 不可解码/为空 → 钳到 now(fail-closed,不再每轮前移一个 fallback TTL);钳制点之后由空闲回收正常拆除,下次 /user-url 重建 + 快照恢复启动器 renew 钳制
空闲回收30min(默认)workspace.idle_timeout_minutes
周期快照导出 + renew默认 5min,实际随 cleanup 间隔:维护检查搭载在 cleanup 循环里,workspace.cleanup_interval_minutes(默认 5)> 5min 时实际导出间隔 = cleanup 间隔SANDBOX_MAINTENANCE_INTERVAL_SECONDS = 300(下限)+ cleanup 循环 sleep
进程心跳刷新恒定 300s——独立定时器 greenlet(worker 启动钩子随 reconcile 同一 env 门控拉起),不随 cleanup 间隔伸缩;新鲜窗口 = 2×300+60 = 660swebui_sandbox.HEARTBEAT_REFRESH_SECONDS;窗口 HEARTBEAT_FRESH_WINDOW_SECONDS
默认 proxy token TTL240min(无需为 sandboxed 抬高;pod TTL 被钳到它之下)DEFAULT_PROXY_TOKEN_TTL_MINUTES,env OPENACE_PROXY_TOKEN_TTL_WEBUI_MINUTES

token 随每次 /user-url 命中以 per-instance secret 重铸;健康检查用现行 token 经本地代理探测(通过即证明 代理→网关→pod→webui→token 校验 全链)。

6.4 诚实声明​

  • 配置面探测 ≠ per-pod 验证:静态 enforced 五维依据配置事实;kernel/ network_egress 待首个 pod boot probe;控制面重启后回退 unverified; probe 失败即撤销该 tier memo,条目 1h 过期(T-L)。
  • pod 内 Kata 的 kernel 验证仅负向(单向判定:只能排除 gVisor,不能与未隔离 runc 区分): kernel 保持 unsupported + sandbox_runtime_kata_negative_only;gVisor 正向 识别才升级 enforced。(§5.3 的本机 Kata 容器改为在宿主侧正向确认 Kata。)
  • gVisor/CNI tier 的出口验证仅负向(集群级 deny-default 对照:证明拒绝 路径存在,不能证明放行真的生效;config 层已禁止 gVisor tier 申报 sidecar): network_egress 保持 unsupported + sandbox_runtime_egress_negative_only; 仅 sidecar attestation tier 的 /policy 实读升级 enforced(T-M)。
  • 会话历史:整目录 tar 快照(存于独立根,键 webui-<user_id>.tar); 控制面 crash 丢失 ≤ 一轮导出间隔的增量(默认 5min;导出搭载 cleanup 循环, cleanup_interval_minutes 拉长时丢失窗口随之拉长);降级启动(restore 门 60s 超时、恢复未确认、或快照不可读)的实例不导出——宁可保住旧快照,也 不让空树覆盖完好快照;不可读快照的降级启动进一步不写控制面确认记录 (reconcile 与导出守卫因此都不会认它的空树);记录仅在删除确认成功后 清除,删除失败保留至下轮重试;快照上界默认 16MiB(OPENACE_WEBUI_STATE_MAX_BYTES 可调),越限跳过导出——该用户历史 冻结在最后一份完好快照,直到手工清理;无自动 GC。
  • token 401 语义(N8):沙箱实例停止后,其已发 token 在 CP URL-token 路径 401(per-instance secret 随实例销毁)。
  • 入口接线范围:terminal/vscode/fs 未接线到沙箱形态 (sandboxed_entry_not_wired);/fs host 树对 sandboxed 用户不可用(文件 在 pod 内,webui 自带 in-pod 文件浏览)。
  • 外部假设:网关凭据注入头由 pod 内自捕获的代理端到端持有——"代理为哑 管道、token 在 pod 内 webui 校验"包含对 webui 行为的假设;3100 端点可达性 与 entrypoint 上游约束为外部假设,真实集群验证归 #3379。
  • 多副本与孤儿回收(T-D,round 2 修订):孤儿回收按进程 generation 判别, 且销毁前检查控制面心跳文件 (<CONFIG_DIR>/webui-agent-state/webui-heartbeat-<process-generation>.json, 每 web 进程一个;文件名与自我排除均按每进程 uuid4 generation——同节点 容器共享宿主 boot_id 且 pid 常碰撞,(boot_id, pid) 身份会让单节点 k3s/kind 副本互认为"自己");刷新由独立 300s 定时器承担(恒定周期, 与 cleanup 间隔解耦,无 manager 实例的副本也会刷新);进程退出时删除自己的 心跳文件(gunicorn worker_exit 钩子 + atexit,幂等 fail-soft)——单进程 部署的重启窗口因此趋零,但 SIGKILL/崩溃路径无法清理,残留心跳至多一个 新鲜窗口(≤ 660s)后自然过期,期间 opensandbox backend 报告为 unsupported,启动一律被拒绝。 存在其它新鲜心跳时跳过清扫。reconcile 互杀已消除(3 副本/滚动重叠下 新副本不再清扫其它副本的在线 pod)。心跳读取 fail-closed:心跳根或 心跳文件不可读 = "无法证明独苗",按"存在 peer"处理(探测拒绝 sandboxed、 清扫跳过),写入侧仍 fail-soft。但 pod 归属仍是单进程内存态——多副本下 的 token 校验与实例管理(回收、快照导出)仍不支持,需要单副本或多副本感知 的重构(§8)。
  • pause/resume/warm pool 不适用于 webui pod;gunicorn 形态正常停机经 worker_exit 钩子清理心跳文件(dev/非 gunicorn 形态由 atexit 兜底; SIGKILL 无法覆盖——残留心跳至多一个新鲜窗口后过期),实例侧正常停机仍尽力 导出,异常退出由下次启动的孤儿 reconcile 兜底。

7. 接入方用法​

查询能力:

curl -H "Authorization: Bearer <token>" \
https://<open-ace>/api/workspace/isolation-capabilities
  • 端点为只读构造:不创建 WebUI manager(即不铸造 token secret、不启动清理 greenlet);manager 尚未存在时快照按磁盘配置推导,无法验证启动路径(该限制 体现在 launch_path_degraded 只在 manager 活跃时可能出现)。
  • 鉴权:任意已认证用户(session cookie 或 Bearer)。契约不含机密;WebUI-token iframe 调用方不在此端点服务范围内(iframe 流程使用各自的 per-resource token)。

安全准入示例(#3410)。下面这段判定与单元测试 TestDocumentedAdmissionPredicate(tests/unit/test_workspace_isolation_contract_3374.py, 它直接执行本文中英两个版本里的这段代码)逐项一致——改一处必须改另一处,否则文档会随版本漂移:

curl -s -H "Authorization: Bearer <token>" \
https://<open-ace>/api/workspace/isolation-capabilities > caps.json
python3 - caps.json <<'PY'
import json, sys
c = json.load(open(sys.argv[1]))
NEEDED = ("webui", "filesystem_api", "session_history") # 本地工作台入口
KNOWN = {"enforced", "partial", "remote_machine_scope",
"separate_contract", "sandboxed_entry_not_wired", "disabled"}
REVIEWED = {"2026-09-26.3"} # 你已评审过的 revision
d = c.get("entry_point_details", {})
ok = (
c.get("local_workspace_multi_user") == "supported"
and c.get("isolation_level") == "os_user"
and c.get("policy_revision") in REVIEWED
and {"identity", "filesystem", "environment", "process"} <= set(c.get("enforced", []))
and all(s in KNOWN for s in c.get("entry_points", {}).values()) # 未知状态 -> 拒绝
and all(
d.get(e, {}).get("status") == "enforced"
and d.get(e, {}).get("covered_by_isolation_level")
and not d.get(e, {}).get("limitations") # residuals 不参与判定
for e in NEEDED
)
)
print("ACCEPT" if ok else "REJECT")
PY

要点:

  • 只钉你已评审过的 policy_revision;未知 revision 与未知入口 status 一律拒绝。
  • 判定用 limitations 是否为空,不要用 residuals——后者是已知且被接受的性质 (如共享项目根按设计跨用户),把它当缺口会把所有健康部署也拒掉。
  • terminal / vscode 是 remote_machine_scope:它们由机器 ACL 治理,不在本地 隔离等级的覆盖面内。需要"仅本地工作台、禁远端机器"的产品形态,做法是不授予 machine assignment,而不是要求这两个入口变成 enforced——契约报告的是机制, 不是你的授权决定(服务端入口开关见 §8 第 7 条)。

带隔离要求启动工作区(能力不足时得到结构化 400,而非静默弱启动):

curl -H "Authorization: Bearer <token>" \
"https://<open-ace>/api/workspace/user-url?required_isolation=os_user"

隔离要求是服务端的:config.json 中的 workspace.isolation.level 就是每一次启动的下限(#3446)。安装脚本与 Docker entrypoint 会把它和 backend 一起写入。不存在派生下限:启动路径因运维动作退化(重跑 installer 冲掉 wrapper、webui_path 改指 dev checkout、sudo 被移除)时,启动会以 isolation_level_unsupported 被拒绝, 直到修复为止,并打 WARNING 日志;绝不会改用共享账户服务。

required_isolation 请求参数只能抬高要求,不能降低;空值/空白参数视为缺省。登录时的后台预启动 (prestart)与工作区目录供给走同一评估——门闸会拒绝的启动/供给不会发生。

  • 成功:响应含 url/token/system_account 与 isolation(部署已验证姿态 快照;服务端下限保证默认路径同样经过门闸,回显与实际启动一致)。
  • 失败:success:false + error_code(§3.2)+ reasons(部署级与请求级两个 命名空间并存)+ isolation。
  • backend shared(下限 none)下不带参数的调用保持既有行为。
  • 行为变化:多用户模式下 system_account 为空的用户,默认路径(无参数) 也会得到 identity_mapping_missing 400——登录不再自动回填 username 约定, 需管理员显式设置映射;缓存实例的启动账户与当前映射不符时会自动重启到新账户。

探针取舍说明:supports_per_user_launch/per_user_launch_readiness 以 probe-only 方式解析 WebUI 可执行文件(绝不触发 npm build);就绪结果 (含降级态)在进程内按 30 秒 TTL 双向记忆化(以检查结束时刻计时),成功解析额外永久缓存。sudo 路径的真实前置是 openace-webui-launch 包装器已安装且可执行(sudoers 规则 本身无法廉价验证);目标系统账户拒绝 uid 0 与保留段(uid < 1000)。

8. 已知缺口与后续路线​

以下缺口已在审计中确认,按 issue #3374 的指示拆分为独立后续工作,本契约的 entry_points 矩阵如实反映:

  1. 终端/VSCode 令牌与用户绑定、终端 work_dir 服务端校验、 /fs/browse、/fs/check-path 补 home 子树锁 — 已于 #3376 / PR #3380 关闭。 这三条在 2026-09-13 合入后,矩阵与本节文字一直未更新,导致接入方把已修好的缺口 当作现存风险(#3410 的起因之一)。当前边界以 §4 与 entry_point_details 为准。
  2. 文件接口跨用户符号链接写入 — 已于 #3410 关闭(见 §4 与 CHANGELOG)。 同批关闭的还有:download/delete-file/search 在 root 进程下"先校验、再按路径操作" 的竞态窗口、包安装形态 openace-rm 以 root 执行的删除、create-directory 缺失的 home 子树锁、多 base 部署下 /api/fs 单文件路径与 /api/fs/home 的 全线 400、<base>/<account> 0755。
  3. (编号保留,原条目已关闭)
  4. WebUI token_secret 未持久化时重启导致已发 token 失效的加固。
  5. 交互工作区 sandboxed 等级:#3378 已交付(§6),不依赖 Kubernetes 的形态由 #3431/#3438 交付 (§5.2、§5.3);遗留 follow-up:真实集群 端到端验收(#3379,含 3100 端点可达性验证)、terminal/vscode/fs 入口 沙箱接线、webui 历史 quota/GC、多 web 副本下的 token 校验/实例管理 (reconcile 已心跳互斥,但 pod 归属仍是单进程内存态)。
  6. 多用户模式真实 Linux 端到端隔离验收(并发用户 + 越权尝试矩阵)——已由 #3379 交付基线;#3410 追加了上传符号链接矩阵、跨用户 create-directory、 <base>/<account> 0700 与"登录供给不跟随 .qwen 符号链接"探针 (scripts/multiuser_acceptance.py item b)。
  7. 入口禁用开关(kill switch)缺失:契约保留 disabled 状态,但当前没有任何 部署形态会输出它——服务端尚无"强制关闭 terminal/vscode/filesystem_api 入口" 的配置项(单元测试 test_no_snapshot_emits_the_reserved_disabled_status 钉住 这一点)。需要"仅本地工作台"形态的部署,目前通过不授予远端机器(machine assignment)实现,并由 entry_point_details[*].scope 让接入方机器可判。
  8. 入口矩阵与代码的 conformance 绑定:entry_points/entry_point_details 是 随 policy_revision 版本化的静态审计结论,尚无自动检查把每个操作的声明与其实现 绑定;声明与实现的一致性目前由评审与 tests/ 中的对应用例保证。
  9. 统一配置词汇:已在 #3446 完成(workspace.isolation {level, backend},快照与 root 策略文件使用同一套 名称,启动时按安装方式校验)。后续:openace isolation check 命令与安装脚本的 --isolation 选项。