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:
1 parent
b74deffb26
commit
03a98124db
5 files changed
+457
-26
No files matched your search
+67
-14
@@ -369,13 +369,24 @@ class DockerExecutor(Executor):
|
||||
"Missing environment variable: %s" % self.env_key)
|
||||
|
||||
# -- 命令构造 ------------------------------------------------------
|
||||
def build_run_command(self, task, inner_cmd):
|
||||
def build_run_command(self, task, inner_cmd, extra_mounts=None,
|
||||
network=None):
|
||||
"""构造完整的 docker run 命令(纯函数,可单测断言)。
|
||||
|
||||
顺序固定:docker run --rm --name <容器名> --user <用户>
|
||||
--cpus <核数> --memory <内存> -v <worktree>:/work
|
||||
-v <代码目录>:/app:ro -w /work [-e <env_key名>]
|
||||
<镜像> <容器内命令...>。-e 只传名字,不带 =值。
|
||||
--cpus <核数> --memory <内存> [--cap-drop=ALL
|
||||
--security-opt=no-new-privileges] [--network <网络>]
|
||||
-v <worktree>:/work -v <代码目录>:/app:ro -w /work
|
||||
[-e <env_key名>] <镜像> <容器内命令...>。-e 只传名字,
|
||||
不带 =值。
|
||||
extra_mounts 给定时替换构造时的静态挂载(多租户隔离
|
||||
用:worker 按项目算出挂载后每次调用传入);不给时用
|
||||
静态挂载。network 给定时容器进该 Docker 网络(多租户
|
||||
用项目独立网络,容器之间默认 bridge 上互相可见的问题
|
||||
由此解决);不给时用 docker 默认网络。
|
||||
降权:容器默认已是非 root 用户,再加 --cap-drop=ALL
|
||||
与 no-new-privileges(提权类工具调用会被挡下,codex
|
||||
的常规文件/git 操作不受影响,见多租户实测)。
|
||||
"""
|
||||
self._check_docker()
|
||||
self._check_key()
|
||||
@@ -384,13 +395,19 @@ class DockerExecutor(Executor):
|
||||
"--user", self.user,
|
||||
"--cpus", self.cpus,
|
||||
"--memory", self.memory,
|
||||
"-v", "%s:/work" % self._worktree_for(task),
|
||||
"-v", "%s:/app:ro" % self._app_dir(),
|
||||
"-w", "/work"]
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges"]
|
||||
if network:
|
||||
cmd += ["--network", network]
|
||||
cmd += ["-v", "%s:/work" % self._worktree_for(task),
|
||||
"-v", "%s:/app:ro" % self._app_dir(),
|
||||
"-w", "/work"]
|
||||
# 额外挂载放在基础挂载之后:/app/work 这类子路径挂载会
|
||||
# 覆盖 /app:ro 的对应子路径,让容器内 host.py 能写
|
||||
# work/ 和 host-state.json,同时代码主体仍只读。
|
||||
for host_p, cont_p, mode in self.extra_mounts:
|
||||
mounts = self.extra_mounts if extra_mounts is None \
|
||||
else self._parse_extra_mounts(extra_mounts)
|
||||
for host_p, cont_p, mode in mounts:
|
||||
cmd += ["-v", "%s:%s:%s" % (host_p, cont_p, mode)]
|
||||
if self.env_key:
|
||||
cmd += ["-e", self.env_key]
|
||||
@@ -500,18 +517,22 @@ class DockerExecutor(Executor):
|
||||
continue
|
||||
return ok
|
||||
|
||||
def _run_in_container(self, task, inner_cmd, timeout=None):
|
||||
def _run_in_container(self, task, inner_cmd, timeout=None,
|
||||
extra_mounts=None, network=None):
|
||||
"""在容器里跑一轮内层命令,返回 LocalExecutor 同款结果 dict。
|
||||
|
||||
超时:runner 抛 subprocess.TimeoutExpired(或同名异常)
|
||||
时先 kill+rm 容器,再返回 failed(stdout 截断 + timeout
|
||||
说明,returncode 为 None);其它 runner 异常也如实返回
|
||||
failed,不编造成功。timeout 只影响本次调用,不改实例。
|
||||
extra_mounts/network 只影响本次调用(多租户按项目传入)。
|
||||
"""
|
||||
task_id = task.get("id") if isinstance(task, dict) else None
|
||||
if task_id is None:
|
||||
raise ValueError("任务缺少 id")
|
||||
cmd = self.build_run_command(task, inner_cmd)
|
||||
cmd = self.build_run_command(task, inner_cmd,
|
||||
extra_mounts=extra_mounts,
|
||||
network=network)
|
||||
try:
|
||||
result = self._call_runner(cmd, timeout=timeout)
|
||||
except Exception as exc:
|
||||
@@ -536,7 +557,8 @@ class DockerExecutor(Executor):
|
||||
parsed["container"] = self.container_name(task)
|
||||
return parsed
|
||||
|
||||
def dispatch_task(self, task, timeout=None) -> dict:
|
||||
def dispatch_task(self, task, timeout=None, extra_mounts=None,
|
||||
network=None) -> dict:
|
||||
"""容器内调 host.py dispatch:建 host 任务并跑一轮 turn。"""
|
||||
if not isinstance(task, dict) or task.get("id") is None:
|
||||
raise ValueError("任务缺少 id")
|
||||
@@ -548,9 +570,12 @@ class DockerExecutor(Executor):
|
||||
repo = task.get("repo")
|
||||
if repo:
|
||||
inner += ["--repo", repo]
|
||||
return self._run_in_container(task, inner, timeout=timeout)
|
||||
return self._run_in_container(task, inner, timeout=timeout,
|
||||
extra_mounts=extra_mounts,
|
||||
network=network)
|
||||
|
||||
def run_task(self, task, timeout=None) -> dict:
|
||||
def run_task(self, task, timeout=None, extra_mounts=None,
|
||||
network=None) -> dict:
|
||||
"""容器内调 host.py run:在已有 host 任务上再跑一轮 turn。"""
|
||||
if not isinstance(task, dict) or task.get("id") is None:
|
||||
raise ValueError("任务缺少 id")
|
||||
@@ -559,7 +584,35 @@ class DockerExecutor(Executor):
|
||||
inner = self._inner_host_command(
|
||||
"run", "--task", self._host_name(task),
|
||||
"--prompt", task["prompt"], model=task.get("model"))
|
||||
return self._run_in_container(task, inner, timeout=timeout)
|
||||
return self._run_in_container(task, inner, timeout=timeout,
|
||||
extra_mounts=extra_mounts,
|
||||
network=network)
|
||||
|
||||
def ensure_network(self, name):
|
||||
"""确保 Docker 网络存在:不存在就创建,已存在就忽略。
|
||||
|
||||
返回 True 表示网络可用(创建成功或本来就存在),
|
||||
False 表示创建失败(调用方继续派发,docker run 时会
|
||||
因网络不存在而明确失败,不在这里静默降级默认网络——
|
||||
降级会让隔离悄悄失效,比失败更糟,所以这里只返回
|
||||
布尔值,是否继续由调用方按失败处理:worker 在租户
|
||||
模式下网络创建失败时直接让任务 failed,不跑容器)。
|
||||
"""
|
||||
if not name:
|
||||
return False
|
||||
try:
|
||||
result = self._call_runner(["docker", "network", "create", name],
|
||||
timeout=30)
|
||||
except Exception as exc:
|
||||
return "already exists" in str(exc)
|
||||
try:
|
||||
if getattr(result, "returncode", 1) == 0:
|
||||
return True
|
||||
text = (getattr(result, "stderr", "") or "") + \
|
||||
(getattr(result, "stdout", "") or "")
|
||||
return "already exists" in text
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def execute(self, task) -> dict:
|
||||
"""只读执行:容器内调 host.py status,不直接跑模型回合。
|
||||
|
||||
Reference in new issue
Block a user