diff --git a/cloud/harness2_tasks.py b/cloud/harness2_tasks.py index 0901da6..e42c547 100644 --- a/cloud/harness2_tasks.py +++ b/cloud/harness2_tasks.py @@ -8,8 +8,8 @@ GET /tasks/{id}/trace 轨迹(探针的 trajectory 来源) GET /tasks/{id}/cost 成本(usd_micros / 1e6;无价 token 不计入 USD, 这是现有 cost 模型的边界,不是本层漏算) - GET /tasks/{id}/files 读本地任务 worktree 文件(产物资产端点; - Docker/跨机任务回 409,见下) + GET /tasks/{id}/files 读任务 worktree 文件(产物资产端点; + cloud/跨机任务回 409,见下) POST /tasks/{id}/cancel 超时取消,不留孤儿任务继续烧钱 认证:X-API-Key 请求头;密钥只由调用方持有,本模块不读、不存、 不打印密钥。 @@ -21,12 +21,12 @@ harness 下跑。prompt = 原任务 prompt + 一段交付脚注(见 _PROBE_DELIVERABLE_FOOTER),因为评审只看最终回复:agent 必须 把交付物完整写在最终回复里,不能只说“文件已写好”。 - 文件型产物走两层(产物资产):①本地任务经 GET /tasks/{id}/files - 读 worktree 新增/改动文件(端点为准);②所有执行器都可用 - 回复里的 DELIVERABLE FILE 块(agent 自报,端点结果优先)。 - 边界仍在:租户 Docker 下 /app/work 是整项目共享目录,按任务 - 归属读文件会错指,端点对 Docker 任务一律 409——这时只有第 - ②层可用,且内容以 agent 自报为准(可能截断,截断有标记)。 + 文件型产物走两层(产物资产):①经 GET /tasks/{id}/files 读 + worktree 新增/改动文件(端点为准;本地任务走本地 worktree, + 租户 Docker 任务走 tenants 下的任务级子目录);②所有执行器 + 都可用回复里的 DELIVERABLE FILE 块(agent 自报,端点结果 + 优先)。边界仍在:cloud/跨机任务端点回 409,此时只有第②层 + 可用,且内容以 agent 自报为准(可能截断,截断有标记)。 - 编辑(CodexEditor.edit):bundle = context/ 前缀的证据树(task.md / rollouts / deliverables / harness / library / pool / decisions)。 agent 在 worktree 的 context/harness/ 里改文件,最终回复里用 @@ -290,7 +290,7 @@ class TaskClient(Protocol): def get_task_files(self, task_id: str, path: str = "") -> dict: """GET /tasks/{id}/files:不传 path 列 worktree 文件,传 path - 读单个文件。服务端对 Docker/跨机任务回 409,调用方须把 + 读单个文件。服务端对 cloud/跨机任务回 409,调用方须把 409 当成“无文件通道”、不得当成探针失败。""" ... @@ -638,7 +638,7 @@ class CodexProbeAdapter: 取 seed 里没有的新文件,以及内容被改过的 seed 文件(与 seed 字节比对,不一致才算产物);跳过隐藏文件、最多收 - 8 个、最多读 16 个;端点不可用(Docker/跨机 409、读错) + 8 个、最多读 16 个;端点不可用(cloud/跨机 409、读错) 时返回空 dict——文件产物是 enrichment,绝不能因此炸轮次。 """ try: diff --git a/cloud/server.py b/cloud/server.py index 6b20cee..4490edf 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -445,6 +445,38 @@ def _thread_worktree_for_task(server, task): return os.path.join(base, safe) +def _task_worktree_for_files(server, task): + """文件端点用的任务 worktree 解析。 + + 本地任务走 _thread_worktree_for_task;租户 Docker 任务走 + tenants_root/<安全项目名>/work/host-worktrees/<安全任务名>—— + worker 在租户 work 目录下按任务建子目录(生产实查 wwww 租户 + 下 62 个任务目录皆如此,安全名规则与 worker 侧一致)。绝不 + 直接 serves 共享的 /app/work 挂载点本身,只 serves 任务级子 + 目录,所以不存在按任务错指。cloud 任务与跨机 worktree 一律 + 409;两个候选路径都没有时 404(任务未启动或目录已清理)。 + 返回 (path, None) 或 (None, (status, msg))。 + """ + if task.get("executor") == "cloud": + return None, (409, "cloud task worktrees are not on this host") + worktree_host = task.get("worktree_host") + if worktree_host and worktree_host != socket.gethostname(): + return None, (409, "task worktree is on host %s;" + " files must be read there" % worktree_host) + safe_task = re.sub(r"[^A-Za-z0-9_.-]", "-", str(task["id"])) + safe_project = _tenant_safe_project_name(task.get("project_id")) + if safe_project: + tenant_path = os.path.join( + _tenants_root_for(server), safe_project, "work", + "host-worktrees", safe_task) + if os.path.isdir(tenant_path): + return tenant_path, None + local_path = _thread_worktree_for_task(server, task) + if os.path.isdir(local_path): + return local_path, None + return None, (404, "no local worktree for this task") + + def _safe_worktree_path(worktree, rel): """worktree 内相对路径的安全解析:空串返回 worktree 本身; 绝对路径、.. 逃逸、符号链接逃逸一律返回 None。realpath 后 @@ -4753,13 +4785,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): }) def _task_files(self, user_id, task_id, query): - """GET /tasks/{id}/files:读本地任务 worktree 里的文件。 + """GET /tasks/{id}/files:读任务 worktree 里的文件。 - 只做 local 执行器任务(复用 thread 操作的执行器门): - Docker 任务的 worktree 在容器内、且租户 Docker 下 /app/work - 是整项目共享目录,按任务归属读文件会错指,一律 409 明 - 说;cloud 任务与跨机 worktree 同理。executor 为空的老 - 任务放行(与 thread 门同款),解析不到 worktree 时 404。 + 解析见 _task_worktree_for_files:本地任务走本地 worktree, + 租户 Docker 任务走 tenants 下的任务级子目录(只 serves + host-worktrees/<任务id>,绝不 serves 共享的 /app/work 本 + 身);cloud 任务与跨机 worktree 一律 409,解析不到时 404。 ?path=<相对路径>:不传列 worktree 顶层(递归、最多 200 条,只含文件路径与大小,不跟符号链接);传目录列该目录 @@ -4772,13 +4803,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): if err is not None: self._send_json(err[0], {"error": err[1]}) return - guard = self._thread_task_guard(task) - if guard is not None: - self._send_json(guard[0], {"error": guard[1]}) - return - worktree = _thread_worktree_for_task(self.server, task) - if not os.path.isdir(worktree): - self._send_json(404, {"error": "no local worktree for this task"}) + worktree, werr = _task_worktree_for_files(self.server, task) + if werr is not None: + self._send_json(werr[0], {"error": werr[1]}) return rel = (query.get("path") or [""])[0] target = _safe_worktree_path(worktree, rel) diff --git a/cloud/tests/test_server_task_files.py b/cloud/tests/test_server_task_files.py index a76890b..908a86e 100644 --- a/cloud/tests/test_server_task_files.py +++ b/cloud/tests/test_server_task_files.py @@ -37,6 +37,8 @@ class TaskFilesTestCase(unittest.TestCase): self.worktrees = os.path.join(self.tmp.name, "host-worktrees") os.makedirs(self.worktrees) self.server.host_worktrees_dir = self.worktrees + self.tenants = os.path.join(self.tmp.name, "tenants") + self.server.tenants_root = self.tenants self.port = self.server.server_address[1] self.thread = threading.Thread( target=self.server.serve_forever, daemon=True) @@ -61,6 +63,14 @@ class TaskFilesTestCase(unittest.TestCase): Path(self.tmp.name, "outside.txt").write_text("secret\n", encoding="utf-8") self._task("task-docker-1", executor="docker") + tenant_wt = os.path.join( + self.tenants, self.project["id"], "work", + "host-worktrees", "task-docker-1") + os.makedirs(tenant_wt) + Path(tenant_wt, "report.md").write_text("Total: 85.15\n", + encoding="utf-8") + self._task("task-docker-nodir", executor="docker") + self._task("task-cloud-1", executor="cloud") self._task("task-nowt-1", executor=None) def tearDown(self): @@ -150,9 +160,23 @@ class TaskFilesTestCase(unittest.TestCase): status, body = self._files("task-files-1", "big.txt") self.assertEqual(status, 413, body) - def test_docker_task_409_and_auth(self): + def test_tenant_docker_task_files(self): + # 租户 Docker 任务走 tenants 下的任务级子目录,不再 409。 status, body = self._files("task-docker-1") + self.assertEqual(status, 200, body) + self.assertEqual([e["path"] for e in body["entries"]], + ["report.md"]) + status, body = self._files("task-docker-1", "report.md") + self.assertEqual(status, 200, body) + self.assertEqual(body["content"], "Total: 85.15\n") + + def test_docker_task_without_worktree_404_and_cloud_409(self): + status, _ = self._files("task-docker-nodir") + self.assertEqual(status, 404) + status, body = self._files("task-cloud-1") self.assertEqual(status, 409, body) + + def test_auth(self): status, _ = self._req("GET", "/tasks/task-files-1/files") self.assertEqual(status, 401) status, _ = self._files("task-files-1", key=self.outsider_key) diff --git a/docs/harness2.md b/docs/harness2.md index 7407bc6..31cfdd5 100644 --- a/docs/harness2.md +++ b/docs/harness2.md @@ -35,7 +35,7 @@ benchmark 上的,不是本项目的承诺——本项目没有 benchmark,接 | Ledger(会话/探针账本)与成本 | 改写:成本汇总进 `ModeResult.total_cost_usd`,由调用方接现有 `cloud/cost.py` / 租户配额计费;另有 `budget_usd` 超支门与 `max_sessions` 会话数门(计探针 + 阶段尝试会话;edit 内 fmt2 纠正与选择/反思会话不计入):任一超支后不再调用编辑器/探针,剩余阶段回落,且该轮不入库;上游的 R=4k/P=3k 事后强校验未引(本项目结构与上游计数不完全同构,门控代替断言) | | run_tag 形状与建任务前预检 | 对照补齐:`validate_run_tag`(字符集、首字符不为 -、不以 s 数字段开头)与 `preflight_checks`(base 目录与 AGENTS.md、source 目录、提示、tag、控制面 health 探针);CLI `--check` 只预检不建任务 | | 分角色模型(SOLVER / IMPROVER / JUDGE) | 已引(机制):`run_improvement_via_tasks` 的 `role_models`(probe / editor / selector / reflector)+ CLI `--model-<角色>`;非空值覆盖该角色,缺键回落共享 `--model`,两者都空走服务端默认——默认行为不变,模型选择权仍在调用方(有价模型产生真实成本) | -| 产物资产(deliverable_files / deliverable_assets / images)与选择 assets note | 已引(两层):①服务端 `GET /tasks/{id}/files`(本地任务 worktree 读取,路径安全解析、文本 ≤256KB、列表 ≤200 条;Docker/跨机任务 409);②探针回复里的 `DELIVERABLE FILE` 块(所有执行器可用,端点结果优先、坏块丢弃不炸轮次)。产物进 `Rollout.deliverable_files`,并写入选择/编辑/反思上下文的 `deliverables/