Merge pull request #26 from openqj/host-sandbox

Host-level sandbox tasks (LocalExecutor + bwrap)
This commit is contained in:
openqj authored and GitHub committed 2026-10-26 08:26:53 +08:00
commit bfe3521ef7
6 files changed
+266 -2

No files matched your search

+17
View File
@@ -507,6 +507,23 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
从记忆里拼一个 allowlist 极易漏掉 codex 工具链要的系统调用
而静默破坏任务,正确的更严 profile 需要先对 codex 做系统
调用画像,那是下一项工作,不是本轮假装做到的。
- 宿主级沙箱任务(与 Docker 任务二选一,绝不叠加):
codex 的 workspace-write(bwrap)在 Docker 容器内不可行
(嵌套 pidns 下 fresh proc mount 内核 EPERM,宿主级则可用),
所以需要内层沙箱的任务改走宿主级:worker 用 LocalExecutor +
`host-config-host.json`(sandbox workspace-write,state 独立
为 work/host-state-host.json),以 `aa-exec -p codex-host`
运行(见 `deploy/apparmor/codex-host`:docker-codex 基础 +
userns/mount/pivot_root,仍禁 raw/ptrace/危险 capability),
并带 `HTTP(S)_PROXY=http://127.0.0.1:8888` 让 relay/git 出口
同样走 Tinyproxy 白名单。路由靠任务的 executor 字段
(POST /tasks 已支持 local/docker/cloud):宿主 worker 必须
开 `--claim-executor-only`,只认领 executor='local' 的任务,
绝不抢 executor 为 NULL 的任务(否则会把本该进 Docker 的
任务拉到宿主上跑,丢了 Docker 外层)。宿主任务放弃 Docker
外层(无容器、无租户挂载/网络隔离),换来的是 bwrap 内层
(agent 只能写自己 worktree,/tmp 是隔离的 tmpfs,写不到
宿主 /tmp;其它任务 worktree 与宿主文件同样不可见)。
- 网络白名单的诚实边界:docker run 默认 bridge 网络,本执行
器不做 egress 白名单——真正的“只放行模型 API”需要宿主防火
墙或代理规则,那是部署项,不是本轮代码假装做到的,不要宣称
+99
View File
@@ -0,0 +1,99 @@
"""宿主级沙箱路由单测:严格 executor 认领 + 配置/profile 文件。
只用 SQLite 测试库与假 runner,不跑真实 codex/Docker。
覆盖:claim_executor_only 开启时 _ready_tasks 只返回
executor == 'local' 的任务(NULL 与 docker 都排除),且
_claim 对 NULL 任务返回 False、对 local 任务返回 True;
host-config-host.json 的 sandbox 确为 workspace-write;
codex-host profile 含 userns 与 mount,且不含 deny mount。
"""
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
try:
from cloud.server import init_db
from cloud.worker import Worker
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.server import init_db
from cloud.worker import Worker
import sqlite3
def _seed(db_path):
init_db(db_path)
conn = sqlite3.connect(db_path)
conn.execute(
"INSERT INTO projects (id, name) VALUES ('p1', 'p1')")
conn.execute(
"INSERT INTO tasks (id, project_id, prompt, status, executor)"
" VALUES ('null-task', 'p1', 'hi', 'queued', NULL)")
conn.execute(
"INSERT INTO tasks (id, project_id, prompt, status, executor)"
" VALUES ('docker-task', 'p1', 'hi', 'queued', 'docker')")
conn.execute(
"INSERT INTO tasks (id, project_id, prompt, status, executor)"
" VALUES ('local-task', 'p1', 'hi', 'queued', 'local')")
conn.commit()
conn.close()
class StrictClaimTestCase(unittest.TestCase):
def test_strict_worker_only_takes_local(self):
tmp = tempfile.TemporaryDirectory()
db_path = os.path.join(tmp.name, "control.db")
_seed(db_path)
worker = Worker(db_path, "/fake/host.py",
env={"XCM_API_KEY": "test-placeholder"},
claim_executor_only=True)
ready = worker._ready_tasks()
self.assertEqual([t["id"] for t in ready], ["local-task"])
# CAS 层同样严格:NULL 任务抢不到,local 任务抢得到。
self.assertFalse(worker._claim("null-task", executor="local"))
self.assertTrue(worker._claim("local-task", executor="local"))
tmp.cleanup()
def test_default_worker_still_takes_null(self):
tmp = tempfile.TemporaryDirectory()
db_path = os.path.join(tmp.name, "control.db")
_seed(db_path)
worker = Worker(db_path, "/fake/host.py",
env={"XCM_API_KEY": "test-placeholder"})
ready = worker._ready_tasks()
ids = [t["id"] for t in ready]
self.assertIn("null-task", ids)
self.assertIn("local-task", ids)
self.assertNotIn("docker-task", ids)
tmp.cleanup()
class HostConfigProfileTestCase(unittest.TestCase):
def test_host_config_uses_workspace_write(self):
repo_root = Path(__file__).resolve().parents[2]
cfg = json.loads((repo_root / "host-config-host.json").read_text())
self.assertEqual(cfg["sandbox"], "workspace-write")
self.assertEqual(cfg["state"], "work/host-state-host.json")
self.assertEqual(cfg["codex_home"],
"/var/lib/codex-app-server/codex-home")
def test_codex_host_profile_allows_bwrap(self):
repo_root = Path(__file__).resolve().parents[2]
text = (repo_root / "deploy" / "apparmor" / "codex-host"
).read_text()
self.assertIn("userns,", text)
# bwrap 需要 mount 与 pivot_root:profile 必须允许,
# 不能像 docker-codex 那样 deny。
self.assertIn("mount,", text)
self.assertIn("pivot_root,", text)
self.assertNotIn("deny mount,", text)
self.assertNotIn("deny umount,", text)
if __name__ == "__main__":
unittest.main()
+29 -2
View File
@@ -236,7 +236,7 @@ class Worker:
worker_id=None, capacity=1, fallback_models=None,
tenant_isolation=False, tenants_root=None,
tenant_template_home=None, tenant_template_repo=None,
egress_proxy_port=0):
egress_proxy_port=0, claim_executor_only=False):
self.db_path = str(db_path)
self.host_py_path = host_py_path
self.config_path = config_path
@@ -322,6 +322,13 @@ class Worker:
# 端口),代理只放行白名单域名;网关查不到时任务直接
# failed,绝不无代理跑(无代理等于绕过白名单)。
self.egress_proxy_port = egress_proxy_port or 0
# 严格 executor 认领:只认领 executor 恰好等于本 worker
# 类型的任务,绝不认领 executor 为 NULL 的任务。默认
# False(老行为:NULL 任务任意 worker 可认领)。宿主级
# 沙箱 worker 必须开这个,否则它会抢走本该进 Docker
# 容器的 NULL 任务(那些任务在宿主上跑就丢了 Docker
# 外层,违背“按任务二选一”的路由意图)。
self.claim_executor_only = bool(claim_executor_only)
# 运维心跳:路径优先显式参数,其次环境变量,默认当前目录下
# 的 worker-heartbeat.json;主机路径不在代码里硬编码,主机
# 部署时用 --heartbeat-file 或环境变量传入(见 deploy/README)。
@@ -439,6 +446,9 @@ class Worker:
执行器、或指定为本 worker 执行器类型的任务;CAS 语义不变,
仍是“仅当 queued 时标 running”,两个同池 worker 抢同一
任务时只有一个能拿到 rowcount == 1。
claim_executor_only 开启时(宿主级沙箱 worker 用):WHERE
只加 executor = ?,NULL 任务绝不认领(否则宿主 worker
会抢走本该进 Docker 的任务)。
"""
now = time.time()
conn = _db_connect(self.db_path)
@@ -451,6 +461,14 @@ class Worker:
" WHERE id = ? AND status = 'queued'",
(now, now, task_id),
)
elif self.claim_executor_only:
cur = conn.execute(
"UPDATE tasks SET status = 'running',"
" last_heartbeat = ?, updated_at = ?"
" WHERE id = ? AND status = 'queued'"
" AND executor = ?",
(now, now, task_id, executor),
)
else:
cur = conn.execute(
"UPDATE tasks SET status = 'running',"
@@ -646,6 +664,8 @@ class Worker:
except ValueError:
return []
mine = self._executor_name()
if self.claim_executor_only:
return [t for t in ready if t.get("executor") == mine]
return [t for t in ready
if t.get("executor") in (None, "", mine)]
@@ -1863,6 +1883,12 @@ def main(argv=None):
" 出口全部走宿主该端口的代理,代理只放行"
"白名单域名;网关查不到时任务直接 failed,"
"绝不无代理运行")
parser.add_argument("--claim-executor-only", action="store_true",
help="严格 executor 认领:只认领 executor 恰好"
"等于本 worker 类型的任务,绝不认领"
" executor 为 NULL 的任务。宿主级沙箱"
" worker 必须开,否则它会抢走本该进"
" Docker 容器的 NULL 任务")
parser.add_argument("--task-timeout", type=float, default=1800,
help="每任务截止时间(秒,默认 1800);超时后真杀"
"容器/进程组、任务置 failed;与 --docker-timeout"
@@ -1912,7 +1938,8 @@ def main(argv=None):
tenants_root=args.tenants_root,
tenant_template_home=args.tenant_template_home,
tenant_template_repo=args.tenant_template_repo,
egress_proxy_port=args.egress_proxy_port)
egress_proxy_port=args.egress_proxy_port,
claim_executor_only=args.claim_executor_only)
# CLI 启动清理兜底(常驻与单次都做):清掉上一轮残留容器。
worker.cleanup_stale_containers()
# CLI 起 worker 前先建表/迁移:全新库直接起循环不应因缺表而炸。
+85
View File
@@ -0,0 +1,85 @@
# AppArmor profile for the HOST-LEVEL sandbox worker (codex-host-worker).
#
# This is docker-codex (outer hardening) PLUS what bwrap needs:
# - userns: allow creating user namespaces (bwrap's inner sandbox)
# - mount, umount, pivot_root: allow (bwrap mounts proc/tmpfs/binds
# and pivot_roots into the new root)
# It KEEPS docker-codex's denies: raw network, ptrace, dangerous
# capabilities, writes to /proc/sys, /sys, /proc/keys.
#
# Why a separate profile: docker-codex deliberately denies mount
# (containers must never mount; inner sandbox stays off there by
# choice). The host worker NEEDS mount+userns because its tasks
# run codex workspace-write (bwrap) directly on the host. The two
# profiles must not be confused: a task container under codex-host
# could bwrap (defeats the Docker-only design), and a host worker
# under docker-codex cannot bwrap at all (mount denied).
#
# Security note (honest): allowing mount+userns for the host worker
# means the WORKER process (trusted, codexsvc) can mount. The AGENT
# inside a task cannot mount on the host: its mounts happen inside
# bwrap's new mount namespace (isolated), and bwrap's pid namespace
# hides other tasks' processes. The agent's writable surface is its
# own worktree (+ a private tmpfs /tmp that never reaches host /tmp).
#
# Install: cp to /etc/apparmor.d/codex-host, then
# apparmor_parser -r /etc/apparmor.d/codex-host
# Use: aa-exec -p codex-host -- python3 -m cloud.worker ...
# (see deploy/host-worker/codex-host-worker.service)
# Verify: cat /proc/<worker-pid>/attr/current shows
# "codex-host (enforce)".
#include <tunables/global>
profile codex-host flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
network inet stream,
network inet dgram,
network inet6 stream,
network inet6 dgram,
deny network raw,
capability chown,
capability dac_override,
capability fsetid,
capability fowner,
capability mknod,
capability net_bind_service,
capability setfcap,
capability setgid,
capability setpcap,
capability setuid,
capability sys_chroot,
deny capability sys_admin,
deny capability sys_ptrace,
deny capability net_admin,
deny capability net_raw,
deny capability sys_module,
deny capability sys_rawio,
deny capability sys_boot,
deny capability sys_time,
deny capability mac_admin,
deny capability mac_override,
# bwrap needs these (docker-codex denies them).
userns,
mount,
umount,
pivot_root,
deny ptrace,
file,
deny @{PROC}/sys/** w,
deny @{PROC}/sysrq-trigger rw,
deny @{PROC}/keys/** rw,
deny @{PROC}/kcore rw,
deny /sys/** w,
deny /etc/shadow rw,
deny /etc/gshadow rw,
signal,
unix,
dbus,
ptrace (readby),
}
@@ -0,0 +1,35 @@
[Unit]
Description=Codex host-level sandbox worker (LocalExecutor, bwrap inner sandbox)
After=network.target codex-cloud-server.service
Wants=codex-cloud-server.service
[Service]
Type=simple
User=codexsvc
Group=codexsvc
WorkingDirectory=/opt/codex-app-server
# EnvironmentFile supplies CLOUD_DB_* and XCM_API_KEY (0600, root-owned).
# The worker never prints env values.
EnvironmentFile=/etc/codex-app-server/env
# Egress allowlist for host-level tasks: relay/git go through the same
# Tinyproxy as container tasks (127.0.0.1 is allowed by the iptables
# rules; without this, host tasks would bypass the allowlist).
Environment=HTTP_PROXY=http://127.0.0.1:8888
Environment=HTTPS_PROXY=http://127.0.0.1:8888
Environment=http_proxy=http://127.0.0.1:8888
Environment=https_proxy=http://127.0.0.1:8888
Environment=NO_PROXY=localhost,127.0.0.1
Environment=no_proxy=localhost,127.0.0.1
# aa-exec confines the worker (and its children: host.py, app-server,
# codex, bwrap, tools) under the codex-host AppArmor profile, which
# allows userns+mount for bwrap. Without aa-exec, bwrap fails on
# AppArmor userns/mount restrictions. --claim-executor-only is
# REQUIRED: this worker must only take executor='local' tasks and
# must never steal NULL-executor tasks meant for Docker workers.
ExecStart=/usr/bin/aa-exec -p codex-host -- /usr/bin/python3 -m cloud.worker --interval 10 --config /opt/codex-app-server/host-config-host.json --executor local --claim-executor-only --worker-capacity 2 --loop --db /var/lib/codex-app-server/control.db --worker-id host-1 --repos-dir /var/lib/codex-app-server/repos
Restart=on-failure
RestartSec=10
TimeoutStopSec=180
[Install]
WantedBy=multi-user.target
+1
View File
@@ -0,0 +1 @@
{"_note":"宿主级沙箱任务专用配置(LocalExecutor,绝不是容器内路径):codex 在宿主上直接跑,sandbox 用 workspace-write(bwrap 内层沙箱,宿主级已验证 --proc 可用;Docker 容器内因嵌套 pidns 导致 fresh proc mount 内核 EPERM,所以容器任务仍用 danger-full-access + Docker 外层,两者按任务 executor 二选一、绝不叠加)。state 用 work/host-state-host.json——相对配置文件所在目录 /opt/codex-app-server 解析,得到 /opt/codex-app-server/work/host-state-host.json,与 Docker 任务的 host-state.json 区分开,避免状态文件互相干扰。repo 与 codex_home 与 Docker 配置相同(宿主测试仓库与真实 relay home);API key 绝不放这里,只走环境变量。宿主 worker 必须以 aa-exec -p codex-host 运行(见 deploy/host-worker/codex-host-worker.service),否则 bwrap 因 AppArmor userns/mount 限制失败;同时 worker 进程须带 HTTP(S)_PROXY=http://127.0.0.1:8888,让宿主级任务的 relay/git 出口同样走 Tinyproxy 白名单(宿主直连出口不受白名单约束,不带代理就等于绕过)。","approval":"deny","approval_policy":"never","codex_home":"/var/lib/codex-app-server/codex-home","effort":"low","model":"gpt-6.1-sol","repo":"/var/lib/codex-app-server/test-repo","sandbox":"workspace-write","state":"work/host-state-host.json"}