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

+208 -11
View File
@@ -233,7 +233,9 @@ class Worker:
task_timeout=1800, max_retries=1,
retry_backoff_seconds=5.0, repos_dir="./repos",
repos_runner=None, heartbeat_file=None,
worker_id=None, capacity=1, fallback_models=None):
worker_id=None, capacity=1, fallback_models=None,
tenant_isolation=False, tenants_root=None,
tenant_template_home=None, tenant_template_repo=None):
self.db_path = str(db_path)
self.host_py_path = host_py_path
self.config_path = config_path
@@ -298,6 +300,22 @@ class Worker:
# repos_runner 可注入(单测用假 git runner),缺省走真实 git。
self.repos_dir = repos_dir
self.repos_runner = repos_runner
# 多租户隔离(仅 Docker 执行器有效,Local 执行器在本机
# 直接跑 host.py,同一用户下本来就没有容器边界,flag
# 对它不起作用):开启后每个项目用独立的宿主目录
# tenants_root/<project_id>/{work,codex-home,test-repo,
# <project_id>},容器内路径保持与原来完全一致(/app/work、
# /var/lib/.../codex-home 等),只是宿主侧的挂载源按
# 项目分开——容器内看不到其它项目的任何文件。codex-home
# 只播种 config.toml(relay 配置,不含任何密钥值),
# 绝不复制 thread_history(那是其它任务的 prompt 与结果)。
self.tenant_isolation = tenant_isolation
self.tenants_root = tenants_root or \
"/var/lib/codex-app-server/tenants"
self.tenant_template_home = tenant_template_home or \
"/var/lib/codex-app-server/codex-home"
self.tenant_template_repo = tenant_template_repo or \
"/var/lib/codex-app-server/test-repo"
# 运维心跳:路径优先显式参数,其次环境变量,默认当前目录下
# 的 worker-heartbeat.json;主机路径不在代码里硬编码,主机
# 部署时用 --heartbeat-file 或环境变量传入(见 deploy/README)。
@@ -856,6 +874,107 @@ class Worker:
"stdout": out or "", "stderr": err or "",
"returncode": proc.returncode, "host_status": None}
# -- 多租户隔离 ----------------------------------------------------
def _tenant_safe_name(self, project_id):
"""项目 id 的安全目录名:与 repos.checkout_dir 同规则。"""
if project_id is None:
raise ValueError("project_id 缺失,无法确定租户目录")
raw = str(project_id)
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", raw).strip(".")
if not safe or safe in (".", ".."):
raise ValueError("project_id 无法映射为安全的租户目录名: %r"
% raw)
return safe
def _tenant_dirs(self, project_id):
"""某项目的租户宿主目录(不存在时创建并播种)。
返回 dict:work / codex_home / repo_checkout / test_repo
(均为宿主绝对路径)。播种规则:
- codex-home:只复制模板的 config.toml(relay 端点与
provider 名,不含密钥值);模板缺 config.toml 时直接
报错——空 home 会让 codex 去 api.openai.com 且无 auth,
绝不能静默放行(见 PR #18 的教训)。
- test-repo:整目录复制模板(它是 sample.py 夹具,含
.git 历史也无妨,本来就是公开的起始代码)。
- work / repo_checkout:只建目录。
并发下两个同项目任务同时播种是安全的:mkdir 用
exist_ok,复制前判存在且复制内容相同(模板),即使
race 到重复复制也不会写坏租户自己的数据(租户数据
在复制完成后才产生)。
"""
import shutil
safe = self._tenant_safe_name(project_id)
base = os.path.join(self.tenants_root, safe)
dirs = {
"work": os.path.join(base, "work"),
"codex_home": os.path.join(base, "codex-home"),
"repo_checkout": os.path.join(base, safe),
"test_repo": os.path.join(base, "test-repo"),
}
# 注意绝不 mkdir repo_checkout:repo_url 任务的检出由
# resolve_repo 负责 clone,空目录先占位会导致它误判
# “目录已存在但不是 git 仓库”而拒绝 clone;_tenant_mounts
# 里按该目录是否存在决定是否挂载它(不存在说明任务
# 不用它,走 test-repo)。
for key in ("work", "codex_home", "test_repo"):
os.makedirs(dirs[key], exist_ok=True)
home_config = os.path.join(dirs["codex_home"], "config.toml")
if not os.path.exists(home_config):
template = os.path.join(self.tenant_template_home,
"config.toml")
if not os.path.exists(template):
raise RuntimeError(
"租户 codex-home 模板缺 config.toml:%s(空 home"
" 会让 codex 去 api.openai.com 且无 auth,拒绝"
" 派发)" % template)
shutil.copyfile(template, home_config)
if not os.path.exists(os.path.join(dirs["test_repo"], ".git")) \
and not os.path.exists(dirs["test_repo"] + "/sample.py"):
# 目标为空目录(刚 mkdir 出来的)才播种;已有内容
# 时绝不覆盖(那是该项目自己的改动或上次播种结果)。
if not os.listdir(dirs["test_repo"]):
if not os.path.exists(self.tenant_template_repo):
raise RuntimeError(
"租户 test-repo 模板不存在:%s"
% self.tenant_template_repo)
os.rmdir(dirs["test_repo"])
shutil.copytree(self.tenant_template_repo,
dirs["test_repo"])
return dirs
def _tenant_mounts(self, project_id):
"""某项目任务的 Docker 挂载列表(字符串,供执行器解析)。
容器内路径与非租户模式完全一致,只是宿主源按项目分开:
/app/work、codex-home、repos/<pid>、test-repo 全部指向
该项目的租户目录;代码目录 /app:ro 由执行器基础挂载
提供,不在这里重复。静态 --docker-extra-mounts 在租户
模式下被这份列表替换(否则共享挂载会继续泄漏)。
repos 挂载只在检出目录已存在时才加(repo_url 任务在
派发前已由 _apply_project_repo clone 好;test-repo 任务
不用它,不存在时硬挂载会被执行器的存在性检查拒绝)。
"""
dirs = self._tenant_dirs(project_id)
mounts = [
"%s:/app/work:rw" % dirs["work"],
"%s:/var/lib/codex-app-server/codex-home:rw"
% dirs["codex_home"],
"%s:/var/lib/codex-app-server/test-repo:rw"
% dirs["test_repo"],
]
if os.path.exists(dirs["repo_checkout"]):
mounts.append(
"%s:/var/lib/codex-app-server/repos/%s:rw"
% (dirs["repo_checkout"],
self._tenant_safe_name(project_id)))
return mounts
def _tenant_network(self, project_id):
"""某项目的 Docker 网络名(容器跨项目网络隔离用)。"""
return "codex-" + self._tenant_safe_name(project_id)
# -- 项目仓库 ------------------------------------------------------
def _project_repo_url(self, project_id):
"""查任务所属项目的 repo_url;查不到项目、列缺失、表缺失时
@@ -882,21 +1001,43 @@ class Worker:
def _apply_project_repo(self, task):
"""派发前按项目 repo_url 解析仓库,返回错误文本或 None。
repo_url 为空:不动 task,返回 None(走原有默认 repo)。
repo_url 为空:非租户模式不动 task,返回 None(走原有
默认 repo);租户模式则把 task["repo"] 指到该项目的
test-repo 副本(容器内路径与原来一致,宿主源已按项目
分开),否则容器会 fallback 到共享 test-repo。
repo_url 非空:调 cloud.repos.resolve_repo 得到本地检出,
设 task["repo"] 再交给执行器——LocalExecutor 与
DockerExecutor 的 dispatch_task 都已读 task["repo"],同一条
路径,不另起分支。resolve 失败返回错误文本,调用方据此把
任务置 failed(仓库解析失败绝不标 completed,也不在此重试,
避免对不可用的仓库反复 clone 烧时间)。
路径,不另起分支。租户模式下检出放在租户目录,task
["repo"] 设为容器内路径(挂载目标),而不是宿主检出
路径(worker 侧不再用 task["repo"] 当宿主路径)。
resolve 失败返回错误文本,调用方据此把任务置 failed
(仓库解析失败绝不标 completed,也不在此重试,避免对
不可用的仓库反复 clone 烧时间)。
"""
use_tenant = self.tenant_isolation and \
isinstance(self.executor, DockerExecutor)
repo_url = self._project_repo_url(task.get("project_id"))
if not repo_url:
if use_tenant:
try:
self._tenant_dirs(task.get("project_id"))
except (ValueError, RuntimeError) as exc:
return "租户目录准备失败: %s" % exc
task["repo"] = "/var/lib/codex-app-server/test-repo"
return None
try:
task["repo"] = resolve_repo(
repo_url, self.repos_dir, task.get("project_id"),
runner=self.repos_runner)
if use_tenant:
safe = self._tenant_safe_name(task.get("project_id"))
resolve_repo(
repo_url, os.path.join(self.tenants_root, safe),
task.get("project_id"), runner=self.repos_runner)
task["repo"] = \
"/var/lib/codex-app-server/repos/%s" % safe
else:
task["repo"] = resolve_repo(
repo_url, self.repos_dir, task.get("project_id"),
runner=self.repos_runner)
except (RepoError, ValueError) as exc:
return "仓库解析失败: %s" % exc
except Exception as exc:
@@ -958,8 +1099,42 @@ class Worker:
deadline_seconds=self.task_timeout)
else:
if is_docker:
tenant_mounts = None
tenant_network = None
if self.tenant_isolation:
try:
tenant_mounts = self._tenant_mounts(
task.get("project_id"))
tenant_network = self._tenant_network(
task.get("project_id"))
except (ValueError, RuntimeError) as exc:
# 提前返回会跳过下面的 finally(清容器
# 标记),这里先手动清掉,否则 _running
# 里永远残留该任务,心跳与退出清理
# 都会把它当在跑任务。
self._running_clear(task_id)
return ({"executor": "docker",
"task_id": task_id,
"status": "failed", "stdout": "",
"returncode": None, "host_status": None,
"stderr": ""},
"租户隔离准备失败: %s" % exc)
# 网络建失败绝不降级默认网络跑容器(降级会
# 让隔离悄悄失效);直接让本次尝试 failed,
# 调用方按普通失败处理(重试/fallback 照常)。
if not self.executor.ensure_network(tenant_network):
self._running_clear(task_id)
return ({"executor": "docker",
"task_id": task_id,
"status": "failed", "stdout": "",
"returncode": None, "host_status": None,
"stderr": ""},
"租户 Docker 网络创建失败: %s"
% tenant_network)
result = self.executor.dispatch_task(
task, timeout=effective_docker_timeout)
task, timeout=effective_docker_timeout,
extra_mounts=tenant_mounts,
network=tenant_network)
else:
result = self.executor.dispatch_task(task)
combined = (result.get("stdout", "") or "") + \
@@ -968,7 +1143,11 @@ class Worker:
_ALREADY_EXISTS_MARKER in combined:
if is_docker:
result = self.executor.run_task(
task, timeout=effective_docker_timeout)
task, timeout=effective_docker_timeout,
extra_mounts=tenant_mounts
if self.tenant_isolation else None,
network=tenant_network
if self.tenant_isolation else None)
else:
result = self.executor.run_task(task)
except Exception as exc: # runner 异常也如实写回 failed
@@ -1585,6 +1764,20 @@ def main(argv=None):
"仓库;Docker 执行器下它必须放在"
" --docker-extra-mounts 已挂载的目录下,"
"否则容器内 task repo 路径不存在")
parser.add_argument("--tenant-isolation", action="store_true",
help="多租户隔离(仅 Docker 执行器):每个项目用"
"独立的 tenants_root/<项目>/work、codex-home、"
"test-repo 副本与项目独立 Docker 网络,容器"
"内路径不变,宿主挂载源按项目分开")
parser.add_argument("--tenants-root",
default="/var/lib/codex-app-server/tenants")
parser.add_argument("--tenant-template-home",
default="/var/lib/codex-app-server/codex-home",
help="租户 codex-home 播种模板:只取其中的"
"config.toml,绝不复制 thread_history")
parser.add_argument("--tenant-template-repo",
default="/var/lib/codex-app-server/test-repo",
help="租户 test-repo 播种模板(整目录复制)")
parser.add_argument("--env-key", default=None)
parser.add_argument("--codex-config", default=None,
help="含 env_key 行的 codex 配置文件(只读名字)")
@@ -1665,7 +1858,11 @@ def main(argv=None):
repos_dir=args.repos_dir,
heartbeat_file=args.heartbeat_file,
worker_id=args.worker_id,
capacity=args.worker_capacity)
capacity=args.worker_capacity,
tenant_isolation=args.tenant_isolation,
tenants_root=args.tenants_root,
tenant_template_home=args.tenant_template_home,
tenant_template_repo=args.tenant_template_repo)
# CLI 启动清理兜底(常驻与单次都做):清掉上一轮残留容器。
worker.cleanup_stale_containers()
# CLI 起 worker 前先建表/迁移:全新库直接起循环不应因缺表而炸。