Docker sandbox real test + cloud merge status endpoint
- DockerExecutor: extra mounts (host.py work/state, repo, codex-home
at same paths), worker --docker-extra-mounts, container user
configurable; Dockerfile copies codex-resources beside the codex
binary and puts bwrap on PATH (previous /usr/local/lib layout
made codex report bubblewrap unavailable inside the container).
- Real Docker task on external host: triple added in ~18s, merged
inside a container, main repo now add+mul2+triple, --rm cleanup.
Honest boundary: this host/kernel forbids unprivileged user
namespaces, so bwrap fails on host and in containers; production
mode here is Docker isolation + inner danger-full-access, not
double sandboxing.
- Cloud merge status: POST /tasks/{id}/merge syncs status only
(merge itself stays with host.py merge); completed -> merged,
maintainer+ (approve_merge), other states and repeat calls 409,
audit merge_task. Tests: cloud 223, host 27, all passing.
This commit is contained in:
1 parent
631580beab
commit
07a8eaaa73
9 files changed
+1082
-37
No files matched your search
+328
-19
@@ -5,11 +5,16 @@
|
||||
- Executor:基类,子类必须实现 execute(task)。
|
||||
- LocalExecutor:先行实现,复用 host.py 现有 worktree + codex 沙箱,
|
||||
通过 host.py CLI 子进程做只读查询封装;不直接跑模型。
|
||||
- DockerExecutor:占位,未接通容器执行。
|
||||
- DockerExecutor:已接通。docker run --rm,任务 worktree 挂载到
|
||||
/work(可写)、代码目录挂载到 /app:ro(只读),--cpus/--memory
|
||||
资源限额,--user 非 root,-e 只传 env_key 名(不带值,key 缺失
|
||||
时不启动容器);超时与取消时 docker kill + rm -f,不留僵尸
|
||||
容器。网络白名单不在本类里假装实现(见 cloud/README.md)。
|
||||
- CloudExecutor:占位,未接通云端执行。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -210,31 +215,335 @@ class LocalExecutor(Executor):
|
||||
|
||||
|
||||
class DockerExecutor(Executor):
|
||||
"""容器执行器(占位,未接通)。
|
||||
"""容器执行器(已接通,命令构造可测,真实 docker 需外部主机)。
|
||||
|
||||
本机无 docker(shutil.which("docker") 为 None)时 raise
|
||||
RuntimeError("docker not available"),绝不静默降级为本地执行。
|
||||
有 docker 时也只返回占位失败,不真跑容器。
|
||||
设计(与 LocalExecutor 对齐,区别只在“进程跑在哪”):
|
||||
- 容器内跑的仍是 host.py:代码目录以只读方式挂载到 /app,
|
||||
容器内命令就是 python3 /app/host.py dispatch/run/status,
|
||||
不重写 agent 逻辑;任务自己的 worktree 挂载到 /work(可写),
|
||||
绝不挂载宿主根目录、绝不挂载任何含密钥的目录。
|
||||
- 容器名:codex-<host 任务名>,host 任务名只保留安全字符,
|
||||
同一任务每次算出同一名字,取消时才能按名杀容器。
|
||||
- key:只把 env_key 名以 `-e <名>` 传给 docker(docker 从宿主
|
||||
进程环境里取值注入容器),命令列表里绝不出现 key 的值;
|
||||
key 缺失时在调 runner 之前就明确失败,容器根本不启动。
|
||||
本类只查 env 映射里的存在性与非空,绝不打印、记录 key 的值。
|
||||
- 资源:--cpus / --memory 可配置,默认 2 核 / 2G,写进文档。
|
||||
- 清理:docker run --rm,失败也 --rm;超时或取消时先
|
||||
docker kill 再 docker rm -f,绝不留僵尸容器。
|
||||
- 网络诚实边界:docker run 默认 bridge 网络;本类不做 egress
|
||||
白名单——真正的“只放行模型 API”需要宿主防火墙/代理规则,
|
||||
那是部署项,不是本类假装做到的(见 cloud/README.md)。
|
||||
|
||||
后续接线:容器挂载(把任务 worktree 以只读/受限写挂载进容器)、
|
||||
网络隔离(默认无外网,仅放行模型 API 所需出口)、资源限额与
|
||||
容器回收,届时再替换本占位实现。
|
||||
构造函数:DockerExecutor(host_py_path=None, config_path=None,
|
||||
runner=None, image="codex-app-server:latest", cpus="2",
|
||||
memory="2g", user="1000:1000", worktrees_root=None,
|
||||
env=None, env_key="XCM_API_KEY", timeout=None)。
|
||||
host_py_path 缺省时取 cloud/ 上一级的 host.py(与 worker
|
||||
CLI 的缺省规则一致),以便 DockerExecutor() 无参调用仍可用。
|
||||
runner 默认是带 timeout 的 subprocess 封装;单测注入的假
|
||||
runner 可以只接受 cmd 一个参数(本类遇 TypeError 时回退
|
||||
到只传 cmd 调用),也可以接受 (cmd, timeout=...)。
|
||||
docker 二进制缺失时 raise RuntimeError("docker not available"),
|
||||
绝不静默降级为本地执行。
|
||||
"""
|
||||
|
||||
def execute(self, task) -> dict:
|
||||
def __init__(self, host_py_path=None, config_path=None, runner=None,
|
||||
image="codex-app-server:latest", cpus="2", memory="2g",
|
||||
user="1000:1000", worktrees_root=None, env=None,
|
||||
env_key="XCM_API_KEY", timeout=None, extra_mounts=None):
|
||||
if host_py_path is None:
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
host_py_path = os.path.join(os.path.dirname(here), "host.py")
|
||||
self.host_py_path = host_py_path
|
||||
self.config_path = config_path
|
||||
self.runner = runner if runner is not None else _docker_default_runner
|
||||
self.image = image
|
||||
self.cpus = str(cpus)
|
||||
self.memory = str(memory)
|
||||
self.user = user
|
||||
self.worktrees_root = worktrees_root
|
||||
self._env = env if env is not None else os.environ
|
||||
self.env_key = env_key
|
||||
self.timeout = timeout
|
||||
self.extra_mounts = self._parse_extra_mounts(extra_mounts)
|
||||
|
||||
@staticmethod
|
||||
def _parse_extra_mounts(extra_mounts):
|
||||
"""解析额外挂载,格式 host:container 或 host:container:ro/rw。
|
||||
|
||||
安全红线(违反时直接抛错,绝不静默放行):宿主路径必须
|
||||
是绝对路径且必须已存在(文件挂载时 docker 对不存在路径
|
||||
会自动建目录,极易把文件挂载变成目录挂载);容器路径
|
||||
必须是绝对路径,不能是 / 或 /app 本身(那会整体覆盖
|
||||
只读代码挂载);宿主路径不能是 /、/etc、/root,也不能
|
||||
在 /etc/codex-app-server 之下(那里放着 key 文件)。
|
||||
"""
|
||||
if not extra_mounts:
|
||||
return []
|
||||
if isinstance(extra_mounts, str):
|
||||
extra_mounts = [m for m in extra_mounts.split(",") if m.strip()]
|
||||
out = []
|
||||
for spec in extra_mounts:
|
||||
parts = spec.split(":")
|
||||
if len(parts) not in (2, 3):
|
||||
raise ValueError("extra mount 格式必须是 host:container[:ro|rw]: %s" % spec)
|
||||
host_p, cont_p = parts[0], parts[1]
|
||||
mode = parts[2] if len(parts) == 3 else "rw"
|
||||
if mode not in ("ro", "rw"):
|
||||
raise ValueError("extra mount 模式只能是 ro 或 rw: %s" % spec)
|
||||
if not os.path.isabs(host_p) or not os.path.isabs(cont_p):
|
||||
raise ValueError("extra mount 路径必须是绝对路径: %s" % spec)
|
||||
if host_p in ("/", "/etc", "/root") or host_p.startswith("/etc/codex-app-server"):
|
||||
raise ValueError("extra mount 拒绝危险宿主路径: %s" % spec)
|
||||
if cont_p in ("/", "/app"):
|
||||
raise ValueError("extra mount 拒绝覆盖容器根或 /app 本身: %s" % spec)
|
||||
if not os.path.exists(host_p):
|
||||
raise ValueError("extra mount 宿主路径不存在,拒绝挂载: %s" % spec)
|
||||
out.append((host_p, cont_p, mode))
|
||||
return out
|
||||
|
||||
# -- 名字 / 路径 ---------------------------------------------------
|
||||
def _host_name(self, task):
|
||||
raw = str(task.get("id"))
|
||||
return re.sub(r"[^A-Za-z0-9_.-]", "-", raw)
|
||||
|
||||
def container_name(self, task):
|
||||
"""任务派生的容器名:codex-<安全字符任务名>。"""
|
||||
return "codex-" + self._host_name(task)
|
||||
|
||||
def _app_dir(self):
|
||||
"""代码目录:host_py_path 所在的目录,挂载到 /app:ro。"""
|
||||
return os.path.dirname(os.path.abspath(self.host_py_path))
|
||||
|
||||
def _worktree_for(self, task):
|
||||
"""任务自己的 worktree:显式字段优先,否则按派生目录。
|
||||
|
||||
显式字段 worktree > cwd;都没有时用 worktrees_root/<host 名>
|
||||
(未传 worktrees_root 时默认在 host.py 同级的 work/
|
||||
docker-worktrees 下)。本方法不建目录:docker -v 对不存
|
||||
在的宿主路径会自动建目录,测试里传显式 worktree 即可
|
||||
避免任何副作用。
|
||||
"""
|
||||
if task.get("worktree"):
|
||||
return task["worktree"]
|
||||
if task.get("cwd"):
|
||||
return task["cwd"]
|
||||
root = self.worktrees_root
|
||||
if root is None:
|
||||
root = os.path.join(self._app_dir(), "work", "docker-worktrees")
|
||||
return os.path.join(root, self._host_name(task))
|
||||
|
||||
# -- 前置检查 ------------------------------------------------------
|
||||
def _check_docker(self):
|
||||
if shutil.which("docker") is None:
|
||||
raise RuntimeError("docker not available")
|
||||
|
||||
def _check_key(self):
|
||||
"""key 门:只查存在性与非空,值本身绝不返回、不打印。"""
|
||||
if not self.env_key:
|
||||
return
|
||||
try:
|
||||
value = self._env.get(self.env_key)
|
||||
except Exception:
|
||||
value = None
|
||||
if value is None or value == "":
|
||||
raise RuntimeError(
|
||||
"Missing environment variable: %s" % self.env_key)
|
||||
|
||||
# -- 命令构造 ------------------------------------------------------
|
||||
def build_run_command(self, task, inner_cmd):
|
||||
"""构造完整的 docker run 命令(纯函数,可单测断言)。
|
||||
|
||||
顺序固定:docker run --rm --name <容器名> --user <用户>
|
||||
--cpus <核数> --memory <内存> -v <worktree>:/work
|
||||
-v <代码目录>:/app:ro -w /work [-e <env_key名>]
|
||||
<镜像> <容器内命令...>。-e 只传名字,不带 =值。
|
||||
"""
|
||||
self._check_docker()
|
||||
self._check_key()
|
||||
cmd = ["docker", "run", "--rm",
|
||||
"--name", self.container_name(task),
|
||||
"--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"]
|
||||
# 额外挂载放在基础挂载之后:/app/work 这类子路径挂载会
|
||||
# 覆盖 /app:ro 的对应子路径,让容器内 host.py 能写
|
||||
# work/ 和 host-state.json,同时代码主体仍只读。
|
||||
for host_p, cont_p, mode in self.extra_mounts:
|
||||
cmd += ["-v", "%s:%s:%s" % (host_p, cont_p, mode)]
|
||||
if self.env_key:
|
||||
cmd += ["-e", self.env_key]
|
||||
cmd += [self.image]
|
||||
cmd += list(inner_cmd)
|
||||
return cmd
|
||||
|
||||
def _inner_host_command(self, *args):
|
||||
"""容器内 host.py 命令:python3 /app/host.py [args...]。
|
||||
|
||||
config_path 若已给,容器内用 /app/<basename> 指代它——
|
||||
调用方须保证配置文件就在代码目录里(host.py 配置约定
|
||||
本就放在项目目录);不在代码目录时不静默猜路径,
|
||||
由 _inner_host_command 的调用方在传 config 前自行确认。
|
||||
这里只做最简单的同名映射,不展开宿主绝对路径进容器。
|
||||
"""
|
||||
cmd = ["python3", "/app/host.py"]
|
||||
if self.config_path is not None:
|
||||
cmd += ["--config",
|
||||
"/app/" + os.path.basename(self.config_path)]
|
||||
cmd += list(args)
|
||||
return cmd
|
||||
|
||||
# -- runner 调用 / 清理 --------------------------------------------
|
||||
def _call_runner(self, cmd):
|
||||
"""调 runner:优先传 timeout,假 runner 只接受 cmd 时回退。"""
|
||||
try:
|
||||
return self.runner(cmd, timeout=self.timeout)
|
||||
except TypeError:
|
||||
return self.runner(cmd)
|
||||
|
||||
def kill_container(self, task_or_name):
|
||||
"""杀容器:docker kill + docker rm -f,尽力而为不抛异常。
|
||||
|
||||
task_or_name 可以是任务 dict(取容器名)或容器名字符串。
|
||||
返回 True 表示至少 kill 或 rm 其中一步成功执行(runner
|
||||
未抛异常),False 表示两步都失败或 docker 不存在。
|
||||
取消路径调用它,绝不只改数据库状态。
|
||||
"""
|
||||
name = (self.container_name(task_or_name)
|
||||
if isinstance(task_or_name, dict) else str(task_or_name))
|
||||
ok = False
|
||||
for cmd in (["docker", "kill", name],
|
||||
["docker", "rm", "-f", name]):
|
||||
try:
|
||||
self._call_runner(cmd)
|
||||
ok = True
|
||||
except Exception:
|
||||
continue
|
||||
return ok
|
||||
|
||||
def _run_in_container(self, task, inner_cmd):
|
||||
"""在容器里跑一轮内层命令,返回 LocalExecutor 同款结果 dict。
|
||||
|
||||
超时:runner 抛 subprocess.TimeoutExpired(或同名异常)
|
||||
时先 kill+rm 容器,再返回 failed(stdout 截断 + timeout
|
||||
说明,returncode 为 None);其它 runner 异常也如实返回
|
||||
failed,不编造成功。
|
||||
"""
|
||||
task_id = task.get("id") if isinstance(task, dict) else None
|
||||
if task_id is None:
|
||||
raise ValueError("任务缺少 id")
|
||||
if shutil.which("docker") is None:
|
||||
raise RuntimeError("docker not available")
|
||||
return {
|
||||
"executor": "docker",
|
||||
"task_id": task_id,
|
||||
"status": "failed",
|
||||
"stdout": "docker executor not implemented: "
|
||||
"container mount and network isolation pending",
|
||||
"returncode": None,
|
||||
}
|
||||
cmd = self.build_run_command(task, inner_cmd)
|
||||
try:
|
||||
result = self._call_runner(cmd)
|
||||
except Exception as exc:
|
||||
if exc.__class__.__name__ == "TimeoutExpired":
|
||||
self.kill_container(task)
|
||||
return {
|
||||
"executor": "docker", "task_id": task_id,
|
||||
"status": "failed", "stdout": "docker run timeout",
|
||||
"returncode": None, "host_status": None, "stderr": "",
|
||||
"container": self.container_name(task),
|
||||
}
|
||||
return {
|
||||
"executor": "docker", "task_id": task_id,
|
||||
"status": "failed",
|
||||
"stdout": _truncate_stdout(str(exc)),
|
||||
"returncode": None, "host_status": None, "stderr": "",
|
||||
"container": self.container_name(task),
|
||||
}
|
||||
fake = SimpleNamespaceShim(result)
|
||||
parsed = _parse_host_result(task_id, fake)
|
||||
parsed["executor"] = "docker"
|
||||
parsed["container"] = self.container_name(task)
|
||||
return parsed
|
||||
|
||||
def dispatch_task(self, task) -> dict:
|
||||
"""容器内调 host.py dispatch:建 host 任务并跑一轮 turn。"""
|
||||
if not isinstance(task, dict) or task.get("id") is None:
|
||||
raise ValueError("任务缺少 id")
|
||||
if not task.get("prompt"):
|
||||
raise ValueError("任务缺少 prompt")
|
||||
inner = self._inner_host_command(
|
||||
"dispatch", "--name", self._host_name(task),
|
||||
"--task", task["prompt"])
|
||||
repo = task.get("repo")
|
||||
if repo:
|
||||
inner += ["--repo", repo]
|
||||
return self._run_in_container(task, inner)
|
||||
|
||||
def run_task(self, task) -> dict:
|
||||
"""容器内调 host.py run:在已有 host 任务上再跑一轮 turn。"""
|
||||
if not isinstance(task, dict) or task.get("id") is None:
|
||||
raise ValueError("任务缺少 id")
|
||||
if not task.get("prompt"):
|
||||
raise ValueError("任务缺少 prompt")
|
||||
inner = self._inner_host_command(
|
||||
"run", "--task", self._host_name(task),
|
||||
"--prompt", task["prompt"])
|
||||
return self._run_in_container(task, inner)
|
||||
|
||||
def execute(self, task) -> dict:
|
||||
"""只读执行:容器内调 host.py status,不直接跑模型回合。
|
||||
|
||||
与 LocalExecutor.execute 对齐:execute 本身只发 status,
|
||||
跑模型请走 dispatch_task/run_task(worker 派发路径)。
|
||||
"""
|
||||
if not isinstance(task, dict) or task.get("id") is None:
|
||||
raise ValueError("任务缺少 id")
|
||||
inner = self._inner_host_command(
|
||||
"status", "--task", self._host_name(task))
|
||||
return self._run_in_container(task, inner)
|
||||
|
||||
|
||||
class SimpleNamespaceShim:
|
||||
"""把 runner 返回值统一成 returncode/stdout/stderr 三字段。"""
|
||||
|
||||
def __init__(self, result):
|
||||
self.returncode = getattr(result, "returncode", 1)
|
||||
self.stdout = getattr(result, "stdout", "")
|
||||
self.stderr = getattr(result, "stderr", "")
|
||||
|
||||
|
||||
def _parse_host_result(task_id, fake):
|
||||
"""LocalExecutor._result_from_runner 的无类版本,供 Docker 用。
|
||||
|
||||
host.py 成功时打印 JSON(含 status/text),失败时非 0 退出;
|
||||
returncode 非 0 一律 failed;为 0 时优先取 JSON 里的 status,
|
||||
completed 外的状态均按 failed 计。
|
||||
"""
|
||||
returncode = getattr(fake, "returncode", 1)
|
||||
stdout = _truncate_stdout(getattr(fake, "stdout", ""))
|
||||
status = "failed"
|
||||
host_status = None
|
||||
if returncode == 0:
|
||||
status = "completed"
|
||||
try:
|
||||
parsed = json.loads(stdout)
|
||||
except (TypeError, ValueError):
|
||||
parsed = None
|
||||
if isinstance(parsed, dict) and parsed.get("status"):
|
||||
host_status = parsed["status"]
|
||||
status = ("completed" if host_status == "completed"
|
||||
else "failed")
|
||||
return {
|
||||
"executor": "docker",
|
||||
"task_id": task_id,
|
||||
"status": status,
|
||||
"stdout": stdout,
|
||||
"returncode": returncode,
|
||||
"host_status": host_status,
|
||||
"stderr": _truncate_stdout(getattr(fake, "stderr", "")),
|
||||
}
|
||||
|
||||
|
||||
def _docker_default_runner(cmd, timeout=None):
|
||||
"""docker 的默认 runner:subprocess.run,支持 timeout。"""
|
||||
return subprocess.run(cmd, capture_output=True, text=True,
|
||||
timeout=timeout)
|
||||
|
||||
|
||||
class CloudExecutor(Executor):
|
||||
|
||||
Reference in new issue
Block a user