Two bwrap failures found by live host tasks, both in the AppArmor profile (not in codex or the worker): 1. deny capability sys_admin (etc.) breaks bwrap at userns creation: codex-host with the deny list failed, the same profile with capability, allowed it. codexsvc holds no host capabilities, so the allow only covers fake-root caps inside bwrap's own user namespace (scoped, harmless to the host); network/ptrace/file denies still hold independently. 2. bwrap brings up loopback in its new net namespace via a NETLINK_ROUTE socket: without network netlink raw, codex workspace-write fails with 'Failed to bind NETLINK_ROUTE socket' and no tool runs. And a bare 'deny network raw' overrides the netlink allow (deny wins over allow), so the raw deny must name inet/inet6 explicitly (verified by an isolated netlink bind test under each profile variant). README correction in the same change: codex workspace-write does NOT isolate /tmp -- an agent /tmp write lands on host /tmp (verified). The verified boundary is: own worktree writable, everything else read-only, /tmp shared.
116 lines
4.7 KiB
Python
116 lines
4.7 KiB
Python
"""宿主级沙箱路由单测:严格 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)
|
||
# bwrap 还需要 userns 内的 capability(实测:deny
|
||
# capability sys_admin 会让 bwrap 在 userns 创建处失败,
|
||
# 而 capability, 允许则成功;codexsvc 本身无宿主
|
||
# capability,所以这里允许的只是 userns 内的假 root
|
||
# capability,不授予宿主特权)。
|
||
self.assertIn("capability,", text)
|
||
self.assertNotIn("deny capability sys_admin,", text)
|
||
# bwrap 还需要 netlink(在新网络命名空间里拉起 loopback;
|
||
# 缺了会报 Failed to bind NETLINK_ROUTE socket,且任何
|
||
# 工具都跑不起来)。注意 AppArmor 里 deny 优先于 allow,
|
||
# 所以 raw 的 deny 必须写明 inet/inet6,不能写裸的
|
||
# deny network raw(那样会连 netlink raw 一起挡掉,
|
||
# 已用隔离 netlink bind 实测验证)。
|
||
self.assertIn("network netlink raw,", text)
|
||
self.assertIn("deny network inet raw,", text)
|
||
self.assertNotIn("deny network raw,", text)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|