Multi-tenant isolation for Docker tasks (per-project mounts + networks + drop caps)

Container-side isolation was project-blind: every task mounted
the shared /app/work (all worktrees + host-state), the shared
codex-home (all thread history), the shared repos dir and the
shared test-repo, and all containers shared the default bridge
network. With --tenant-isolation (Docker only; Local runs on
the host under one user and has no container boundary to
enforce), each project gets its own host dirs under
tenants_root/<project>/{work,codex-home,test-repo,<project>}:
- mounts: container paths stay identical (/app/work,
  codex-home, repos/<pid>, test-repo) so host.py config needs
  no change; only the host mount sources are per-project.
  Static --docker-extra-mounts are replaced per task (not
  appended), so a shared mount cannot leak back in.
- codex-home seeds ONLY the template's config.toml (relay
  endpoint/provider names, no secret values); thread_history
  is never copied (it holds other tasks' prompts/results).
  test-repo is a per-project copy of the fixture (worktree
  creation needs a writable repo; a shared writable fixture
  would let one tenant poison the next task's base code).
- repos: repo_url checkouts go to the tenant dir and task
  repo is set to the container path; no-repo_url tasks get
  the tenant test-repo copy instead of the shared fixture.
- network: one Docker network per project (codex-<pid>,
  created idempotently, already-exists is OK); network
  creation failure fails the task instead of silently
  falling back to the default network (a silent downgrade
  would make isolation fail quietly).
- hardening for all Docker runs: --cap-drop=ALL and
  --security-opt=no-new-privileges (non-root user was already
  the case); verified live by a good-model task, not assumed.
Honest limit, kept in README: the inner codex sandbox is
still danger-full-access (host kernel blocks user namespaces,
bwrap unusable), so the boundary is container + mounts +
network, not the inner sandbox.
Tests: 7 tenant tests (seeding incl. no-history, per-project
mounts, network sanitization, per-call override, ensure
network ok/fail). Full suites pass.
This commit is contained in:
host committed 2026-10-26 07:24:54 +08:00
1 parent b74deffb26
commit 03a98124db
5 files changed
+457 -26

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
"""多租户隔离单测。
只用临时目录与假 runner,不跑真实 Docker、不联网。
覆盖:租户目录播种(codex-home 只取 config.toml,绝不
复制 thread_history)、挂载按项目分开、网络名安全化、
执行器 per-call 挂载/网络覆盖、网络创建失败不降级。
"""
import os
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
try:
from cloud.sandbox import DockerExecutor
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.sandbox import DockerExecutor
from cloud.server import init_db
from cloud.worker import Worker
def _ok_runner_factory(seen):
def fake(cmd, timeout=None):
seen.append(list(cmd))
return SimpleNamespace(returncode=0, stdout="", stderr="")
return fake
class TenantDirsTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
# 模板 home:config.toml + 绝不能复制的 thread_history
# 与假密钥文件;模板 repo:sample.py + .git。
self.template_home = os.path.join(self.tmp.name, "tpl-home")
os.makedirs(self.template_home)
open(os.path.join(self.template_home, "config.toml"),
"w").write('[model_provider.xcmapi]\nbase_url="https://xcmapi.org/v1"\n')
open(os.path.join(self.template_home, "thread_history_1.sqlite"),
"w").write("OTHER TENANT HISTORY")
open(os.path.join(self.template_home, "auth.json"),
"w").write("SECRET")
self.template_repo = os.path.join(self.tmp.name, "tpl-repo")
os.makedirs(os.path.join(self.template_repo, ".git"))
open(os.path.join(self.template_repo, "sample.py"),
"w").write("def add(a, b):\n return a + b\n")
def tearDown(self):
self.tmp.cleanup()
def _worker(self, **kwargs):
kwargs.setdefault("env", {"XCM_API_KEY": "test-placeholder"})
kwargs.setdefault("tenants_root",
os.path.join(self.tmp.name, "tenants"))
kwargs.setdefault("tenant_template_home", self.template_home)
kwargs.setdefault("tenant_template_repo", self.template_repo)
kwargs.setdefault("tenant_isolation", True)
return Worker(self.db_path, "/fake/host.py", **kwargs)
def test_dirs_seed_config_only_not_history(self):
worker = self._worker()
dirs = worker._tenant_dirs("projA")
home = dirs["codex_home"]
self.assertTrue(os.path.exists(os.path.join(home, "config.toml")))
self.assertFalse(os.path.exists(
os.path.join(home, "thread_history_1.sqlite")))
self.assertFalse(os.path.exists(os.path.join(home, "auth.json")))
# test-repo 副本有 sample.py,且是复制不是链接。
self.assertTrue(os.path.exists(
os.path.join(dirs["test_repo"], "sample.py")))
# 第二次调用不覆盖租户自己的改动。
marker = os.path.join(dirs["test_repo"], "mine.txt")
open(marker, "w").write("mine")
worker._tenant_dirs("projA")
self.assertTrue(os.path.exists(marker))
def test_mounts_are_per_project(self):
worker = self._worker()
mounts_a = worker._tenant_mounts("projA")
mounts_b = worker._tenant_mounts("projB")
# 容器内路径相同(/app/work 等),宿主源不同项目不同。
cont_a = [m.split(":")[1] for m in mounts_a]
cont_b = [m.split(":")[1] for m in mounts_b]
self.assertEqual(cont_a, cont_b)
self.assertIn("/app/work", cont_a)
self.assertIn("/var/lib/codex-app-server/codex-home", cont_a)
hosts_a = {m.split(":")[0] for m in mounts_a}
hosts_b = {m.split(":")[0] for m in mounts_b}
self.assertFalse(hosts_a & hosts_b)
# 租户 A 的 work 里写文件,B 的挂载里看不到(宿主侧)。
open(os.path.join(worker._tenant_dirs("projA")["work"],
"secret-a.txt"), "w").write("SECRET-A")
self.assertFalse(os.path.exists(os.path.join(
worker._tenant_dirs("projB")["work"], "secret-a.txt")))
def test_network_name_sanitized(self):
worker = self._worker()
self.assertEqual(worker._tenant_network("abc123"),
"codex-abc123")
self.assertEqual(worker._tenant_network("a b/c"),
"codex-a-b-c")
with self.assertRaises(ValueError):
worker._tenant_network("...")
def test_template_home_without_config_fails_loudly(self):
empty_home = os.path.join(self.tmp.name, "empty-home")
os.makedirs(empty_home)
worker = self._worker(tenant_template_home=empty_home)
with self.assertRaises(RuntimeError):
worker._tenant_dirs("projA")
class DockerTenantCommandTestCase(unittest.TestCase):
def test_per_call_mounts_network_and_drop_caps(self):
seen = []
static_dir = tempfile.mkdtemp()
tenant_dir = tempfile.mkdtemp()
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex = DockerExecutor(
"/fake/host.py",
runner=_ok_runner_factory(seen),
env={"XCM_API_KEY": "test-placeholder"},
extra_mounts=["%s:/app/work:rw" % static_dir])
cmd = ex.build_run_command(
{"id": "t1", "worktree": "/tmp/wt"},
["echo", "hi"],
extra_mounts=["%s:/app/work:rw" % tenant_dir],
network="codex-projA")
mounts = [cmd[i + 1] for i, a in enumerate(cmd) if a == "-v"]
# per-call 挂载替换静态挂载,不是追加。
self.assertIn("%s:/app/work:rw" % tenant_dir, mounts)
self.assertNotIn("%s:/app/work:rw" % static_dir, mounts)
self.assertEqual(cmd[cmd.index("--network") + 1], "codex-projA")
self.assertIn("--cap-drop=ALL", cmd)
self.assertIn("--security-opt=no-new-privileges", cmd)
def test_ensure_network_already_exists_is_ok(self):
def exists_runner(cmd, timeout=None):
return SimpleNamespace(
returncode=1, stdout="",
stderr="Error response from daemon: network with name "
"codex-p already exists")
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex = DockerExecutor("/fake/host.py", runner=exists_runner,
env={"XCM_API_KEY": "test-placeholder"})
self.assertTrue(ex.ensure_network("codex-p"))
def test_ensure_network_real_failure_is_false(self):
def fail_runner(cmd, timeout=None):
return SimpleNamespace(returncode=1, stdout="",
stderr="daemon not reachable")
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex = DockerExecutor("/fake/host.py", runner=fail_runner,
env={"XCM_API_KEY": "test-placeholder"})
self.assertFalse(ex.ensure_network("codex-p"))
if __name__ == "__main__":
unittest.main()