Merge pull request #26 from openqj/host-sandbox
Host-level sandbox tasks (LocalExecutor + bwrap)
This commit is contained in:
6 files changed
+266
-2
No files matched your search
@@ -507,6 +507,23 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
|
|||||||
从记忆里拼一个 allowlist 极易漏掉 codex 工具链要的系统调用
|
从记忆里拼一个 allowlist 极易漏掉 codex 工具链要的系统调用
|
||||||
而静默破坏任务,正确的更严 profile 需要先对 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 网络,本执行
|
- 网络白名单的诚实边界:docker run 默认 bridge 网络,本执行
|
||||||
器不做 egress 白名单——真正的“只放行模型 API”需要宿主防火
|
器不做 egress 白名单——真正的“只放行模型 API”需要宿主防火
|
||||||
墙或代理规则,那是部署项,不是本轮代码假装做到的,不要宣称
|
墙或代理规则,那是部署项,不是本轮代码假装做到的,不要宣称
|
||||||
|
|||||||
@@ -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
@@ -236,7 +236,7 @@ class Worker:
|
|||||||
worker_id=None, capacity=1, fallback_models=None,
|
worker_id=None, capacity=1, fallback_models=None,
|
||||||
tenant_isolation=False, tenants_root=None,
|
tenant_isolation=False, tenants_root=None,
|
||||||
tenant_template_home=None, tenant_template_repo=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.db_path = str(db_path)
|
||||||
self.host_py_path = host_py_path
|
self.host_py_path = host_py_path
|
||||||
self.config_path = config_path
|
self.config_path = config_path
|
||||||
@@ -322,6 +322,13 @@ class Worker:
|
|||||||
# 端口),代理只放行白名单域名;网关查不到时任务直接
|
# 端口),代理只放行白名单域名;网关查不到时任务直接
|
||||||
# failed,绝不无代理跑(无代理等于绕过白名单)。
|
# failed,绝不无代理跑(无代理等于绕过白名单)。
|
||||||
self.egress_proxy_port = egress_proxy_port or 0
|
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;主机路径不在代码里硬编码,主机
|
# 的 worker-heartbeat.json;主机路径不在代码里硬编码,主机
|
||||||
# 部署时用 --heartbeat-file 或环境变量传入(见 deploy/README)。
|
# 部署时用 --heartbeat-file 或环境变量传入(见 deploy/README)。
|
||||||
@@ -439,6 +446,9 @@ class Worker:
|
|||||||
执行器、或指定为本 worker 执行器类型的任务;CAS 语义不变,
|
执行器、或指定为本 worker 执行器类型的任务;CAS 语义不变,
|
||||||
仍是“仅当 queued 时标 running”,两个同池 worker 抢同一
|
仍是“仅当 queued 时标 running”,两个同池 worker 抢同一
|
||||||
任务时只有一个能拿到 rowcount == 1。
|
任务时只有一个能拿到 rowcount == 1。
|
||||||
|
claim_executor_only 开启时(宿主级沙箱 worker 用):WHERE
|
||||||
|
只加 executor = ?,NULL 任务绝不认领(否则宿主 worker
|
||||||
|
会抢走本该进 Docker 的任务)。
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
conn = _db_connect(self.db_path)
|
conn = _db_connect(self.db_path)
|
||||||
@@ -451,6 +461,14 @@ class Worker:
|
|||||||
" WHERE id = ? AND status = 'queued'",
|
" WHERE id = ? AND status = 'queued'",
|
||||||
(now, now, task_id),
|
(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:
|
else:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"UPDATE tasks SET status = 'running',"
|
"UPDATE tasks SET status = 'running',"
|
||||||
@@ -646,6 +664,8 @@ class Worker:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return []
|
return []
|
||||||
mine = self._executor_name()
|
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
|
return [t for t in ready
|
||||||
if t.get("executor") in (None, "", mine)]
|
if t.get("executor") in (None, "", mine)]
|
||||||
|
|
||||||
@@ -1863,6 +1883,12 @@ def main(argv=None):
|
|||||||
" 出口全部走宿主该端口的代理,代理只放行"
|
" 出口全部走宿主该端口的代理,代理只放行"
|
||||||
"白名单域名;网关查不到时任务直接 failed,"
|
"白名单域名;网关查不到时任务直接 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,
|
parser.add_argument("--task-timeout", type=float, default=1800,
|
||||||
help="每任务截止时间(秒,默认 1800);超时后真杀"
|
help="每任务截止时间(秒,默认 1800);超时后真杀"
|
||||||
"容器/进程组、任务置 failed;与 --docker-timeout"
|
"容器/进程组、任务置 failed;与 --docker-timeout"
|
||||||
@@ -1912,7 +1938,8 @@ def main(argv=None):
|
|||||||
tenants_root=args.tenants_root,
|
tenants_root=args.tenants_root,
|
||||||
tenant_template_home=args.tenant_template_home,
|
tenant_template_home=args.tenant_template_home,
|
||||||
tenant_template_repo=args.tenant_template_repo,
|
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 启动清理兜底(常驻与单次都做):清掉上一轮残留容器。
|
# CLI 启动清理兜底(常驻与单次都做):清掉上一轮残留容器。
|
||||||
worker.cleanup_stale_containers()
|
worker.cleanup_stale_containers()
|
||||||
# CLI 起 worker 前先建表/迁移:全新库直接起循环不应因缺表而炸。
|
# CLI 起 worker 前先建表/迁移:全新库直接起循环不应因缺表而炸。
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
Reference in new issue
Block a user