From ae3bce7b5dfe6b60294f6a7ac8448c167b34cf57 Mon Sep 17 00:00:00 2001 From: laily Date: Fri, 31 Jul 2026 23:34:13 +0800 Subject: [PATCH] feat(ack): add structured worker model routing --- docs/ack-design-review.md | 123 +- skills/ack/README.md | 61 +- skills/ack/SKILL.md | 35 +- skills/ack/VERSION | 2 +- skills/ack/examples/project.example.md | 24 +- skills/ack/examples/tasks.example.yaml | 94 +- skills/ack/references/adoption-checklist.md | 16 +- skills/ack/references/closed-loop.md | 61 +- skills/ack/references/init-new-project.md | 11 +- skills/ack/references/kickoff.md | 85 +- skills/ack/references/model-routing.md | 205 ++- skills/ack/references/orca-adapter.md | 110 +- skills/ack/scripts/launch_worker.py | 1636 +++++++++++++++++ skills/ack/scripts/validate_tasks.py | 44 + skills/ack/scripts/validate_worker_command.py | 171 +- skills/ack/scripts/worker_profiles.py | 992 ++++++++++ skills/ack/templates/project.template.md | 30 +- skills/ack/templates/tasks.schema.json | 536 +++++- skills/ack/templates/tasks.template.yaml | 74 +- tests/test_ack_launch_worker.py | 1408 ++++++++++++++ tests/test_ack_skill.py | 20 +- tests/test_ack_tasks_validation.py | 185 ++ tests/test_ack_worker_profiles.py | 895 +++++++++ 23 files changed, 6491 insertions(+), 327 deletions(-) create mode 100755 skills/ack/scripts/launch_worker.py create mode 100644 skills/ack/scripts/worker_profiles.py create mode 100644 tests/test_ack_launch_worker.py create mode 100644 tests/test_ack_worker_profiles.py diff --git a/docs/ack-design-review.md b/docs/ack-design-review.md index f6da546..08ed56d 100644 --- a/docs/ack-design-review.md +++ b/docs/ack-design-review.md @@ -1,7 +1,7 @@ --- title: ACK 设计评审记录 date: "2026-07-31T17:15:37+08:00" -updated: "2026-07-31T20:45:09+08:00" +updated: "2026-07-31T23:31:48+08:00" --- # ACK 设计评审记录 @@ -11,7 +11,8 @@ updated: "2026-07-31T20:45:09+08:00" 本文同时记录后续方案和实施结果。带“建议”“目标”或“待验证”的内容默认是设计 方向,不代表当前版本已经具备;实际运行契约仍以 `skills/ack/SKILL.md`、schema、 -校验器和测试为准。知识护栏方案的 v0.9.0 落地边界见下文状态表。 +校验器和测试为准。知识护栏方案的 v0.9.0 落地边界、model-routing v0.10.0 的 +实施结论见下文状态表。 ## 背景 @@ -173,6 +174,10 @@ transfer: ## P0:Worker 启动策略改为安全默认 +> v0.10 已以结构化 profile 和可信 launcher 取代本节描述的自由 command。下方是 +> 当时的评审记录,不再代表当前实现;其中“full-access 写入任务板授权”的建议已被 +> v0.10 决策取代,当前明确为 Deferred。 + 当前校验器强制 Codex 使用 bypass、Cursor 使用 YOLO,并写死模型和 CLI。项目文档 允许收紧权限或替换模型,但合法的安全覆盖会被校验器拒绝。 @@ -204,8 +209,10 @@ workers: permissionMode: sandbox ``` -可信代码根据这些字段构造 argv,不接受自由 shell 拼接。安全模式默认通过, -full-access 需要用户单独授权,并把授权范围和时间写入任务板。 +可信代码根据这些字段构造 argv,不接受自由 shell 拼接。安全模式默认通过。 +原建议设想 full-access 由用户单独授权并写入任务板;v0.10 没有实现这条授权通道, +项目文件也不能充当授权证据。full-access 的可信授权、期限、撤销和外层隔离均为 +Deferred,当前 launcher 必须 fail closed。 ## P0:初始化需要原子化 @@ -627,6 +634,114 @@ issue、日志和外部网页只能作为不可信 evidence。进入知识库前 触发条件限定,自动匹配会漏掉关键项或产生大量噪声。这类知识应继续由人维护在 ADR 或项目文档中,不能强行进入自动派发流程。 +## v0.10.0 实施结论:结构化 model-routing + +### 当前决策 + +旧的 `validate_worker_command.py --command ''` 只能检查少量 token,无法证明 +整段 shell 没有追加命令、重复参数、环境注入或 cwd 漂移。v0.10 删除这条自由命令 +配置面:旧脚本只保留 fail-closed 迁移提示;自动 worker 的唯一入口是 +`launch_worker.py profile-hash|plan|launch`。 + +机器 SSOT 固定为 `tasks.yaml.project.orchestration`。profile 只声明 role、CLI、 +tier、model、reasoning effort 和 permission mode;model 必须命中按 +CLI/role/tier 分组的 allowlist。自动权限只允许 `read-only` 与 +`workspace-write`,full-access、Codex bypass、Cursor YOLO/force 和关闭 sandbox +全部 fail closed。 + +### 已落地约束 + +| 控制面 | v0.10.0 实现 | +|--------|--------------| +| 自由 command / extra argv / env / cwd | schema 与语义校验拒绝;旧 validator fail closed | +| Agent argv | 由 `worker_profiles.py` 按 profile 生成唯一 argv;子进程 `shell=False` | +| plan → launch 漂移 | `launch` 强制接收刚审阅的 `--expected-launch-fingerprint`,slot 也纳入 hash | +| PATH / loader / 凭据串用 | 不按调用者 PATH 找可执行文件;固定可信目录;控制进程无供应商凭据,worker 使用 `per-cli-allowlist-v1` | +| worktree 冒充 | 绝对规范路径、无 symlink、同 Git common-dir,并命中 `git worktree list -z` | +| Orca command 注入 | `--command` 只含固定 bootstrap 和随机 launch ID | +| bootstrap 抢跑 | 先绑定 runtime/handle/incarnation/worktree,再用 terminal stdin nonce/proof 授权 | +| cleanup / bootstrap 竞态 | Popen 前在同一 record lock 内复核 awaiting 状态;cleanup 先发生则拒绝启动 Agent | +| create 部分失败 | 取得确定 handle 前的超时、transport/解码异常、中断、非零、畸形/缺字段响应均记为 indeterminate,禁止自动重试 | +| handle 后失败清理 | record 写失败不阻断 close;只有 `terminal close --tab --json` 完整匹配且最终 failed 状态可靠落盘才记普通失败,否则保持 indeterminate/reconcile-required | +| receipt 漂移 | 规范化 profileHash、argvHash、slot、launchFingerprint 与 receiptHash 交叉校验 | +| dispatch 旧 receipt 改挂 | 每个角色显式记录 attemptId;receipt 必须绑定当前 ACK task.id、同 role/profile/attempt,receiptId/attemptId 同空同填 | +| 配置兼容 | ackVersion 必须是合法 SemVer;v0.10+ 的 orchestration/workerReceipts 必须同时存在,v0.9 旧板仍可只读校验和手动协作 | + +### 不能被弱证明掩盖的边界 + +`receiptHash` 是无密钥 checksum,不是签名。项目内可写方能修改 receipt 后重算 +hash;而当前 Orca terminal metadata 不提供原始 Agent argv、模型或权限 +attestation。因此 v0.10 **不根据持久化 receipt 自动复用旧终端**。每次自动派发都 +重新执行 plan,并用 expected fingerprint 启动 fresh worker;持久化 receipt 只作 +审计和 dispatch 关联。 + +`launchFingerprint` 只证明完整计划没有漂移,不是一次性授权或幂等键;同一计划重复 +执行仍会创建新的 fresh terminal。成功后不得重放,结果不确定时必须先 reconcile。 +自动防重放要依赖后端原子 idempotency/claim 或项目外可信 launch intent,不能由 +checksum 伪装提供。 + +任务板里的 dispatch 关联也不是宽松的历史索引。`dispatch.` 显式记录 +`profileId`、`receiptId` 和 `attemptId`,校验器要求 receipt 的 `createdFor` 精确绑定 +当前 ACK `tasks[].id`、同一角色和同一 attempt,并要求 profile 一致。该约束阻止把 +旧任务、旧角色或旧轮次 receipt 改挂到当前 dispatch,但不会把无密钥 receipt 升级成 +可信 attestation。 + +同样,receipt 证明的是 launcher 请求和本地 CLI/runtime 绑定,不证明模型供应商最终 +执行的模型。开放自动复用需要 Orca/ACP 提供启动参数 attestation,或项目外可信签发 +与校验通道。开放 full-access 还需要不可由项目文本伪造的用户授权、期限、撤销和外层 +隔离。这两项均留待后续版本。 + +威胁模型也明确到 Coordinator 账户边界:v0.10 防止任务板、任务内容、普通环境和 +受限 worker 把数据变成第二个启动命令入口;不抵御已经完全控制 Coordinator OS +账户、Orca runtime、受信 Agent/Orca 可执行文件或用户级 Agent 配置/插件/MCP 的 +攻击者。`permissionMode` 也不是模型供应商或外部工具能力 attestation;后者必须依赖 +独立 OS 身份、受控 Agent 配置或平台能力。 + +## 后续版本提案:ACP 与可替换编排后端 + +### 当前决策 + +ACK 当前版本继续以 **Orca** 作为唯一自动编排后端。闭环语义保持工具无关,但 +worker 创建、终端身份绑定、dispatch 和 wait 的可执行适配仍由 Orca 完成。 + +ACP(Agent Client Protocol)作为后续版本的优先候选,当前只记录方案,不加入运行 +时依赖、不新增半成品 adapter,也不让现有流程在 Orca 与 ACP 之间自动猜测。 + +| 能力 | 当前版本 | 后续方向 | +|------|----------|----------| +| 自动创建并监督 worker | Orca | ACP capability negotiation 后可增加 ACP adapter | +| 结构化模型与权限 profile | ACK 自己定义,Orca 只承载启动 | 保持为 ACK 稳定契约,不交给后端自由解释 | +| dispatch / wait / terminal identity | Orca runtime handle | 映射到 ACP session / request / event identity | +| 无自动后端时运行闭环 | 手动模式 | 可增加 PTY / Zellij 类低层 fallback,但不冒充语义协议 | +| 后端选择 | 项目显式配置 | 未来仍需显式配置,不按已安装命令静默切换 | + +### 后端无关的最小语义 + +未来 adapter 只应实现以下能力,不应接管 ACK 的角色、状态机或安全策略: + +1. `createWorker(profile, worktree)`:按 ACK 已验证的结构化 profile 创建 worker。 +2. `inspectWorker(identity)`:返回可绑定的 runtime、session/incarnation 和活性。 +3. `dispatch(identity, taskContext)`:把一个明确任务投递给已验证 worker。 +4. `wait(identity, eventTypes)`:等待完成、复测、升级或 decision gate 事件。 +5. `closeWorker(identity)`:只关闭本次创建且身份仍匹配的 worker。 + +模型、reasoning effort、权限模式、worktree 和 receipt hash 仍由 ACK 校验。adapter +不能重新开放自由 shell、任意 argv、任意环境变量或“后端默认模型”作为旁路。 + +### ACP 采用门槛 + +满足以下条件后再实现 ACP adapter: + +- 目标 Agent CLI 对 ACP 的启动、会话身份、取消和事件语义足够稳定。 +- 能把 Developer/Test 的独立身份、worktree 和结构化 profile 映射到可核对字段。 +- 能区分“已请求的模型/权限”和“运行时可观测事实”,不把客户端请求冒充供应商证明。 +- 超时、重连、重复 dispatch 和 runtime 重启具有明确的幂等或恢复语义。 +- 与 Orca adapter 使用同一组 ACK conformance tests,且不会降低安全默认。 + +在这些门槛满足前,ACP 保持 **Deferred**。PTY 或 Zellij 一类方案只能作为较低层的 +进程/终端承载,不提供 session 语义、模型证明或任务状态机;如果以后加入,也必须 +经过独立 adapter,不能散落为文档中的自由命令。 + ### 参考依据 - [GitHub 仓库自定义指令](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions) diff --git a/skills/ack/README.md b/skills/ack/README.md index d262aaf..344f5fe 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -54,7 +54,7 @@ skills/ack/ ├── references/ # 三角色规范、闭环流程和初始化说明 ├── templates/ # project.md、tasks.yaml、knowledge.yaml 模板和 schema ├── examples/ # 完整示例 -└── scripts/ # 状态校验、知识选择、安全验证执行与 worker 命令工具 +└── scripts/ # 状态校验、知识选择、安全验证执行与结构化 worker launcher ``` `SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范; @@ -115,6 +115,60 @@ python3 /scripts/run_verification.py \ 执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK 不自动修改项目的 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 +## 启动 Worker + +worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型 +allowlist、结构化 profile、默认 profile 和允许的 worktree。不得在 `project.md` +或任务里保存完整启动命令、额外 argv 或环境变量。 + +先审阅计划,不产生终端: + +```bash +python3 /scripts/launch_worker.py plan \ + --project-root \ + --task-id \ + --attempt-id -A \ + --role developer \ + --profile-id codex-dev-standard \ + --worktree +``` + +确认后执行 `launch`,并显式绑定刚审阅的 fingerprint: + +```bash +python3 /scripts/launch_worker.py launch \ + --project-root \ + --task-id \ + --attempt-id -A \ + --role developer \ + --profile-id codex-dev-standard \ + --worktree \ + --expected-launch-fingerprint +``` + +launcher 是自动创建 worker 的唯一入口:它从权威任务板重读 profile,构造固定 +argv,忽略调用者 PATH、使用环境 allowlist,并验证真实 Git worktree 注册;然后 +通过仓库外的单次启动记录、terminal-bound nonce/proof 和受限 bootstrap 调用 Orca。 +返回的 receipt 含 `runtimeId`、handle、incarnation、profile hash、slot 和 worktree +identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 receipt ID +写入任务的 `dispatch.developer` 或 `dispatch.test`,并同步写入本轮 `attemptId`。 +校验器要求 receipt 与当前 ACK task、角色、profile 和 attempt 完全一致;历史 receipt +不能跨任务或跨轮次改挂。 + +v0.10 自动 launcher 仅支持 `read-only` 与 `workspace-write`。full-access、 +Codex bypass、Cursor YOLO/force 和关闭 sandbox 都会 fail closed;在有可信平台 +审批或独立签发通道之前,不用项目文件伪装成用户授权。旧任务板没有结构化 +`project.orchestration` 时仍可读取和手动协作,但不得自动创建 worker。 + +持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。由于 Orca 当前 +不能证明旧终端的原始 argv/模型/权限,v0.10 不自动复用既有 worker;每次自动派发 +都重新 `plan` 并用 expected fingerprint 启动 fresh worker。 +fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功后不得用同一 fingerprint +重复启动,结果不确定时必须先 reconcile。 +若创建或关闭回执不完整,或外部 launch record 状态无法可靠持久化,launcher 会返回 +`indeterminate/reconcile-required`;必须先核对 record 与 Orca live state,不能 +直接重试。 + ## 开始一个需求 初始化完成后可以直接说: @@ -128,5 +182,6 @@ Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品 ## 版本 -当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中记录 `ackVersion`。旧项目的 -`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。 +当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录 +`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须 +同时存在;旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。 diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index db1159a..d1eb594 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -75,7 +75,9 @@ description: >- - `docs/ack/tasks.yaml` - `docs/ack/knowledge.yaml` 2. 读取 `/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有 - `kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。 + `kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法 + SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时 + 存在。 3. 查找未替换占位符,并核对项目路径、覆盖层路径、Developer 白盒命令、Test 黑盒命令和 Base URL。 4. 使用 `/scripts/validate_tasks.py` 校验任务板,使用 @@ -84,7 +86,12 @@ description: >- 字段缺失而宣称失败。 5. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且 `stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。 -6. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。 +6. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、 + 允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test` 的引用;receipt + 必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId` + 必须同时为空或同时填写。 + 缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。 +7. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。 ## 工作 @@ -97,22 +104,30 @@ description: >- - `/references/kickoff.md` - kickoff 指定且与当前任务相关的 references 文件 3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和 - worker 复用规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active` + worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active` 知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文; 不全量注入知识库。 4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验, 然后交给用户确认;确认前不派发实现。 -5. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证,Test 独立黑盒 +5. 创建或更换 worker 时,只使用 + `/scripts/launch_worker.py plan|launch` 读取 + `tasks.yaml.project.orchestration` 的 profile。不得直接执行 + `orca terminal create --command`,不得接受或拼接自由 command、额外 argv、 + executable、env 或 cwd。必须先审阅 `plan.launchFingerprint`,再把它作为 + `launch --expected-launch-fingerprint` 传入。v0.10 不根据持久化 receipt 自动 + 复用旧终端;每次自动派发都创建 fresh worker,receipt 只作审计与 dispatch + 关联。 +6. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证,Test 独立黑盒 复测,Coordinator 读取证据终检并唯一写入 `tasks.yaml`。Developer 回报 `knowledgeApplied` 和 `knowledgeCandidates`,Test 回报 `knowledgeChecks`; `candidate` 只有在独立验证和 gate 后才能由 Coordinator 写入或激活。 -6. 执行知识项的 `verification.ref` 时,只调用 +7. 执行知识项的 `verification.ref` 时,只调用 `/scripts/run_verification.py docs/ack/knowledge.yaml --project-root `。不要直接执行选择器返回的 path/args, 也不要给 runner 注入额外命令或参数。 -7. 不把 `worker_done` 或 Test 自报成功直接当作完成。每项最多三轮,仍失败则记录 +8. 不把 `worker_done` 或 Test 自报成功直接当作完成。每项最多三轮,仍失败则记录 `leftover` 并继续其它任务。 -8. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范; +9. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范; `knowledge.yaml` 只保存触发条件、原因与证据引用,不能替代可执行控制。 ## 边界 @@ -120,6 +135,12 @@ description: >- - 不修改或追加任何项目 Agent 指令文件,包括 `AGENTS.md`。 - 不在项目中维护第二份 ACK 通用规范、模板或任务 schema。 - 不猜测项目命令、服务地址、worker handle 或模型名称。 +- 不把 full-access、bypass、YOLO/force、关闭 sandbox 或项目内“授权”字段当成 + v0.10 自动 worker 的合法配置;当前一律 fail closed。 +- 不把无密钥 `receiptHash` 或 Orca live metadata 当作旧终端的启动 attestation; + v0.10 不自动复用既有 worker。 +- launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按 + launch ID、外部 record 和 Orca live state 完成人工核对。 - 不覆盖已有 `docs/ack` 文件,不擅自提交、推送、创建终端或新 worktree。 - 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Developer 与 Test 只读, 只能通过回报提名或验证知识。 diff --git a/skills/ack/VERSION b/skills/ack/VERSION index ac39a10..78bc1ab 100644 --- a/skills/ack/VERSION +++ b/skills/ack/VERSION @@ -1 +1 @@ -0.9.0 +0.10.0 diff --git a/skills/ack/examples/project.example.md b/skills/ack/examples/project.example.md index ed1af52..51c9bc2 100644 --- a/skills/ack/examples/project.example.md +++ b/skills/ack/examples/project.example.md @@ -1,6 +1,6 @@ # notes-web Agent 协作协议(示例,项目覆盖层) -> 本项目基于 ack v0.9.0。 +> 本项目基于 ack v0.10.0。 > 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。 > 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。 > ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 @@ -26,13 +26,18 @@ - 派发 prompt 模板:`references/prompt-templates.md` - Orca 编排命令:`references/orca-adapter.md` -## 模型档位 +## Worker 路由 -| 角色 | 默认档位 | 本项目实际 | -|------|----------|------------| -| Coordinator (PM) | 强模型 | claude-sonnet-5-thinking-high | -| Test | 中低模型 | 默认(中低) | -| Developer | 中低模型 | 默认(中低),架构任务临时升级 | +结构化配置位于 `docs/ack/tasks.yaml` 的 `project.orchestration`,启动记录位于顶层 +`workerReceipts`。本项目默认使用: + +| 角色 | profile ID | 档位 | +|------|------------|------| +| Developer | `codex-dev-standard` | standard | +| Test | `codex-test-standard` | standard | +| Developer 升级 | `codex-dev-strong` | strong | + +本文件不保存或拼接 worker shell 命令。 ## 路径权限 @@ -77,6 +82,11 @@ path/args。 - 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现。 - 模型分层:Coordinator 强模型不跑测试,Test/Developer 中低模型(见 references/model-routing.md)。 +- 自动 worker 只通过 ACK 的 `scripts/launch_worker.py` 启动;不直接执行自由 + `orca --command`。v0.10 的自动 profile 只允许 `read-only` 或 + `workspace-write`,不允许 full-access、bypass、YOLO/force 或关闭 sandbox。 +- 每次自动派发都先审阅 plan,再用 `--expected-launch-fingerprint` 启动 fresh + worker;持久化 receipt 只作审计,不授权自动复用旧终端。 - `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。 - 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Test 与 Developer 只读。 - Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识; diff --git a/skills/ack/examples/tasks.example.yaml b/skills/ack/examples/tasks.example.yaml index 8ecf345..9e04a68 100644 --- a/skills/ack/examples/tasks.example.yaml +++ b/skills/ack/examples/tasks.example.yaml @@ -1,8 +1,9 @@ -# 填好的最小示例(虚构的 notes-web 项目)。可用 scripts/validate_tasks.py 校验通过。 +# 填好的手动闭环示例(虚构的 notes-web 项目)。历史轮次没有自动 launcher receipt; +# 因此使用 manual 模式。可用 scripts/validate_tasks.py 校验通过。 version: 1 updatedAt: "2026-07-06T09:40:00+08:00" source: "Coordinator (PM) Agent" -ackVersion: "0.9.0" +ackVersion: "0.10.0" project: name: "notes-web" repoPath: "/home/dev/notes-web" @@ -10,6 +11,65 @@ project: devWorktree: "/home/dev/notes-web-wt/fix-preview" overlayFile: "docs/ack/project.md" knowledgeFile: "docs/ack/knowledge.yaml" + orchestration: + profileVersion: 1 + mode: "manual" + allowedWorktrees: + - "/home/dev/notes-web-wt/fix-preview" + modelAllowlist: + codex: + developer: + standard: ["gpt-5.6-terra"] + strong: ["gpt-5.6-sol"] + test: + standard: ["gpt-5.6-luna"] + cursor-agent: + developer: + standard: ["auto"] + test: + standard: ["auto"] + profiles: + codex-dev-standard: + role: "developer" + cli: "codex" + tier: "standard" + model: "gpt-5.6-terra" + reasoningEffort: "medium" + permissionMode: "workspace-write" + codex-test-standard: + role: "test" + cli: "codex" + tier: "standard" + model: "gpt-5.6-luna" + reasoningEffort: "low" + permissionMode: "workspace-write" + codex-dev-strong: + role: "developer" + cli: "codex" + tier: "strong" + model: "gpt-5.6-sol" + reasoningEffort: "high" + permissionMode: "workspace-write" + cursor-dev-standard: + role: "developer" + cli: "cursor-agent" + tier: "standard" + model: "auto" + reasoningEffort: null + permissionMode: "workspace-write" + cursor-test-standard: + role: "test" + cli: "cursor-agent" + tier: "standard" + model: "auto" + reasoningEffort: null + permissionMode: "workspace-write" + defaults: + developer: "codex-dev-standard" + test: "codex-test-standard" + developerUpgraded: "codex-dev-strong" + +workerReceipts: [] summary: verified: ["BUG-002"] @@ -65,9 +125,18 @@ tasks: checks: - "预览后出现 4 行 diff:title/before/after/coverChanged" dispatch: - taskId: "orca-task-88" - dispatchId: "orca-disp-91" - worker: "dev-worker-1" + developer: + profileId: "codex-dev-standard" + receiptId: null + attemptId: null + taskId: null + dispatchId: null + test: + profileId: "codex-test-standard" + receiptId: null + attemptId: null + taskId: null + dispatchId: null rounds: - round: 1 attemptId: "BUG-002-A1" @@ -130,9 +199,18 @@ tasks: checks: - "并发保存时后写方收到冲突提示" dispatch: - taskId: "orca-task-90" - dispatchId: "orca-disp-95" - worker: "dev-worker-1" + developer: + profileId: "codex-dev-standard" + receiptId: null + attemptId: null + taskId: null + dispatchId: null + test: + profileId: "codex-test-standard" + receiptId: null + attemptId: null + taskId: null + dispatchId: null rounds: - round: 1 attemptId: "BUG-003-A1" diff --git a/skills/ack/references/adoption-checklist.md b/skills/ack/references/adoption-checklist.md index f72acca..6589b1b 100644 --- a/skills/ack/references/adoption-checklist.md +++ b/skills/ack/references/adoption-checklist.md @@ -18,6 +18,9 @@ `docs/ack/knowledge.yaml`。 - [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。 - [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。 +- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目 + allowlist,默认 profile 与角色/档位一致。 +- [ ] `allowedWorktrees` 只列出同一 Git common-dir 下已经核对的绝对 worktree。 ## 路径权限 @@ -56,8 +59,17 @@ ## 编排 - [ ] 已选择 Orca 或手动模式。 -- [ ] 派发前优先复用同 worktree、同角色、同配置的空闲 worker。 -- [ ] Developer 与 Test 的启动命令通过校验。 +- [ ] 自动创建只调用 `launch_worker.py plan|launch`,没有直接拼接 + `orca terminal create --command` 或自由 worker 命令。 +- [ ] `launch` 使用刚审阅的 `plan.launchFingerprint` 作为 + `--expected-launch-fingerprint`;漂移时重新 plan。 +- [ ] Developer/Test 只使用 `read-only` 或 `workspace-write` profile; + v0.10 没有 full-access、bypass、YOLO/force 或关闭 sandbox。 +- [ ] v0.10 每次自动派发都启动 fresh worker;没有把历史 receipt checksum 或 + Orca live metadata 当作自动复用授权。 +- [ ] launcher receipt 已追加到顶层 `workerReceipts`,任务分别引用 + `dispatch.developer` 与 `dispatch.test`;每个引用的 task/role/profile/attempt + 都与当前任务、本轮 receipt 完全一致,没有用单一 handle 混淆两个角色。 - [ ] 多 worktree 场景只有一个权威 `tasks.yaml` 和 `knowledge.yaml`。 - [ ] Test 使用的服务来自正确 worktree。 diff --git a/skills/ack/references/closed-loop.md b/skills/ack/references/closed-loop.md index 80d899a..967798f 100644 --- a/skills/ack/references/closed-loop.md +++ b/skills/ack/references/closed-loop.md @@ -2,8 +2,8 @@ 本文件定义**与具体编排工具无关**的三角色协作闭环。运行时调度可以用 Orca(见 `orca-adapter.md`),也可以手动跑(见下方「手动模式」)。 -原则:调度消息只是运行时载体,任务结论必须回写到 `tasks.yaml`;跨任务复用、已经 -验证的项目经验必须由 Coordinator 回写到 `knowledge.yaml`。不要把消息当最终记录。 +原则:调度消息只是运行时载体,任务结论必须回写到 `tasks.yaml`;已经验证且可跨 +任务使用的项目经验必须由 Coordinator 回写到 `knowledge.yaml`。不要把消息当最终记录。 角色定义见 `roles-and-permissions.md`:Coordinator 只编排、Test 只验证、 Developer 只实现。 @@ -34,10 +34,13 @@ Coordinator 发现或读取 open 任务 -> 从 knowledge.yaml 按 scope 推荐 active 知识,Coordinator 确认固定 revision 的 knowledgeRefs -> 为新轮次生成稳定 attemptId(-A),Developer 与 Test 共用 -> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」) - -> 解析 worker:先复用同 worktree、同角色的空闲 worker;没有可复用项时才校验命令并新建(见 orca-adapter.md) + -> 解析安全 profile:机器配置只读 tasks.yaml.project.orchestration + -> 运行 launcher plan 并审阅 launchFingerprint + -> 带 expected fingerprint 启动 fresh worker,把 receipt 写回 tasks.yaml(见 orca-adapter.md) -> dispatch 给 Developer(--to ) -> wait:Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates) -> writeback fixed_by_dev + -> 为 Test 独立解析安全 profile,重新 plan/launch fresh worker 并写回 receipt -> dispatch 给 Test(retesting) -> wait:Test 的 retest_result(含 knowledgeChecks 和 candidate 独立证据) -> Test 通过:gate(Coordinator 读证据对齐意图) @@ -48,7 +51,9 @@ Coordinator 发现或读取 open 任务 ``` 一次派发只修一个明确问题(细则见 `optimization-method.md` §「每轮派发只修一个明确问题」)。 -「决定 worktree」「解析 worker」两步的决策见下节与 `model-routing.md` / `orca-adapter.md`。 +「决定 worktree」「解析 profile/receipt」几步的决策见下节与 `model-routing.md` / +`orca-adapter.md`。v0.10 不自动复用历史 receipt 或既有终端;标题、preview、分支名、 +worker 自报和无密钥 receipt checksum 都不能代替可信启动 attestation。 Coordinator 默认给新逻辑轮次生成 `-A`,并在记录轮次结果时写入 `tasks[].dispatch.rounds[].attemptId`。它独立于编排工具产生的 `taskId` 和 `dispatchId`:前者用于知识来源追溯,后两者只用于查询运行时。0.9.0 之前的历史 @@ -101,23 +106,48 @@ Test 对显式引用回报 `not_applicable` 时,说明 Coordinator 的选择 Coordinator 派发前先决定 Developer/Test 在哪工作。两种方式: -**方式 1:当前 worktree 复用或起子 agent(终端)** +**方式 1:当前 worktree 起 fresh 子 agent(终端)** ```bash -# 先按 orca-adapter.md 的 worker 复用流程查找空闲终端;没有时才创建 -orca terminal create --worktree active --command "cursor-agent --yolo --model auto" --title "ACK-DEV-CURSOR-AUTO-1" --json +# v0.10 不自动复用既有终端;先审阅 plan: +python3 /scripts/launch_worker.py plan \ + --project-root --task-id \ + --attempt-id -A --role \ + --profile-id --worktree --slot <1..99> +python3 /scripts/launch_worker.py launch \ + --project-root --task-id \ + --attempt-id -A --role \ + --profile-id --worktree --slot <1..99> \ + --expected-launch-fingerprint ``` - 适合:串行闭环、一次一个 Developer 任务、小改动、Test 要复测的正是 Developer 改的那棵树。 - 优点:单一服务实例、无跨 worktree 对齐困扰、开销小。 - 风险:多个 agent 同时改同一棵树会互相踩(半构建状态、git 冲突)。 +- 记录:`launch` 成功后把 receipt 追加到权威 `tasks.yaml.workerReceipts`,并把 + `receipt.id` 写入 `dispatch..receiptId`、把 `profileId` 写入 + `dispatch..profileId`、把本轮稳定 ID 写入 `dispatch..attemptId`; + receipt 的 task/role/profile/attempt 必须与当前 dispatch 完全一致,不能只记录 + 一个裸 handle,也不能引用旧任务或旧轮次 receipt。 **方式 2:新建隔离 worktree,再在其中起子 agent** +创建成功后,先把 Orca 返回的绝对路径加入权威任务板的 +`project.orchestration.allowedWorktrees` 并重新运行 `validate_tasks.py`;未进入 +allowlist 的路径不能交给 launcher。 + ```bash orca worktree create --name --base-branch --json -# 然后先查该 worktree 的同角色空闲终端;没有时才创建 -orca terminal create --worktree path: --command "cursor-agent --yolo --model auto" --title "ACK-DEV-CURSOR-AUTO-1" +# 对返回的绝对路径调用 ACK launcher +python3 /scripts/launch_worker.py plan \ + --project-root --task-id \ + --attempt-id -A --role \ + --profile-id --worktree --slot <1..99> +python3 /scripts/launch_worker.py launch \ + --project-root --task-id \ + --attempt-id -A --role \ + --profile-id --worktree --slot <1..99> \ + --expected-launch-fingerprint ``` - 适合:并行多个互不依赖的子任务、大/高风险/实验性改动、要保持基线分支干净(如 prod 不动、feature 走独立分支)、要独立 build 或跑独立服务实例、best-of-N 尝试。 @@ -136,8 +166,13 @@ orca terminal create --worktree path: --command "cursor-agent --yolo --mode **项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml` 和 `knowledge.yaml` 都只认一个权威副本(通常在基线/协调所在 worktree),由 -Coordinator 单写。不要每个 worktree 各留一份会分叉的项目状态。模型固定方式见 -`model-routing.md` 与 `orca-adapter.md`。 +Coordinator 单写。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch +也只写入这个副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、 +launcher 与 receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。 + +两种 worktree 方式都只允许 profile 中的 `read-only` / `workspace-write` 安全权限。 +v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 full-access 或等价 +的 sandbox 绕过请求必须 fail closed。 --- @@ -158,6 +193,10 @@ Coordinator 单写。不要每个 worktree 各留一份会分叉的项目状态 手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。 +手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff, +但不能因此进入 Orca 自动派发信任路径;后续自动派发仍需重新通过 ACK launcher +创建 fresh worker。 + --- ## Test 复测(编排无关) diff --git a/skills/ack/references/init-new-project.md b/skills/ack/references/init-new-project.md index 8267975..200a668 100644 --- a/skills/ack/references/init-new-project.md +++ b/skills/ack/references/init-new-project.md @@ -68,10 +68,14 @@ docs/ack/ 编辑 `docs/ack/tasks.yaml`: -- `ackVersion` 使用 ACK Skill 的 `VERSION`。 +- `ackVersion` 使用 ACK Skill 的合法 SemVer `VERSION`;从 `0.10.0` 起 + `project.orchestration` 与顶层 `workerReceipts` 必须同时存在。 - `updatedAt` 使用当前带时区时间。 - `project.name`、`repoPath`、`devWorktree`、`overlayFile` 和 `knowledgeFile` 使用 真实值。 +- `project.orchestration.allowedWorktrees` 使用已核对的绝对 worktree;模型 + allowlist、profiles 和 defaults 使用项目实际允许值。不要把完整启动命令、 + `extraArgs`、`env` 或任意 executable 写进任务板。 - 非服务项目的 `baseUrl` 写为 `n/a`。 - 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。 @@ -109,6 +113,11 @@ python3 /scripts/validate_knowledge.py docs/ack/knowledge.yaml -- - `project.overlayFile` 指向真实文件。 - `project.knowledgeFile` 指向 `docs/ack/knowledge.yaml`。 - Developer 与 Test 的验证命令可执行。 +- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许 + `read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。 +- 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt + 引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计, + v0.10 不自动复用旧终端。 - 网站或 API 项目写清服务启动、重启和 Base URL。 - 任务中的固定 revision `knowledgeRefs` 都能解析,非 `active` 条目没有被派发。 diff --git a/skills/ack/references/kickoff.md b/skills/ack/references/kickoff.md index 1da09b4..db562d4 100644 --- a/skills/ack/references/kickoff.md +++ b/skills/ack/references/kickoff.md @@ -6,7 +6,11 @@ ## 前提:谁是 Coordinator -**你(发起编排的强模型会话)就是 Coordinator (PM) / 产品。** 你负责写文档、拆任务、编排、终检,**不亲自写代码、不亲自跑测试**。开发和测试是另起的 worker agent;具体 CLI、模型、reasoning effort 和执行模式必须先读项目覆盖层 `docs/ack/project.md`,禁止照抄未核对的默认命令。 +**你(发起编排的强模型会话)就是 Coordinator (PM) / 产品。** 你负责写文档、拆任务、 +编排、终检,**不亲自写代码、不亲自跑测试**。开发和测试是另起的 worker agent; +具体 CLI、模型、reasoning effort 和执行模式的机器事实源是 +`docs/ack/tasks.yaml` 的 `project.orchestration`。`docs/ack/project.md` 只解释项目 +差异,不能提供另一套启动命令。 --- @@ -16,7 +20,7 @@ 我要做一个新需求:<一句话需求>。 你作为 ack 的 Coordinator(PM),按 ACK Skill 的 references 规范执行: -1. 先读 docs/ack/project.md、docs/ack/tasks.yaml,校验 docs/ack/knowledge.yaml 并 +1. 先读 docs/ack/project.md、docs/ack/tasks.yaml(包括 project.orchestration),校验 docs/ack/knowledge.yaml 并 用 `scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读 references/roles-and-permissions.md、closed-loop.md、optimization-method.md。 2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。 @@ -24,7 +28,8 @@ knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。 4. 把任务写进 docs/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。 5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认,不要急着派发。 -6. 我确认后,按 ack 闭环循环:先按 docs/ack/project.md 校验 Developer/Test worker 启动命令, +6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化 + Developer/Test profile,审阅 plan 后用 expected fingerprint 创建 fresh worker, dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml; 每个任务最多三轮,三轮不过记 leftover 并升级我复盘。 ``` @@ -45,6 +50,9 @@ python3 /scripts/validate_tasks.py docs/ack/tasks.yaml python3 /scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml ``` + `project.orchestration` 是 worker profile 的机器 SSOT;未知字段、非 allowlist 模型、 + 不匹配的角色/档位和非安全权限必须在派发前 fail closed。 + 5. **停下来给人确认**:这是强模型该花时间的地方,不要跳过。 --- @@ -58,26 +66,58 @@ python3 /scripts/validate_knowledge.py docs/ack/knowledge.yaml -- --- -## 第 3 步:复用或启动 worker +## 第 3 步:审阅并启动 worker -先读 `docs/ack/project.md` 的项目覆盖规则,再按 `orca-adapter.md` §「解析并复用 worker」执行:先列出现有终端和活跃任务,优先复用同 worktree、同角色、配置兼容且空闲的 worker。只有没有可复用项时才创建新终端;如果项目提供 `validate_worker_command.py`,校验未通过时不得创建。 +先读 `tasks.yaml.project.orchestration` 的结构化 profiles。v0.10 不把历史 receipt +或既有 Orca terminal 当作自动复用授权;每次自动派发都创建 fresh worker。原因和 +边界见 `model-routing.md` §「Receipt、审计与复用边界」。 + +先查看目标 profile hash,确认本次结构化配置。这个 hash 只用于审计和漂移比较, +不能用于匹配或复用旧 receipt / 既有终端: ```bash -# Codex 示例(模型与执行模式以项目覆盖层为准) -DEV_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium' -TEST_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low' -python3 /scripts/validate_worker_command.py --role developer --command "$DEV_CMD" -python3 /scripts/validate_worker_command.py --role test --command "$TEST_CMD" -orca terminal create --worktree active --command "$DEV_CMD" --title "ACK-DEV-CODEX-TERRA-1" --json -orca terminal create --worktree active --command "$TEST_CMD" --title "ACK-TEST-CODEX-LUNA-1" --json - -# Cursor 示例 -CURSOR_CMD='cursor-agent --yolo --model auto' -python3 /scripts/validate_worker_command.py --role developer --command "$CURSOR_CMD" -orca terminal create --worktree active --command "$CURSOR_CMD" --title "ACK-DEV-CURSOR-AUTO-1" --json +python3 /scripts/launch_worker.py profile-hash \ + --project-root --profile-id ``` -上面的 `terminal create` 都是“没有空闲兼容 worker”时的兜底,不是每次 kickoff 的固定动作。新 worktree 时先 `orca worktree create --name --base-branch `,再解析该 worktree 的 worker。选型与升级见 `model-routing.md`。新建终端后、dispatch 前必须读取启动信息,确认实际 CLI、模型和 reasoning effort;派发后把实际 handle 写入对应任务的 `dispatch.worker`。 +生成只读计划: + +```bash +python3 /scripts/launch_worker.py plan \ + --project-root --task-id \ + --attempt-id -A --role \ + --profile-id --worktree --slot <1..99> +``` + +审阅计划后执行唯一创建入口: + +```bash +python3 /scripts/launch_worker.py launch \ + --project-root --task-id \ + --attempt-id -A --role \ + --profile-id --worktree --slot <1..99> \ + --expected-launch-fingerprint +``` + +`launch` 的 fingerprint 必须来自刚才审阅的 plan;若中间任何关键事实漂移,重新 +plan,不得覆盖检查。标准输出是单一 JSON。Coordinator 校验其中的 receipt 后,把 +receipt 原样追加到 `tasks.yaml.workerReceipts`,把 `receipt.id` 写入该任务 +`dispatch..receiptId`、把 `profileId` 写入 `dispatch..profileId`,并把 +本轮稳定 ID 写入 `dispatch..attemptId`。三者必须与 receipt 的当前 +task/role/profile/attempt 完全绑定;不得把旧任务或旧轮次的 receipt 改挂到当前 +dispatch。后续 +紧随其后的本次 Orca dispatch 只使用这次 fresh receipt binding 中的 handle;持久化 +receipt 仅用于审计,不能在后续轮次自动复用。不要自行拼装 Agent CLI 或 Orca 的 +底层创建命令。 + +新 worktree 时可先运行 +`orca worktree create --name --base-branch --json`,再对返回的绝对 +worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调用 launcher +前,先把新 +路径加入 `project.orchestration.allowedWorktrees` 并重新运行任务板校验。profile +只允许 `read-only` 或 `workspace-write`;v0.10 的 full-access 授权通道尚未实现, +任何 bypass、YOLO/force 或关闭 sandbox 的请求都必须失败,不能手写命令兜底。 +选型与升级见 `model-routing.md`。 --- @@ -85,6 +125,7 @@ orca terminal create --worktree active --command "$CURSOR_CMD" --title "ACK-DEV- ```text task-create → dispatch 给 DEV → 等 worker_done + → 每个角色都通过 plan + expected fingerprint launch fresh worker 并写回 receipt → 每轮使用 Coordinator 分配的稳定 -A → 回写 fixed_by_dev → dispatch 给 TEST 复测 → 等 retest_result → Developer 回 knowledgeApplied / knowledgeCandidates,Test 回 knowledgeChecks @@ -110,6 +151,8 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo ## 一句话 -产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 按项目 -覆盖层校验并启动 DEV/TEST → 核对实际模型 → dispatch / 复测 / 终检循环 → 任务 -结论落 `tasks.yaml`,验证后的跨任务知识由 Coordinator 落 `knowledge.yaml`。 +产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从 +`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected +fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落 +`tasks.yaml`,验证后的 +跨任务知识由 Coordinator 落 `knowledge.yaml`。 diff --git a/skills/ack/references/model-routing.md b/skills/ack/references/model-routing.md index 1258883..9c2bc0b 100644 --- a/skills/ack/references/model-routing.md +++ b/skills/ack/references/model-routing.md @@ -1,8 +1,11 @@ # 模型路由(稳定核心) -本文件是**三角色默认模型档位**和**升级规则**的单一事实源(SSOT)。目标:在不牺牲质量的前提下降低 token 和模型成本——把昂贵的强模型留给需要判断的工作,把机械执行交给较弱模型。 +本文件是**三角色默认模型档位、升级规则和 worker profile 选择规则**的单一规范源。 +目标是在不牺牲质量的前提下降低 token 和模型成本:把昂贵的强模型留给需要判断的 +工作,把机械执行交给较弱模型。 -角色定义见 `roles-and-permissions.md`(Coordinator 编排 / Test 验证 / Developer 实现)。本文件只补一层正交的「用哪个档位的模型」。 +角色定义见 `roles-and-permissions.md`(Coordinator 编排 / Test 验证 / Developer +实现)。本文件只补一层正交的「用哪个档位、以什么安全 profile 启动 worker」。 --- @@ -14,7 +17,9 @@ | Test | 中低模型 | 按既定验收信号执行浏览器/API/脚本,主要做观察、记录、逐条 pass/fail | | Developer | 中低模型(按任务升级) | 多数实现可照规格执行;跨系统、数据迁移、重复失败时再升级 | -关键点:Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),所以强模型的 token 花在思考和终检上,而不是反复点击页面、跑 smoke、复制日志。这一分工天然省 token,同时保持「验证者 ≠ 实现者」。 +Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),所以强模型的 token +花在思考和终检上,而不是反复点击页面、跑 smoke、复制日志。这一分工天然省 token, +同时保持「验证者 != 实现者」。 --- @@ -51,67 +56,183 @@ - 需要设计新的抽象。 - 低档位反复产出表面修复。 -升级动作本身由 Coordinator 判断并记录(可写进 `tasks.yaml` 的 `dispatch` 备注或 `resolution`)。 +升级由 Coordinator 判断并记录原因。升级不是修改一个正在运行的终端:必须选择 +对应强档 profile,重新计算 `profileHash`,再通过 launcher 创建新的 worker。 +v0.10 不自动复用持久化 receipt 指向的旧终端,Test 也不得使用 Developer 的强档 +worker。 --- -## 如何给 worker 指定模型(编排相关) +## 结构化 worker profile -**模型不由编排层设置。** Orca 的 `orchestration task-create` / `dispatch` 没有 `--model` 参数——`dispatch` 只是把任务投递给一个已存在的终端 handle。**模型在创建 worker 终端、启动 agent CLI 时用 CLI 自带的 flag 固定**,之后该终端的所有 dispatch 都用这个模型。 +### 机器事实源 -**默认不跨 Agent CLI 创建 worker。** Coordinator 应按自己所在的运行环境选择同类 worker:Cursor 会话创建 `cursor-agent` worker,Codex 会话创建 `codex` worker。不要依赖 Coordinator 凭模型回答来猜测运行环境或精确模型名;以实际 CLI / 终端环境为准。只有项目 overlay 或用户明确指定跨 Agent 时,才混用不同 CLI,并记录原因。 +worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的 +`project.orchestration`。`project.md` 可以解释项目为何选某个档位,但不能另写一份 +完整启动命令或覆盖机器配置。字段结构以 `templates/tasks.schema.json` 为准。 -因此「档位 → 具体模型」的映射是 **agent 相关** 的,落地方式见 `orca-adapter.md` §「给 worker 终端固定模型」。常见 CLI: +每个 profile 明确声明: -| Agent CLI | 指定模型的方式 | -|-----------|----------------| -| Cursor (`cursor-agent`) | `cursor-agent --model `;`--model auto` 让 Cursor 自动选(推荐给 Test/Developer worker) | -| Codex (`codex`) | `codex -m -c model_reasoning_effort=` | -| 其它(opencode 等) | 用各自 CLI 的模型参数或配置 | +- `role`:`developer` 或 `test`; +- `cli`:受支持的 Agent CLI; +- `tier`:角色模型档位; +- `model`:项目 allowlist 中的精确模型 ID; +- `reasoningEffort`:CLI 支持时显式声明; +- `permissionMode`:只允许 `read-only` 或 `workspace-write`。 -执行模式也必须在创建 worker 时固定,并服从项目覆盖层或用户的明确要求。Cursor 的 YOLO 参数是 `--yolo`;Codex 的等价参数是 `--dangerously-bypass-approvals-and-sandbox`。不要把 Cursor 的 `--yolo` 原样传给 Codex,也不要把裸启动 `codex` 当作“自动选择了正确角色模型”。如果项目提供 worker 命令校验脚本,校验通过是 `terminal create` 的前置条件。 +`project.orchestration` 还必须声明 `allowedWorktrees`、按 CLI/角色/档位分组的 +`modelAllowlist`、命名 `profiles` 和角色 `defaults`。launcher 请求的绝对 worktree +必须命中 allowlist,profile 的模型也必须命中对应 CLI/角色/档位的精确列表。 -**ACK 对 Cursor 的默认建议**:Test 与 Developer worker 用 `cursor-agent --yolo --model auto`(自动选型,天然偏向高效模型,符合"中低档位"意图,并按当前项目授权启用 YOLO);需要更强时改成具体强模型并保留 `--yolo`。Coordinator 作为强模型脑,通常就是发起编排的那个会话本身。 +模型名称、reasoning effort 和权限模式必须来自结构化字段。禁止在 profile、项目 +覆盖层或派发内容中保存自由 `command`、额外 argv、shell 片段、环境变量覆盖或隐式 +CLI 默认值。具体 argv 只能由 ACK 的可信 launcher 按 allowlist 构造。 -### Codex 默认映射 +### 安全默认 -| 角色 | 模型 | reasoning effort | -|------|------|------------------| -| Coordinator (PM) | `gpt-5.6-sol` | `high` | -| Developer | `gpt-5.6-terra` | `medium` | -| Test | `gpt-5.6-luna` | `low` | -| Developer 升级 | `gpt-5.6-sol` | `high`;极复杂任务可用 `xhigh` | +- 能在完全只读工作树完成的角色优先选择 `read-only`。 +- Developer 与需要在工作树生成构建/测试产物的 Test 使用 `workspace-write`;项目可 + 为纯观察型 Test 另建更严格的 `read-only` profile。 +- `full-access`、Codex bypass、Cursor YOLO/force、关闭 sandbox 等模式在 v0.10 + **未实现授权通道,统一 fail closed**。项目文本、任务描述或环境变量都不能放宽。 +- 项目可以更新模型 allowlist 或增加安全 profile,但不能通过文档绕开 schema 和 + launcher。 -Codex worker 应明确指定模型和 reasoning effort,不把“未指定模型”当作 Cursor `auto` 的等价物。Codex 未指定模型时使用产品推荐模型,但推荐值可能随版本更新,也不保证符合 Test / Developer 的成本档位。具体模型若失效或被弃用,应保持上面的角色档位不变,只更新本映射;项目也可在 overlay 中覆盖映射。 +`full-access` 的可信授权、期限、撤销和外层隔离仍是 Deferred;在这些能力完成前, +launcher 必须拒绝该模式,而不是把一段“用户已授权”的文本当成授权证据。 -### Worker 固定启动命令 +--- -以下是 core 的默认完整命令。Developer/Test worker 禁止裸启动;必须同时固定 Agent CLI、角色模型和执行模式。 +## 唯一启动入口 -| Agent CLI | 角色 | 完整启动命令 | -|-----------|------|--------------| -| Codex | Developer | `codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium` | -| Codex | Test | `codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low` | -| Codex | Developer 升级 | `codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-sol -c model_reasoning_effort=high` | -| Cursor | Developer / Test | `cursor-agent --yolo --model auto` | +worker 的 profile 解析、计划和启动只允许通过: -硬规则: +```bash +python3 /scripts/launch_worker.py profile-hash \ + --project-root --profile-id -1. Codex worker 必须带 `--dangerously-bypass-approvals-and-sandbox`;Cursor worker 必须带 `--yolo`。 -2. 禁止用裸 `codex`、裸 `cursor-agent` 或只指定 CLI 而不指定角色模型。 -3. 创建终端前先运行项目提供的 worker 命令校验器;校验失败时不得执行 `orca terminal create`。 -4. 创建终端后、dispatch 前读取启动信息,确认实际 CLI、模型和 reasoning effort 与命令一致。 -5. Developer 升级到强模型必须由 Coordinator 记录原因;Test 不得复用 Developer 的强模型终端。 -6. YOLO 会跳过确认和沙箱,只能在用户已明确授权、外层运行环境受控时启用;项目覆盖层可以收紧执行模式,不能静默放宽。 +python3 /scripts/launch_worker.py plan \ + --project-root --task-id \ + --attempt-id -A --role \ + --profile-id --worktree [--slot <1..99>] -项目可以覆盖模型名称或收紧权限,但必须在 `project.md` 中写出完整替代命令,不能只写“auto”“默认模型”或依赖当前 CLI 的隐式默认值。 +python3 /scripts/launch_worker.py launch \ + --project-root --task-id \ + --attempt-id -A --role \ + --profile-id --worktree [--slot <1..99>] \ + --expected-launch-fingerprint +``` + +`--project-root` 始终指向保存权威 `docs/ack/tasks.yaml` 的项目根;`--worktree` 是本次 +worker 实际工作的绝对路径,两者可以不同。后者仍必须属于同一项目并命中 +`project.orchestration.allowedWorktrees`。 + +- `profile-hash` 读取 `tasks.yaml` 中的 profile,输出确定性 hash。 +- `plan` 只校验并展示将使用的结构化计划,不创建终端。 +- `launch` 是唯一允许创建 worker 的入口;它要求传入刚审阅的 + `plan.launchFingerprint`,当前 profile、任务/attempt、worktree、CLI 或 argv + 任一事实漂移都会在创建前失败。它内部固定 Orca 调用和 Agent argv,标准输出只有 + 一份 JSON 结果。 +- launcher 的内部 bootstrap 属于私有协议,Coordinator 不直接调用、不复制到项目 + 文档,也不自行拼装 Orca 的底层终端创建文本。 + +Coordinator 在执行 `launch` 前必须先审阅 `plan`。任何校验失败、字段未知、路径不 +匹配、CLI 不可用或权限模式不支持,都停止创建并回报,不回退到裸 Agent 命令。 + +--- + +## 可执行信任边界 + +v0.10 的 launcher 还执行以下约束: + +- 不读取调用者传入的 `PATH` 来寻找 `git`、Orca 或 Agent CLI,只在固定的系统与 + 当前用户工具目录中解析受支持的可执行文件;候选目标必须由 root/当前用户拥有、 + 不是 group/other writable,并在 plan 与 bootstrap 间保持相同 device/inode。 +- Git、Orca 和 CLI version probe 使用不含供应商凭据的控制环境;worker 使用 + `per-cli-allowlist-v1`,Codex 只取得 Codex 所需凭据,Cursor 只取得 Cursor 凭据, + 不跨 CLI 透传。`DBUS_*`、`DISPLAY`、`WAYLAND_DISPLAY`、`XDG_RUNTIME_DIR`、 + `LD_*`、`PYTHON*`、`NODE_OPTIONS`、`CODEX_HOME`、`GIT_*` 和调用者 PATH 均不会 + 透传给 worker。 +- worktree 除了命中项目 allowlist、属于同一 Git common-dir,还必须精确出现在 + `git worktree list --porcelain -z` 中;`.git` symlink 冒充的目录会失败。 +- Orca 的 `--command` 只含固定 bootstrap 与随机 launch ID。终端创建后,父进程先 + 绑定 runtime/handle/incarnation/worktree,再通过该终端 stdin 发送一次性 nonce; + bootstrap 校验 nonce 后才允许启动 Agent CLI,并把 child PID 与 nonce/launch + fingerprint 绑定的 proof 原子写入 ready 状态;父进程看到并核对 proof 后才把 + worker 视为可派发。 +- `orca terminal create` 一经发出,在取得确定 handle 前遇到超时、transport/解码 + 异常、进程中断、非零退出、畸形 JSON 或缺字段,一律标记为 `indeterminate`, + 禁止自动重试。 +- 取得 handle 后若任一绑定或握手检查失败,launcher 会先用 + `orca terminal close --tab --json` 请求持久关闭,并严格核对 runtime、handle、 + tab 和 close mode。只有关闭回执完整匹配才记为普通 `failed`;关闭无法确认时保持 + `indeterminate/reconcile-required`,调用方必须先人工核对,不能直接重试。 +- bootstrap 在真正启动 Agent 前,会在同一 launch-record 锁内再次确认状态仍为 + `awaiting-authorization` 且没有 cleanup;父进程一旦先进入 cleanup/reconcile, + bootstrap 必须拒绝 `Popen`,不能让取消后的 workspace-write worker 继续启动。 +- 异常路径的 launch record 写入失败不能阻断上述关闭动作,也不能遮蔽原始 + `indeterminate`。即使关闭已确认,只要最终状态无法可靠持久化,也保守返回 + `indeterminate/reconcile-required`。 + +这些约束防止任务板、任务文本和普通环境变量变成第二个启动命令入口。它们不抵御已经 +完全控制 Coordinator 用户账户、Orca 运行时、受信 Agent/Orca 可执行文件或 +Coordinator 用户级 Agent 配置/插件/MCP 的攻击者;该场景需要独立 OS 身份、受控 +Agent 配置或平台签发能力。`permissionMode` 约束的是 Agent CLI 的 shell/文件系统 +执行模式,不是模型供应商或外部工具能力的 attestation。 + +--- + +## Receipt、审计与复用边界 + +成功启动返回的 JSON 含一个可校验 receipt。Coordinator 把 receipt 追加到 +`tasks.yaml` 顶层 `workerReceipts`,并把它的 `id` 写入当前任务 +`dispatch..receiptId`、把 `profileId` 写入 `dispatch..profileId`,同时把 +本次 `-A` 写入 `dispatch..attemptId`。 + +这三个字段不是可任意组合的历史索引。任务板校验器要求被引用 receipt 的 +`createdFor.taskId` 等于当前 ACK `tasks[].id`,`createdFor.role` 等于 dispatch +角色,`profileId` 完全相同,且 `createdFor.attemptId` 等于 +`dispatch..attemptId`。`receiptId: null` 时 `attemptId` 必须同为 `null`; +`receiptId` 非空时 `attemptId` 必须存在并命中本轮。`dispatch..taskId` 是 Orca +运行时 task ID,不能拿来替代 ACK task ID 或稳定 attempt ID。 + +receipt 至少把以下事实绑定在一起: + +- `profileHash` 与请求的角色、模型、effort、权限和精确 argv; +- `slot`,确保 fingerprint 也绑定审阅过的 worker 槽位和派生标题; +- 任务、attempt、绝对 worktree 及 path/device/inode/git common-dir 等稳定身份; +- Orca `runtimeId`、terminal `handle` 和 `incarnationId`; +- 创建时间、launcher 指纹和 `receiptHash`。 + +`receiptHash` 是无密钥的规范 JSON checksum,只能发现意外漂移或未同步修改,**不是 +launcher 身份证明,也不是复用授权**。项目内有写权限的一方可以修改 receipt 后重算 +hash;而当前 Orca metadata 又不能证明终端最初执行的命令、模型和权限。因此 v0.10 +明确禁止根据持久化 receipt 自动复用既有终端:每次需要自动派发 worker,都重新走 +`plan` → 带 expected fingerprint 的 `launch`,只使用该次 launcher 标准输出中的 +fresh handle 完成本次派发。 + +`launchFingerprint` 是确定性的完整计划漂移校验,不是一次性授权或幂等键。同一份 +计划重复执行 `launch` 会创建新的 fresh terminal;成功后不得用同一 fingerprint +再次启动。若结果不确定,必须先 reconcile,不能把重跑当作恢复方式。未来需要自动 +防重放时,应由 Orca/ACP 的原子 idempotency/claim 能力或项目外可信 launch intent +提供,而不是把 checksum 冒充成一次性令牌。 + +持久化 receipt 仍用于审计、dispatch 关联和检测配置漂移;标题、preview、分支名、 +worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可信 worker。未来只有 +在 Orca/ACP 提供启动参数 attestation,或存在项目外可信签发与校验通道后,才开放 +自动复用。CLI / 模型变更仍需更新 allowlist 并重新生成 receipt。 + +`ackVersion` 必须使用合法 SemVer。`0.10.0` 及以后版本的任务板必须同时存在 +`project.orchestration` 与顶层 `workerReceipts`;其中任一字段出现,另一个也必须 +出现,避免 schema-only 校验把半迁移任务板误判为可自动派发。 --- ## 成本原则 强模型产出高密度、可复用的产物:需求、架构决策、验收信号、任务拆分、失败复盘。 -中低模型消费这些产物,产出可核对的执行证据:测试结果、快照、API 响应、构建日志、改动文件清单。 +中低模型消费这些产物,产出可核对的执行证据:测试结果、快照、API 响应、构建日志、 +改动文件清单。 这样把昂贵推理挡在重复执行之外。 @@ -119,4 +240,6 @@ Codex worker 应明确指定模型和 reasoning effort,不把“未指定模 ## 一句话 -Coordinator 是脑,Test 是眼,Developer 是手。脑用最强的模型且不做机械测试,眼和手用便宜模型,只有常规闭环卡住时才升级。 +Coordinator 是脑,Test 是眼,Developer 是手。脑用强模型且不做机械测试,眼和手 +默认用安全、较低成本的结构化 profile;只有常规闭环卡住时才升级,并且所有 worker +都必须经 launcher 产生可核对的 receipt。 diff --git a/skills/ack/references/orca-adapter.md b/skills/ack/references/orca-adapter.md index e3dfc67..9506da8 100644 --- a/skills/ack/references/orca-adapter.md +++ b/skills/ack/references/orca-adapter.md @@ -23,79 +23,83 @@ orca orchestration task-list --json orca orchestration inbox --limit 20 --json ``` -确认:Orca runtime 可达;Coordinator、Developer、Test 三个终端都存在;Developer 在正确 worktree;当前没有冲突的活跃编排任务。 +确认:Orca runtime 可达并取得本次 `runtimeId`;Coordinator 终端存在;当前没有冲突 +的活跃编排任务。Developer/Test worker 必须通过本次 launcher 新建,不要求预先存在。 --- -## 解析并复用 worker +## 既有 worker 的处理 -**默认复用,创建是兜底。** 每次向 Developer 或 Test 派发前都先解析 worker,不能直接照后文示例执行 `terminal create`: - -1. 运行 `orca terminal list --json`,筛选 `connected=true`、`writable=true`、目标 `worktreePath` 一致、标题角色和配置一致的终端。稳定标题使用 `ACK----`,例如 `ACK-DEV-CODEX-TERRA-1`、`ACK-TEST-CODEX-LUNA-1`、`ACK-DEV-CURSOR-AUTO-1`;强档示例为 `ACK-DEV-CODEX-SOL-1`。 -2. 运行 `orca orchestration task-list --status dispatched --json`。若候选 handle 出现在活跃任务的 `assignee_handle`,视为 busy,不复用;不要仅凭终端预览文本或最近输出时间猜忙闲。 -3. 对空闲候选运行 `orca terminal show --terminal --json`,确认仍存活且 worktree、角色和 Agent CLI / 模型档位符合本次要求。符合就直接把该 handle 作为 `dispatch --to` 的目标。 -4. 只有不存在兼容的空闲候选时才创建。若同角色同配置已有 busy worker,使用下一个未占用序号,例如 `ACK-DEV-CODEX-TERRA-2`;不要创建另一个同名终端。 -5. dispatch 成功后,把实际 handle 写入对应 `tasks.yaml` 条目的 `dispatch.worker`。下一轮先尝试该 handle;若它已消失、断开、不可写、worktree/角色/档位不兼容或正忙,再回到第 1 步选择其它 worker。 - -`tasks.yaml.dispatch.worker` 记录“这个任务实际用了谁”,便于续跑和审计;`orca terminal list` 记录“谁现在还活着”,是运行时存活状态的事实源。不要另建一份永久 worker 池,因为终端关闭后其中的 handle 会过期。 - -复用决策: +v0.10 **不自动复用任何既有终端**。`receiptHash` 是无密钥 checksum,Orca 当前的 +terminal metadata 也不包含原始 Agent argv、模型或权限 attestation;两者即使完全 +匹配,仍不足以证明旧终端由可信 launcher 以目标 profile 创建。标题、preview、分支 +名或 worker 自报更不能作为证明。 | 情况 | 动作 | |------|------| -| 任务已记录 worker,且该 handle 存活、兼容、空闲 | 优先复用原 handle | -| 有其它同 worktree、同角色、兼容的空闲 worker | 复用该 handle | -| 同角色 worker 存在但都 busy | 创建下一编号的临时并发 worker | -| worker 已关闭、断开或不存在 | 创建新 worker | -| 模型升级或 worktree 不同 | 不复用不兼容 worker,创建对应档位/路径的新 worker | +| 历史 receipt 与 live metadata 看似完全匹配 | 只作审计,不自动复用;重新 launch | +| 没有 receipt,或只有同名终端 | 不复用;重新 launch | +| 需要并发 worker | 使用下一 `slot` 重新 launch | +| Developer 升级模型档位 | 选择强档安全 profile,重新 plan/launch | + +只有刚刚成功返回的 `launch` 标准输出中的 fresh handle,才可用于紧随其后的本次 +dispatch。未来若 Orca/ACP 增加启动参数 attestation,或 ACK 接入项目外可信签发 +通道,再单独设计自动复用;不能用项目文档声明跳过这条限制。 --- -## 给 worker 终端固定模型 +## 通过 launcher 创建 worker -**编排层不设模型。** `task-create` / `dispatch` 都没有 `--model`;`dispatch --to ` 只是把任务投给一个已存在的终端,用的是那个终端里 agent 会话启动时的模型。要固定模型,就在 **创建 worker 终端** 时用 agent CLI 的模型参数: +**模型不由 Orca dispatch 设置。** `task-create` / `dispatch` 只投递任务;Agent CLI、 +模型、reasoning effort 和权限由 `tasks.yaml.project.orchestration` 的结构化 profile +决定,并且只能由 ACK launcher 构造。 -默认沿用 Coordinator 当前所在的 Agent CLI:Cursor Coordinator 创建 Cursor worker,Codex Coordinator 创建 Codex worker。不要通过询问模型来猜运行环境;以当前 CLI / 终端环境为准。除非项目 overlay 或用户明确指定,否则不跨 Agent CLI 创建 worker。 +先只读生成计划: ```bash -# Cursor:Test / Developer worker 用 auto 模型,并按项目要求启用 YOLO -orca terminal create --worktree path: \ - --command "cursor-agent --yolo --model auto" --title "ACK-DEV-CURSOR-AUTO-1" --json - -# 需要更强模型时改成具体模型 -orca terminal create --worktree path: \ - --command "cursor-agent --yolo --model claude-opus-4-8-thinking-high" --title "ACK-DEV-CURSOR-STRONG-1" --json - -# Codex:Developer worker -orca terminal create --worktree path: \ - --command "codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium" \ - --title "ACK-DEV-CODEX-TERRA-1" --json - -# Codex:Test worker -orca terminal create --worktree path: \ - --command "codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low" \ - --title "ACK-TEST-CODEX-LUNA-1" --json - -# Codex:复杂 Developer 任务升级 -orca terminal create --worktree path: \ - --command "codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-sol -c model_reasoning_effort=high" \ - --title "ACK-DEV-CODEX-SOL-1" --json +python3 /scripts/launch_worker.py plan \ + --project-root --task-id \ + --attempt-id -A --role \ + --profile-id --worktree --slot <1..99> ``` -这些创建命令只在复用流程找不到空闲兼容 worker 时执行。拿到返回的 handle 后再 `task-create` + `dispatch --to `,并把 handle 写入 `tasks.yaml` 的 `dispatch.worker`。模型档位与选型策略见 `model-routing.md`。 - -需要隔离/并行时,先建新 worktree 再在其中起 worker(是否新建见 `closed-loop.md` §「子任务放哪」): +确认计划中的任务、attempt、角色、profile hash、绝对 worktree 和安全权限模式后,再 +执行唯一启动入口: ```bash -orca worktree create --name --base-branch --json -orca terminal create --worktree path: --command "cursor-agent --yolo --model auto" --title "ACK-DEV-CURSOR-AUTO-1" --json +python3 /scripts/launch_worker.py launch \ + --project-root --task-id \ + --attempt-id -A --role \ + --profile-id --worktree --slot <1..99> \ + --expected-launch-fingerprint ``` -- `cursor-agent --list-models` 可列出合法模型;`auto` 表示由 Cursor 自动选型。 -- Codex 用 `-m / --model` 指定模型,用 `-c model_reasoning_effort=` 固定推理档位;不要把省略 `-m` 当作 Cursor `auto` 的等价物。 -- 本项目要求 Cursor worker 带 `--yolo`,Codex worker 带 `--dangerously-bypass-approvals-and-sandbox`;创建前先运行项目覆盖层指定的校验器。 -- 其它 CLI(opencode 等)用各自的模型参数或配置,`--command` 相应替换。 -- 若 worker 是已在跑的会话(用 `--inject` 投递),模型已由该会话启动时决定,无法在 dispatch 时改;要换模型需新建终端。 +`launch` 必须携带刚才审阅的 `plan.launchFingerprint`;若 profile、任务/attempt、 +worktree、CLI identity/version 或精确 argv 漂移,创建前就失败。它内部调用当前 +Orca 后端并完成终端绑定;Coordinator 不自行执行或拼装 Orca 的底层终端创建文本, +也不直接调用 launcher 的私有 bootstrap。 +若 launcher 报告 `indeterminate` 或 `reconcile required`,说明终端可能已经创建且 +持久关闭尚未得到可信确认;此时先按返回的 launch ID/record 和 Orca live state +人工核对,禁止直接重复执行 `launch`。 +标准输出是单一 JSON。成功后: + +1. 校验返回 receipt 的 `receiptHash`; +2. 把 receipt 原样追加到 `tasks.yaml.workerReceipts`; +3. 把 `receipt.id` 写入当前任务 `dispatch..receiptId`,把 `profileId` 写入 + `dispatch..profileId`,把本轮 `-A` 写入 + `dispatch..attemptId`;校验器会要求 receipt 的 task/role/profile/attempt + 与当前 ACK 任务完全一致; +4. 再用 receipt binding 中的 handle 创建/派发 Orca 子任务。 + +profile 只允许 `read-only` 或 `workspace-write`。v0.10 不提供可信的 full-access 授权 +通道;bypass、YOLO/force、关闭 sandbox 及其它 full-access 请求必须由 schema 和 +launcher fail closed,不能改用手写命令兜底。模型档位与升级规则见 +`model-routing.md`。 + +需要隔离/并行时,可以先用 Orca 创建 worktree。Coordinator 必须先把返回的绝对 +路径加入 `project.orchestration.allowedWorktrees` 并重新校验 `tasks.yaml`,然后才在 +该路径上走 `plan` -> 带 expected fingerprint 的 `launch`,并把 receipt 留作审计。 +既有会话可以由用户直接操作,但不能进入 ACK v0.10 的自动派发信任路径。 --- diff --git a/skills/ack/scripts/launch_worker.py b/skills/ack/scripts/launch_worker.py new file mode 100755 index 0000000..467d7be --- /dev/null +++ b/skills/ack/scripts/launch_worker.py @@ -0,0 +1,1636 @@ +#!/usr/bin/env python3 +"""Launch an ACK worker from a validated, structured profile. + +Public commands: + profile-hash Print the canonical hash for one configured profile. + plan Validate and print the exact launch plan without creating a terminal. + launch Create an Orca terminal through a fixed bootstrap command. + +The private ``_bootstrap`` command accepts only an opaque hexadecimal launch ID. +It re-reads the authoritative task board, revalidates the profile and worktree, +then starts the Agent CLI with an argv array and ``shell=False``. +""" + +from __future__ import annotations + +import argparse +import fcntl +import hashlib +import hmac +import json +import os +import pwd +import re +import secrets +import select +import shlex +import stat +import subprocess +import sys +import time +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Iterator + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from validate_tasks import load_document, validate_builtin # noqa: E402 +from worker_profiles import ( # noqa: E402 + LAUNCH_PROTOCOL_VERSION, + canonical_sha256, + profile_hash, + render_worker_argv, + validate_routing_document, +) + +PROTOCOL_VERSION = LAUNCH_PROTOCOL_VERSION +RECEIPT_VERSION = 1 +ENVIRONMENT_POLICY = "per-cli-allowlist-v1" +TASKS_RELATIVE_PATH = Path("docs/ack/tasks.yaml") +MAX_CONTROL_OUTPUT = 1024 * 1024 +MAX_RECORD_SIZE = 256 * 1024 +LAUNCH_TTL_SECONDS = 120 +BOOTSTRAP_READY_TIMEOUT_SECONDS = 10 +CONTROL_TIMEOUT_SECONDS = 30 +LAUNCH_ID_RE = re.compile(r"^[0-9a-f]{64}$") +PROFILE_ID_RE = re.compile(r"^[a-z][a-z0-9-]{1,63}$") +TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") + +CONTROL_ENVIRONMENT_NAMES = { + "COLORTERM", + "LANG", + "LC_ALL", + "NO_COLOR", + "TERM", + "TZ", +} +WORKER_ENVIRONMENT_NAMES = CONTROL_ENVIRONMENT_NAMES | { + "HTTPS_PROXY", + "HTTP_PROXY", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "https_proxy", + "http_proxy", + "no_proxy", + "NO_PROXY", +} +WORKER_CREDENTIAL_NAMES = { + "codex": frozenset({"AZURE_OPENAI_API_KEY", "OPENAI_API_KEY"}), + "cursor-agent": frozenset({"CURSOR_API_KEY"}), +} +INHERITED_ENVIRONMENT_PREFIXES = ( + "LC_", +) + + +class LaunchError(RuntimeError): + """A deterministic launch validation or runtime error.""" + + +class IndeterminateLaunch(LaunchError): + """Orca may have created a terminal, but no safe identity was obtained.""" + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def format_timestamp(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def parse_timestamp(value: object, label: str) -> datetime: + if not isinstance(value, str) or not value: + raise LaunchError(f"{label} 必须是 RFC3339 时间字符串") + normalized = value[:-1] + "+00:00" if value.endswith("Z") else value + try: + parsed = datetime.fromisoformat(normalized) + except ValueError as exc: + raise LaunchError(f"{label} 不是合法 RFC3339 时间") from exc + if parsed.tzinfo is None: + raise LaunchError(f"{label} 必须包含时区") + return parsed.astimezone(timezone.utc) + + +def safe_identity_text(value: object, label: str, *, max_length: int = 512) -> str: + if ( + not isinstance(value, str) + or not value.strip() + or value != value.strip() + or len(value) > max_length + or any(character in value for character in ("\0", "\n", "\r")) + ): + raise LaunchError(f"{label} 必须是安全的单行非空字符串") + return value + + +def account_identity() -> tuple[Path, str]: + account = pwd.getpwuid(os.getuid()) + home = Path(account.pw_dir).resolve(strict=True) + if not home.is_dir(): + raise LaunchError("当前用户 home 不是可访问目录") + return home, account.pw_name + + +def trusted_path_entries() -> list[Path]: + home, _ = account_identity() + candidates = [ + home / ".local" / "bin", + home / ".local" / "share" / "mise" / "shims", + home / ".cargo" / "bin", + Path("/home/linuxbrew/.linuxbrew/bin"), + Path("/usr/local/go/bin"), + Path("/usr/local/bin"), + Path("/usr/bin"), + Path("/bin"), + ] + result: list[Path] = [] + for candidate in candidates: + try: + resolved = candidate.resolve(strict=True) + except OSError: + continue + if resolved.is_dir() and resolved not in result: + result.append(resolved) + return result + + +def _sanitized_environment(allowed_names: frozenset[str] | set[str]) -> dict[str, str]: + """Build an environment from exact names plus locale categories.""" + + home, username = account_identity() + result = { + "HOME": str(home), + "LOGNAME": username, + "PATH": os.pathsep.join(str(path) for path in trusted_path_entries()), + "USER": username, + } + for name, value in os.environ.items(): + if name in allowed_names or any( + name.startswith(prefix) for prefix in INHERITED_ENVIRONMENT_PREFIXES + ): + if "\0" not in value: + result[name] = value + return result + + +def control_environment() -> dict[str, str]: + """Return a credential-free environment for Git, Orca, and CLI probes.""" + + return _sanitized_environment(CONTROL_ENVIRONMENT_NAMES) + + +def worker_environment(cli: str) -> dict[str, str]: + """Return only the supported CLI's own credentials and common runtime data.""" + + credential_names = WORKER_CREDENTIAL_NAMES.get(cli) + if credential_names is None: + raise LaunchError(f"不支持的 worker CLI 环境: {cli}") + return _sanitized_environment(WORKER_ENVIRONMENT_NAMES | credential_names) + + +def reject_duplicate_or_separator_args(argv: list[str]) -> None: + seen: set[str] = set() + for token in argv: + if token == "--": + raise LaunchError("不接受 -- 分隔符或额外位置参数") + if not token.startswith("--"): + continue + name = token.split("=", 1)[0] + if name in seen: + raise LaunchError(f"命令行参数不能重复: {name}") + seen.add(name) + + +def _path_has_parent_reference(value: str) -> bool: + return ".." in Path(value).parts + + +def _assert_no_symlink_components(path: Path, label: str) -> None: + if not path.is_absolute(): + raise LaunchError(f"{label}必须是绝对路径: {path}") + current = Path(path.anchor) + for part in path.parts[1:]: + current /= part + try: + metadata = os.lstat(current) + except OSError as exc: + raise LaunchError(f"{label}不存在或不可访问: {current}") from exc + if stat.S_ISLNK(metadata.st_mode): + raise LaunchError(f"{label}不能包含 symlink: {current}") + + +def canonical_directory(value: str, label: str) -> Path: + if not value or _path_has_parent_reference(value): + raise LaunchError(f"{label}必须是无 '..' 的绝对目录") + raw = Path(value) + _assert_no_symlink_components(raw, label) + try: + resolved = raw.resolve(strict=True) + except OSError as exc: + raise LaunchError(f"{label}不存在: {raw}") from exc + if resolved != raw: + raise LaunchError(f"{label}必须使用规范绝对路径: {resolved}") + if not resolved.is_dir(): + raise LaunchError(f"{label}不是目录: {resolved}") + return resolved + + +def authoritative_tasks_path(project_root: Path) -> Path: + tasks_path = project_root / TASKS_RELATIVE_PATH + _assert_no_symlink_components(tasks_path, "任务板路径") + metadata = tasks_path.stat() + if not stat.S_ISREG(metadata.st_mode): + raise LaunchError(f"任务板必须是普通文件: {tasks_path}") + if metadata.st_size > MAX_RECORD_SIZE: + raise LaunchError(f"任务板超过 {MAX_RECORD_SIZE} 字节上限") + return tasks_path + + +def load_authoritative_board(project_root_value: str) -> tuple[Path, dict]: + project_root = canonical_directory(project_root_value, "项目根目录") + tasks_path = authoritative_tasks_path(project_root) + board = load_document(tasks_path) + errors = validate_builtin(board) + errors.extend(validate_routing_document(board)) + if errors: + formatted = "\n".join(f" - {error}" for error in dict.fromkeys(errors)) + raise LaunchError(f"任务板未通过 worker 路由校验:\n{formatted}") + + project = board.get("project") + repo_path = project.get("repoPath") if isinstance(project, dict) else None + if not isinstance(repo_path, str): + raise LaunchError("project.repoPath 必须配置为项目根绝对路径") + configured_root = canonical_directory(repo_path, "project.repoPath") + if configured_root != project_root: + raise LaunchError( + f"project.repoPath 与 --project-root 不一致: {configured_root}" + ) + return project_root, board + + +def resolve_executable(name: str) -> Path: + supported = {"codex", "cursor-agent", "git", "orca"} + if name not in supported: + raise LaunchError(f"不支持的可执行文件: {name}") + search_paths = trusted_path_entries() + if name == "git": + search_paths = [ + path for path in search_paths if str(path) in {"/usr/local/bin", "/usr/bin", "/bin"} + ] + for directory in search_paths: + candidate = directory / name + try: + candidate_metadata = os.lstat(candidate) + resolved = candidate.resolve(strict=True) + metadata = resolved.stat() + except OSError: + continue + if not ( + stat.S_ISREG(candidate_metadata.st_mode) + or stat.S_ISLNK(candidate_metadata.st_mode) + ): + continue + if not stat.S_ISREG(metadata.st_mode) or not os.access(resolved, os.X_OK): + continue + if metadata.st_uid not in {0, os.getuid()}: + continue + if stat.S_IMODE(metadata.st_mode) & 0o022: + continue + if resolved.name != name: + continue + return resolved + raise LaunchError( + f"找不到可信 {name};只搜索固定用户工具目录和系统目录,不读取 PATH" + ) + + +def run_process( + argv: list[str], + *, + timeout: int = CONTROL_TIMEOUT_SECONDS, + indeterminate_on_timeout: bool = False, +) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + argv, + shell=False, + check=False, + capture_output=True, + text=True, + timeout=timeout, + env=control_environment(), + ) + except subprocess.TimeoutExpired as exc: + error_type = IndeterminateLaunch if indeterminate_on_timeout else LaunchError + raise error_type(f"命令超时: {argv[0]}") from exc + + +def run_text(argv: list[str], label: str) -> str: + completed = run_process(argv) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).strip()[:2000] + raise LaunchError(f"{label}失败(exit {completed.returncode}): {detail}") + output = completed.stdout.strip() + if not output: + raise LaunchError(f"{label}没有输出") + if len(output.encode("utf-8")) > MAX_CONTROL_OUTPUT: + raise LaunchError(f"{label}输出超过安全上限") + return output + + +def parse_json_output(output: str, label: str) -> dict: + if len(output.encode("utf-8")) > MAX_CONTROL_OUTPUT: + raise LaunchError(f"{label} JSON 超过安全上限") + + def reject_duplicate(pairs: list[tuple[str, object]]) -> dict: + result: dict = {} + for key, value in pairs: + if key in result: + raise LaunchError(f"{label} JSON 存在重复键: {key}") + result[key] = value + return result + + try: + value = json.loads(output, object_pairs_hook=reject_duplicate) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise LaunchError(f"{label} 返回畸形 JSON") from exc + if not isinstance(value, dict): + raise LaunchError(f"{label} JSON 顶层必须是对象") + if value.get("ok") is False: + raise LaunchError(f"{label} 返回 ok=false") + return value + + +def run_json(argv: list[str], label: str) -> dict: + response = parse_json_output(run_text(argv, label), label) + if response.get("ok") is not True: + raise LaunchError(f"{label} 未明确返回 ok=true") + return response + + +def run_orca_create(argv: list[str]) -> tuple[dict, str]: + """Run Orca's mutating create once and obtain a safe terminal identity. + + Once the mutating call is attempted, any timeout, transport/decode error, + interruption, non-zero exit, malformed response, or response without a + handle is indeterminate: Orca may already have created a terminal, so + callers must not retry automatically. + """ + + label = "orca terminal create" + try: + completed = run_process(argv, indeterminate_on_timeout=True) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).strip()[:2000] + raise IndeterminateLaunch( + f"{label} 返回 exit {completed.returncode},可能已创建终端: {detail}" + ) + output = completed.stdout.strip() + if not output: + raise IndeterminateLaunch(f"{label} 无响应,可能已创建终端") + response = parse_json_output(output, label) + if response.get("ok") is not True: + raise IndeterminateLaunch( + f"{label} 未明确返回 ok=true,可能已创建终端" + ) + handle = terminal_handle_from_create(response) + if not handle: + raise IndeterminateLaunch(f"{label} 响应缺少 handle,可能已创建终端") + return response, handle + except IndeterminateLaunch: + raise + except BaseException as exc: + detail = str(exc).strip() or type(exc).__name__ + raise IndeterminateLaunch( + f"{label} 调用或响应处理异常,可能已创建终端: " + f"{type(exc).__name__}: {detail[:2000]}" + ) from exc + + +def terminal_metadata(response: dict, expected_handle: str) -> dict | None: + """Read only Orca's documented result.terminal/result handle shapes.""" + + result = response.get("result") + if not isinstance(result, dict): + return None + terminal = result.get("terminal") + candidates = [ + candidate + for candidate in (terminal, result) + if isinstance(candidate, dict) and candidate.get("handle") == expected_handle + ] + if len(candidates) != 1: + return None + return candidates[0] + + +def terminal_handle_from_create(response: dict) -> str | None: + result = response.get("result") + if not isinstance(result, dict): + return None + terminal = result.get("terminal") + values = [ + candidate.get("handle") + for candidate in (terminal, result) + if isinstance(candidate, dict) + and isinstance(candidate.get("handle"), str) + and candidate.get("handle") + ] + unique = set(values) + if len(values) != 1 or len(unique) != 1: + return None + try: + return safe_identity_text(values[0], "Orca terminal handle") + except LaunchError: + return None + + +def git_output(git: Path, directory: Path, *args: str) -> str: + return run_text( + [str(git), "-C", str(directory), *args], + f"git {' '.join(args)}", + ).splitlines()[0] + + +def git_common_directory(git: Path, worktree: Path) -> Path: + raw = git_output(git, worktree, "rev-parse", "--git-common-dir") + candidate = Path(raw) + if not candidate.is_absolute(): + candidate = worktree / candidate + try: + return candidate.resolve(strict=True) + except OSError as exc: + raise LaunchError(f"无法解析 Git common-dir: {candidate}") from exc + + +def registered_git_worktrees(git: Path, project_root: Path) -> set[Path]: + output = run_text( + [ + str(git), + "-C", + str(project_root), + "worktree", + "list", + "--porcelain", + "-z", + ], + "git worktree list", + ) + paths: set[Path] = set() + for record in output.split("\0\0"): + if not record: + continue + fields = record.split("\0") + if not fields or not fields[0].startswith("worktree "): + raise LaunchError("git worktree list 返回畸形 porcelain 记录") + raw_path = fields[0][len("worktree ") :] + path = Path(raw_path) + if ( + not path.is_absolute() + or _path_has_parent_reference(raw_path) + or os.path.normpath(raw_path) != raw_path + ): + raise LaunchError("git worktree list 返回非规范绝对路径") + if path in paths: + raise LaunchError(f"git worktree list 返回重复路径: {path}") + paths.add(path) + if not paths: + raise LaunchError("git worktree list 未返回任何注册 worktree") + return paths + + +def assert_git_control_entry(worktree: Path) -> None: + control_path = worktree / ".git" + try: + metadata = os.lstat(control_path) + except OSError as exc: + raise LaunchError(f"worktree 缺少 .git 控制入口: {worktree}") from exc + if stat.S_ISLNK(metadata.st_mode): + raise LaunchError(f"worktree .git 不能是 symlink: {control_path}") + if not (stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode)): + raise LaunchError(f"worktree .git 必须是目录或普通 gitfile: {control_path}") + + +def capture_worktree_identity( + project_root: Path, + worktree_value: str, + allowed_worktrees: object, +) -> dict: + if not isinstance(allowed_worktrees, list): + raise LaunchError("project.orchestration.allowedWorktrees 必须是列表") + worktree = canonical_directory(worktree_value, "worker worktree") + configured_paths: list[Path] = [] + for index, configured in enumerate(allowed_worktrees): + if not isinstance(configured, str): + raise LaunchError(f"allowedWorktrees[{index}] 必须是字符串") + configured_paths.append( + canonical_directory(configured, f"allowedWorktrees[{index}]") + ) + if worktree not in configured_paths: + raise LaunchError(f"worker worktree 不在 allowedWorktrees 中: {worktree}") + + git = resolve_executable("git") + assert_git_control_entry(project_root) + assert_git_control_entry(worktree) + top_level = canonical_directory( + git_output(git, worktree, "rev-parse", "--show-toplevel"), + "Git worktree top-level", + ) + if top_level != worktree: + raise LaunchError("worker worktree 必须是 Git worktree 根目录,不能是子目录") + project_top = canonical_directory( + git_output(git, project_root, "rev-parse", "--show-toplevel"), + "项目 Git top-level", + ) + if project_top != project_root: + raise LaunchError("--project-root 必须是 Git worktree 根目录") + registered = registered_git_worktrees(git, project_root) + if project_root not in registered: + raise LaunchError("项目根目录不在 git worktree 注册表中") + if worktree not in registered: + raise LaunchError("worker worktree 未出现在 git worktree list 中") + + common = git_common_directory(git, worktree) + project_common = git_common_directory(git, project_root) + common_stat = common.stat() + project_common_stat = project_common.stat() + if ( + common_stat.st_dev, + common_stat.st_ino, + ) != ( + project_common_stat.st_dev, + project_common_stat.st_ino, + ): + raise LaunchError("worker worktree 不属于项目的 Git common-dir") + + worktree_stat = worktree.stat() + return { + "path": str(worktree), + "device": worktree_stat.st_dev, + "inode": worktree_stat.st_ino, + "gitCommonDir": str(common), + "gitCommonDevice": common_stat.st_dev, + "gitCommonInode": common_stat.st_ino, + } + + +def assert_identity_current(identity: dict, label: str) -> None: + path = canonical_directory(str(identity.get("path", "")), label) + metadata = path.stat() + if ( + metadata.st_dev, + metadata.st_ino, + ) != ( + identity.get("device"), + identity.get("inode"), + ): + raise LaunchError(f"{label} inode 已变化: {path}") + common = canonical_directory( + str(identity.get("gitCommonDir", "")), + f"{label} Git common-dir", + ) + common_metadata = common.stat() + if ( + common_metadata.st_dev, + common_metadata.st_ino, + ) != ( + identity.get("gitCommonDevice"), + identity.get("gitCommonInode"), + ): + raise LaunchError(f"{label} Git common-dir identity 已变化") + + +def find_task(board: dict, task_id: str) -> dict: + tasks = board.get("tasks") + if not isinstance(tasks, list): + raise LaunchError("tasks 必须是列表") + matches = [ + task + for task in tasks + if isinstance(task, dict) and task.get("id") == task_id + ] + if len(matches) != 1: + raise LaunchError(f"任务必须唯一存在: {task_id}") + return matches[0] + + +def build_plan( + *, + project_root_value: str, + task_id: str, + attempt_id: str, + role: str, + profile_id: str, + worktree_value: str, + slot: int, +) -> dict: + if not TASK_ID_RE.fullmatch(task_id): + raise LaunchError("task-id 只允许字母、数字、点、下划线和连字符") + if attempt_id not in {f"{task_id}-A1", f"{task_id}-A2", f"{task_id}-A3"}: + raise LaunchError("attempt-id 必须精确为 -A1..A3") + if role not in {"developer", "test"}: + raise LaunchError("role 必须是 developer 或 test") + if not PROFILE_ID_RE.fullmatch(profile_id): + raise LaunchError("profile-id 格式非法") + if not isinstance(slot, int) or isinstance(slot, bool) or not 1 <= slot <= 99: + raise LaunchError("slot 必须是 1..99 的整数") + + project_root, board = load_authoritative_board(project_root_value) + find_task(board, task_id) + project = board["project"] + orchestration = project.get("orchestration") + if not isinstance(orchestration, dict): + raise LaunchError("旧任务板缺少 project.orchestration,只能使用手动模式") + if orchestration.get("mode") != "orca": + raise LaunchError("project.orchestration.mode 不是 orca,拒绝自动创建 worker") + profiles = orchestration.get("profiles") + profile = profiles.get(profile_id) if isinstance(profiles, dict) else None + if not isinstance(profile, dict): + raise LaunchError(f"找不到结构化 profile: {profile_id}") + if profile.get("role") != role: + raise LaunchError( + f"profile {profile_id} 的角色是 {profile.get('role')!r},不是 {role}" + ) + + worktree = capture_worktree_identity( + project_root, + worktree_value, + orchestration.get("allowedWorktrees"), + ) + executable = resolve_executable(str(profile["cli"])) + executable_stat = executable.stat() + cli_version = run_text([str(executable), "--version"], "读取 Agent CLI 版本") + if len(cli_version) > 256 or any(ord(char) < 32 for char in cli_version): + raise LaunchError("Agent CLI 版本输出包含控制字符或过长") + + argv = render_worker_argv(profile, str(executable), worktree["path"]) + requested = { + "cli": profile["cli"], + "tier": profile["tier"], + "model": profile["model"], + "reasoningEffort": profile["reasoningEffort"], + "permissionMode": profile["permissionMode"], + "executable": str(executable), + "executableDevice": executable_stat.st_dev, + "executableInode": executable_stat.st_ino, + "cliVersion": cli_version, + "argv": argv, + "argvHash": canonical_sha256(argv), + "environmentPolicy": ENVIRONMENT_POLICY, + } + current_profile_hash = profile_hash( + profile, + profile_version=orchestration["profileVersion"], + ) + created_for = { + "taskId": task_id, + "attemptId": attempt_id, + "role": role, + } + launch_fingerprint = canonical_sha256( + { + "protocolVersion": PROTOCOL_VERSION, + "backend": "orca", + "profileId": profile_id, + "profileHash": current_profile_hash, + "createdFor": created_for, + "worktree": worktree, + "requested": requested, + "slot": slot, + } + ) + cli_label = "CODEX" if profile["cli"] == "codex" else "CURSOR" + role_label = "DEV" if role == "developer" else "TEST" + digest_short = launch_fingerprint.split(":", 1)[-1][:10] + title = ( + f"ACK-{role_label}-{cli_label}-{str(profile['tier']).upper()}-" + f"{digest_short}-{slot}" + ) + return { + "protocolVersion": PROTOCOL_VERSION, + "backend": "orca", + "projectRoot": str(project_root), + "taskId": task_id, + "attemptId": attempt_id, + "role": role, + "profileId": profile_id, + "profileHash": current_profile_hash, + "launchFingerprint": launch_fingerprint, + "worktree": worktree, + "requested": requested, + "slot": slot, + "title": title, + } + + +def record_directory() -> Path: + root = Path("/tmp") / f"ack-worker-launch-{os.getuid()}" + try: + os.mkdir(root, 0o700) + except FileExistsError: + pass + metadata = os.lstat(root) + if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + raise LaunchError(f"启动记录目录不是普通目录: {root}") + if metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o700: + raise LaunchError(f"启动记录目录必须由当前用户拥有且权限为 0700: {root}") + return root + + +def record_path(launch_id: str) -> Path: + if not LAUNCH_ID_RE.fullmatch(launch_id): + raise LaunchError("launch-id 必须是 64 位小写十六进制") + return record_directory() / f"{launch_id}.json" + + +def _read_record_file(path: Path) -> dict: + file_descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + try: + metadata = os.fstat(file_descriptor) + _assert_private_file(metadata, "启动记录") + if metadata.st_size > MAX_RECORD_SIZE: + raise LaunchError("启动记录超过安全上限") + chunks: list[bytes] = [] + remaining = MAX_RECORD_SIZE + 1 + while remaining: + chunk = os.read(file_descriptor, min(65536, remaining)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + content_bytes = b"".join(chunks) + if len(content_bytes) > MAX_RECORD_SIZE: + raise LaunchError("启动记录超过安全上限") + try: + content = content_bytes.decode("utf-8") + except UnicodeDecodeError as exc: + raise LaunchError("启动记录不是 UTF-8") from exc + finally: + os.close(file_descriptor) + value = parse_json_output(content, "启动记录") + if value.get("launchId") != path.stem: + raise LaunchError("启动记录 launchId 与文件名不一致") + return value + + +def _assert_private_file(metadata: os.stat_result, label: str) -> None: + if not stat.S_ISREG(metadata.st_mode): + raise LaunchError(f"{label}必须是普通文件") + if metadata.st_uid != os.getuid(): + raise LaunchError(f"{label}必须由当前用户拥有") + if stat.S_IMODE(metadata.st_mode) != 0o600: + raise LaunchError(f"{label}权限必须精确为 0600") + if metadata.st_nlink != 1: + raise LaunchError(f"{label}不能有额外 hard link") + + +def _write_record_file(path: Path, value: dict, *, exclusive: bool) -> None: + payload = ( + json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + + "\n" + ).encode("utf-8") + if len(payload) > MAX_RECORD_SIZE: + raise LaunchError("启动记录超过安全上限") + if exclusive: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW + file_descriptor = os.open(path, flags, 0o600) + with os.fdopen(file_descriptor, "wb") as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + else: + temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp") + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW + file_descriptor = os.open(temporary, flags, 0o600) + try: + with os.fdopen(file_descriptor, "wb") as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + + +def create_record(value: dict) -> Path: + path = record_path(str(value.get("launchId", ""))) + _write_record_file(path, value, exclusive=True) + return path + + +@contextmanager +def locked_record(launch_id: str) -> Iterator[dict]: + path = record_path(launch_id) + lock_path = path.with_suffix(".lock") + lock_fd = os.open( + lock_path, + os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, + 0o600, + ) + try: + _assert_private_file(os.fstat(lock_fd), "启动记录锁") + fcntl.flock(lock_fd, fcntl.LOCK_EX) + value = _read_record_file(path) + yield value + _write_record_file(path, value, exclusive=False) + finally: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + os.close(lock_fd) + + +def read_record(launch_id: str) -> dict: + with locked_record(launch_id) as value: + return json.loads(json.dumps(value)) + + +def update_record( + launch_id: str, + *, + preserve_cleanup_state: bool = False, + **changes: object, +) -> dict: + with locked_record(launch_id) as value: + if preserve_cleanup_state and isinstance(value.get("cleanup"), dict): + return json.loads(json.dumps(value)) + value.update(changes) + return json.loads(json.dumps(value)) + + +def try_update_record(launch_id: str, **changes: object) -> str | None: + """Attempt an exception-path record update without changing control flow.""" + + try: + update_record(launch_id, **changes) + except BaseException as exc: + detail = str(exc).strip() or type(exc).__name__ + return f"{type(exc).__name__}: {detail[:2000]}" + return None + + +def build_bootstrap_command(launch_id: str) -> str: + if not LAUNCH_ID_RE.fullmatch(launch_id): + raise LaunchError("launch-id 格式非法") + python = Path(sys.executable).resolve(strict=True) + script = Path(__file__).resolve(strict=True) + argv = [ + str(python), + "-I", + str(script), + "_bootstrap", + "--launch-id", + launch_id, + ] + return "exec " + shlex.join(argv) + + +def bootstrap_authorization_hash( + launch_id: str, + launch_fingerprint: str, + nonce: str, +) -> str: + if not LAUNCH_ID_RE.fullmatch(launch_id): + raise LaunchError("launch-id 格式非法") + if not SHA256_RE.fullmatch(launch_fingerprint): + raise LaunchError("launch fingerprint 格式非法") + if not LAUNCH_ID_RE.fullmatch(nonce): + raise LaunchError("bootstrap nonce 格式非法") + return canonical_sha256( + { + "protocolVersion": PROTOCOL_VERSION, + "launchId": launch_id, + "launchFingerprint": launch_fingerprint, + "nonce": nonce, + } + ) + + +def bootstrap_ready_proof( + launch_id: str, + launch_fingerprint: str, + nonce: str, +) -> str: + bootstrap_authorization_hash(launch_id, launch_fingerprint, nonce) + message = ( + f"ack-bootstrap-ready-v{PROTOCOL_VERSION}:" + f"{launch_id}:{launch_fingerprint}" + ).encode("utf-8") + digest = hmac.new(bytes.fromhex(nonce), message, hashlib.sha256).hexdigest() + return f"sha256:{digest}" + + +def runtime_id_from_response(response: dict) -> str: + metadata = response.get("_meta") + runtime_id = metadata.get("runtimeId") if isinstance(metadata, dict) else None + return safe_identity_text(runtime_id, "Orca runtimeId") + + +def run_orca_close( + orca: Path, + handle: str, + expected_runtime_id: str, +) -> dict[str, str]: + """Durably close the created tab and require a bound JSON confirmation.""" + + label = "orca terminal close --tab" + try: + completed = run_process( + [ + str(orca), + "terminal", + "close", + "--terminal", + handle, + "--tab", + "--json", + ], + timeout=10, + indeterminate_on_timeout=True, + ) + except (LaunchError, OSError) as exc: + raise IndeterminateLaunch( + f"{label} 未获得确认,reconcile required: {exc}" + ) from exc + + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).strip()[:2000] + raise IndeterminateLaunch( + f"{label} 返回 exit {completed.returncode},reconcile required: {detail}" + ) + output = completed.stdout.strip() + if not output: + raise IndeterminateLaunch(f"{label} 无响应,reconcile required") + try: + response = parse_json_output(output, label) + if response.get("ok") is not True: + raise LaunchError(f"{label} 未明确返回 ok=true") + runtime_id = runtime_id_from_response(response) + if runtime_id != expected_runtime_id: + raise LaunchError( + f"{label} runtimeId 不匹配: {runtime_id} != {expected_runtime_id}" + ) + result = response.get("result") + close = result.get("close") if isinstance(result, dict) else None + if not isinstance(close, dict): + raise LaunchError(f"{label} 缺少 result.close") + closed_handle = safe_identity_text(close.get("handle"), "close handle") + tab_id = safe_identity_text(close.get("tabId"), "close tabId") + if closed_handle != handle: + raise LaunchError( + f"{label} handle 不匹配: {closed_handle} != {handle}" + ) + if close.get("closeMode") != "tab": + raise LaunchError(f"{label} 未确认整 tab 持久关闭") + except (LaunchError, OSError, ValueError, KeyError) as exc: + raise IndeterminateLaunch( + f"{label} 回执无法确认,reconcile required: {exc}" + ) from exc + + return { + "runtimeId": runtime_id, + "handle": closed_handle, + "tabId": tab_id, + "closeMode": "tab", + } + + +def wait_for_bootstrap(launch_id: str, expected_proof: str) -> dict: + deadline = time.monotonic() + BOOTSTRAP_READY_TIMEOUT_SECONDS + while time.monotonic() < deadline: + value = read_record(launch_id) + state = value.get("state") + if state in {"bootstrap-ready", "ready"}: + if not hmac.compare_digest( + str(value.get("bootstrapProof", "")), + expected_proof, + ): + raise LaunchError("worker bootstrap ready proof 不匹配") + return value + if state in {"failed", "closed", "indeterminate"}: + raise LaunchError( + f"worker bootstrap 未就绪: state={state}, error={value.get('error')}" + ) + time.sleep(0.1) + raise LaunchError("worker bootstrap ready handshake 超时") + + +def validate_terminal_binding(plan: dict, metadata: dict) -> dict[str, str]: + handle = safe_identity_text(metadata.get("handle"), "terminal show handle") + incarnation_id = safe_identity_text( + metadata.get("incarnationId"), + "terminal show incarnationId", + ) + connected = metadata.get("connected") + writable = metadata.get("writable") + if connected is not True or writable is not True: + raise LaunchError("新 worker terminal 必须 connected=true 且 writable=true") + observed_worktree = metadata.get("worktreePath") + if not isinstance(observed_worktree, str) or not observed_worktree: + raise LaunchError("terminal show 缺少可核对的 worktreePath") + observed_path = canonical_directory( + observed_worktree, + "Orca observed worktreePath", + ) + if str(observed_path) != plan["worktree"]["path"]: + raise LaunchError( + "Orca observed worktreePath 与请求不一致: " + f"{observed_path} != {plan['worktree']['path']}" + ) + return { + "handle": handle, + "incarnationId": incarnation_id, + "observedWorktreePath": str(observed_path), + } + + +def build_receipt( + launch_id: str, + plan: dict, + runtime_id: str, + metadata: dict, + created_at: str, +) -> dict: + observed = validate_terminal_binding(plan, metadata) + + receipt = { + "receiptVersion": RECEIPT_VERSION, + "id": f"WR-{launch_id}", + "launchId": launch_id, + "profileId": plan["profileId"], + "profileHash": plan["profileHash"], + "launchFingerprint": plan["launchFingerprint"], + "slot": plan["slot"], + "createdFor": { + "taskId": plan["taskId"], + "attemptId": plan["attemptId"], + "role": plan["role"], + }, + "worktree": plan["worktree"], + "requested": plan["requested"], + "binding": { + "orchestrator": "orca", + "runtimeId": runtime_id, + "handle": observed["handle"], + "incarnationId": observed["incarnationId"], + "observedWorktreePath": observed["observedWorktreePath"], + "connected": True, + "writable": True, + "boundAt": format_timestamp(utc_now()), + }, + "createdAt": created_at, + } + receipt["receiptHash"] = canonical_sha256(receipt) + return receipt + + +def launch_with_orca(plan: dict) -> dict: + launch_id = secrets.token_hex(32) + authorization_nonce = secrets.token_hex(32) + created_at_dt = utc_now() + created_at = format_timestamp(created_at_dt) + record = { + "recordVersion": 1, + "launchId": launch_id, + "state": "prepared", + "createdAt": created_at, + "expiresAt": format_timestamp( + created_at_dt + timedelta(seconds=LAUNCH_TTL_SECONDS) + ), + "plan": plan, + "runtime": None, + "receipt": None, + "authorizationHash": bootstrap_authorization_hash( + launch_id, + plan["launchFingerprint"], + authorization_nonce, + ), + "bootstrapProof": None, + "error": None, + } + record_path_value = create_record(record) + + orca: Path | None = None + handle: str | None = None + runtime_id: str | None = None + try: + orca = resolve_executable("orca") + status_response = run_json([str(orca), "status", "--json"], "orca status") + runtime_id = runtime_id_from_response(status_response) + bootstrap_command = build_bootstrap_command(launch_id) + create_argv = [ + str(orca), + "terminal", + "create", + "--worktree", + f"path:{plan['worktree']['path']}", + "--command", + bootstrap_command, + "--title", + plan["title"], + "--json", + ] + assert_identity_current(plan["worktree"], "worker worktree") + create_response, handle = run_orca_create(create_argv) + create_runtime_id = runtime_id_from_response(create_response) + if create_runtime_id != runtime_id: + raise LaunchError("Orca runtimeId 在 terminal create 期间变化") + initial_show = run_json( + [ + str(orca), + "terminal", + "show", + "--terminal", + handle, + "--json", + ], + "orca terminal show (pre-authorization)", + ) + if runtime_id_from_response(initial_show) != runtime_id: + raise LaunchError("Orca runtimeId 在 terminal create 期间变化") + initial_metadata = terminal_metadata(initial_show, handle) + if initial_metadata is None: + raise LaunchError("terminal show 无法绑定刚创建的 handle") + initial_binding = validate_terminal_binding(plan, initial_metadata) + update_record( + launch_id, + runtime={ + "runtimeId": runtime_id, + "handle": handle, + "incarnationId": initial_binding["incarnationId"], + }, + ) + send_response = run_json( + [ + str(orca), + "terminal", + "send", + "--terminal", + handle, + "--text", + authorization_nonce, + "--enter", + "--json", + ], + "orca terminal send bootstrap authorization", + ) + if runtime_id_from_response(send_response) != runtime_id: + raise LaunchError("Orca runtimeId 在 bootstrap 授权期间变化") + expected_proof = bootstrap_ready_proof( + launch_id, + plan["launchFingerprint"], + authorization_nonce, + ) + wait_for_bootstrap(launch_id, expected_proof) + show_response = run_json( + [ + str(orca), + "terminal", + "show", + "--terminal", + handle, + "--json", + ], + "orca terminal show", + ) + if runtime_id_from_response(show_response) != runtime_id: + raise LaunchError("Orca runtimeId 在 terminal show 期间变化") + metadata = terminal_metadata(show_response, handle) + if metadata is None: + raise LaunchError("terminal show 无法绑定刚创建的 handle") + final_binding = validate_terminal_binding(plan, metadata) + if final_binding["incarnationId"] != initial_binding["incarnationId"]: + raise LaunchError("Orca terminal incarnation 在 bootstrap 期间变化") + assert_identity_current(plan["worktree"], "worker worktree") + receipt = build_receipt( + launch_id, + plan, + runtime_id, + metadata, + created_at, + ) + update_record( + launch_id, + state="ready", + runtime={ + "runtimeId": runtime_id, + "handle": handle, + "incarnationId": receipt["binding"]["incarnationId"], + }, + receipt=receipt, + error=None, + ) + return receipt + except BaseException as exc: + original_error = str(exc) or type(exc).__name__ + if isinstance(exc, IndeterminateLaunch) and not handle: + detail = ( + f"launchId={launch_id}; record={record_path_value}; " + f"{original_error}; reconcile required" + ) + record_error = try_update_record( + launch_id, + state="indeterminate", + error=detail, + ) + if record_error is not None: + detail += f"; record update failed: {record_error}" + raise IndeterminateLaunch(detail) from exc + + if not handle: + record_error = try_update_record( + launch_id, + state="failed", + error=original_error, + ) + if record_error is not None: + detail = ( + f"launchId={launch_id}; record={record_path_value}; " + f"pre-handle failure: {original_error}; " + f"record update failed: {record_error}" + ) + raise LaunchError(detail) from exc + raise + + pending_detail = ( + f"launchId={launch_id}; record={record_path_value}; " + f"post-handle failure: {original_error}; cleanup pending; " + "reconcile required" + ) + pending_record_error = try_update_record( + launch_id, + state="indeterminate", + error=pending_detail, + cleanup={ + "method": "terminal.closeTab", + "runtimeId": runtime_id, + "handle": handle, + "confirmed": False, + "reconcileRequired": True, + }, + ) + try: + if orca is None or runtime_id is None: + raise IndeterminateLaunch( + "缺少 Orca runtime identity,无法确认关闭" + ) + close_confirmation = run_orca_close(orca, handle, runtime_id) + except BaseException as close_exc: + close_error = str(close_exc) or type(close_exc).__name__ + detail = ( + f"launchId={launch_id}; record={record_path_value}; " + f"post-handle failure: {original_error}; " + f"close confirmation failed: {close_error}; reconcile required" + ) + if pending_record_error is not None: + detail += ( + "; pending record update failed: " + f"{pending_record_error}" + ) + reconcile_record_error = try_update_record( + launch_id, + state="indeterminate", + error=detail, + cleanup={ + "method": "terminal.closeTab", + "runtimeId": runtime_id, + "handle": handle, + "confirmed": False, + "reconcileRequired": True, + "error": close_error, + }, + ) + if reconcile_record_error is not None: + detail += ( + "; reconcile record update failed: " + f"{reconcile_record_error}" + ) + raise IndeterminateLaunch(detail) from close_exc + + final_record_error = try_update_record( + launch_id, + state="failed", + error=original_error, + cleanup={ + "method": "terminal.closeTab", + **close_confirmation, + "confirmed": True, + "reconcileRequired": False, + }, + ) + if final_record_error is not None: + detail = ( + f"launchId={launch_id}; record={record_path_value}; " + f"post-handle failure: {original_error}; close confirmed; " + "failed state persistence could not be confirmed: " + f"{final_record_error}; reconcile required" + ) + if pending_record_error is not None: + detail += ( + "; pending record update failed: " + f"{pending_record_error}" + ) + raise IndeterminateLaunch(detail) from exc + raise + + +def bootstrap_worker(launch_id: str) -> int: + try: + with locked_record(launch_id) as record: + if record.get("state") != "prepared": + raise LaunchError( + f"启动记录不能重复消费: state={record.get('state')}" + ) + expires_at = parse_timestamp(record.get("expiresAt"), "expiresAt") + if utc_now() >= expires_at: + raise LaunchError("启动记录已过期") + record["state"] = "bootstrapping" + plan_snapshot = record.get("plan") + if not isinstance(plan_snapshot, dict): + raise LaunchError("启动记录缺少 plan") + authorization_hash = record.get("authorizationHash") + if not isinstance(authorization_hash, str) or not SHA256_RE.fullmatch( + authorization_hash + ): + raise LaunchError("启动记录缺少 bootstrap authorization hash") + + rebuilt = build_plan( + project_root_value=str(plan_snapshot.get("projectRoot", "")), + task_id=str(plan_snapshot.get("taskId", "")), + attempt_id=str(plan_snapshot.get("attemptId", "")), + role=str(plan_snapshot.get("role", "")), + profile_id=str(plan_snapshot.get("profileId", "")), + worktree_value=str( + (plan_snapshot.get("worktree") or {}).get("path", "") + ), + slot=plan_snapshot.get("slot", 0), + ) + if rebuilt != plan_snapshot: + raise LaunchError("bootstrap 重读后的 launch plan 与已审阅快照不一致") + if Path.cwd().resolve(strict=True) != Path(rebuilt["worktree"]["path"]): + raise LaunchError("bootstrap cwd 与已验证 worktree 不一致") + assert_identity_current(rebuilt["worktree"], "bootstrap worktree") + + executable = Path(rebuilt["requested"]["executable"]) + executable_metadata = executable.stat() + if ( + executable_metadata.st_dev, + executable_metadata.st_ino, + ) != ( + rebuilt["requested"]["executableDevice"], + rebuilt["requested"]["executableInode"], + ): + raise LaunchError("Agent CLI executable identity 已变化") + + awaiting_record = update_record( + launch_id, + preserve_cleanup_state=True, + state="awaiting-authorization", + error=None, + ) + if ( + awaiting_record.get("state") != "awaiting-authorization" + or isinstance(awaiting_record.get("cleanup"), dict) + ): + raise LaunchError("bootstrap 已被父进程取消,拒绝等待授权") + readable, _, _ = select.select( + [sys.stdin], + [], + [], + BOOTSTRAP_READY_TIMEOUT_SECONDS, + ) + if not readable: + raise LaunchError("bootstrap authorization 输入超时") + nonce_line = sys.stdin.readline(130) + nonce = nonce_line.rstrip("\r\n") + if not LAUNCH_ID_RE.fullmatch(nonce) or nonce_line not in { + nonce + "\n", + nonce + "\r\n", + }: + raise LaunchError("bootstrap authorization nonce 格式非法") + expected_authorization = bootstrap_authorization_hash( + launch_id, + rebuilt["launchFingerprint"], + nonce, + ) + if not hmac.compare_digest(authorization_hash, expected_authorization): + raise LaunchError("bootstrap authorization hash 不匹配") + + ready_proof = bootstrap_ready_proof( + launch_id, + rebuilt["launchFingerprint"], + nonce, + ) + with locked_record(launch_id) as current_record: + if ( + current_record.get("state") != "awaiting-authorization" + or isinstance(current_record.get("cleanup"), dict) + ): + raise LaunchError("bootstrap 已被父进程取消,拒绝启动 Agent") + if not hmac.compare_digest( + str(current_record.get("authorizationHash", "")), + authorization_hash, + ): + raise LaunchError("bootstrap authorization record 已漂移") + process = subprocess.Popen( + rebuilt["requested"]["argv"], + shell=False, + cwd=rebuilt["worktree"]["path"], + env=worker_environment(str(rebuilt["requested"]["cli"])), + ) + current_record.update( + state="bootstrap-ready", + childPid=process.pid, + bootstrapProof=ready_proof, + error=None, + ) + return_code = process.wait() + update_record( + launch_id, + preserve_cleanup_state=True, + state="closed" if return_code == 0 else "failed", + childExitCode=return_code, + error=None if return_code == 0 else f"Agent CLI exit {return_code}", + ) + return return_code + except BaseException as exc: + try: + update_record( + launch_id, + preserve_cleanup_state=True, + state="failed", + error=str(exc), + ) + except BaseException: + pass + sys.stderr.write(f"ACK worker bootstrap 失败: {exc}\n") + return 1 + + +def add_launch_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--project-root", required=True) + parser.add_argument("--task-id", required=True) + parser.add_argument("--attempt-id", required=True) + parser.add_argument("--role", required=True, choices=("developer", "test")) + parser.add_argument("--profile-id", required=True) + parser.add_argument("--worktree", required=True) + parser.add_argument("--slot", type=int, default=1) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="按 ACK 结构化 profile 规划或启动 worker", + allow_abbrev=False, + ) + subparsers = parser.add_subparsers(dest="action", required=True) + + profile_parser = subparsers.add_parser( + "profile-hash", + help="输出一个 profile 的规范化 SHA-256", + allow_abbrev=False, + ) + profile_parser.add_argument("--project-root", required=True) + profile_parser.add_argument("--profile-id", required=True) + + plan_parser = subparsers.add_parser( + "plan", + help="只校验并输出启动计划,不创建终端", + allow_abbrev=False, + ) + add_launch_arguments(plan_parser) + + launch_parser = subparsers.add_parser( + "launch", + help="通过 Orca 固定 bootstrap 创建 worker", + allow_abbrev=False, + ) + add_launch_arguments(launch_parser) + launch_parser.add_argument( + "--expected-launch-fingerprint", + required=True, + help="必须与刚审阅的 plan.launchFingerprint 精确一致", + ) + + bootstrap_parser = subparsers.add_parser( + "_bootstrap", + help=argparse.SUPPRESS, + allow_abbrev=False, + ) + bootstrap_parser.add_argument("--launch-id", required=True) + return parser + + +def profile_hash_command(project_root_value: str, profile_id: str) -> dict: + if not PROFILE_ID_RE.fullmatch(profile_id): + raise LaunchError("profile-id 格式非法") + _, board = load_authoritative_board(project_root_value) + orchestration = board["project"].get("orchestration") + profiles = orchestration.get("profiles") if isinstance(orchestration, dict) else None + profile = profiles.get(profile_id) if isinstance(profiles, dict) else None + if not isinstance(profile, dict): + raise LaunchError(f"找不到 profile: {profile_id}") + return { + "profileVersion": orchestration["profileVersion"], + "profileId": profile_id, + "profileHash": profile_hash( + profile, + profile_version=orchestration["profileVersion"], + ), + "profile": profile, + } + + +def main(argv: list[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + try: + reject_duplicate_or_separator_args(arguments) + except LaunchError as exc: + sys.stderr.write(f"{exc}\n") + return 2 + + parser = build_parser() + try: + args = parser.parse_args(arguments) + except SystemExit as exc: + return int(exc.code) + + if args.action == "_bootstrap": + if not LAUNCH_ID_RE.fullmatch(args.launch_id): + sys.stderr.write("launch-id 必须是 64 位小写十六进制\n") + return 2 + return bootstrap_worker(args.launch_id) + + try: + if args.action == "profile-hash": + output = profile_hash_command(args.project_root, args.profile_id) + else: + plan = build_plan( + project_root_value=args.project_root, + task_id=args.task_id, + attempt_id=args.attempt_id, + role=args.role, + profile_id=args.profile_id, + worktree_value=args.worktree, + slot=args.slot, + ) + if args.action == "plan": + output = {"mode": "plan", "plan": plan} + else: + if not SHA256_RE.fullmatch(args.expected_launch_fingerprint): + raise LaunchError( + "expected-launch-fingerprint 必须是规范 sha256 值" + ) + if not hmac.compare_digest( + args.expected_launch_fingerprint, + plan["launchFingerprint"], + ): + raise LaunchError( + "当前 launch plan 与已审阅 fingerprint 不一致;" + "请重新执行 plan 并审阅" + ) + output = { + "mode": "launched", + "receipt": launch_with_orca(plan), + } + except IndeterminateLaunch as exc: + sys.stderr.write( + "worker 创建结果不确定;不要直接重试,请先按外部 launch record " + f"reconcile: {exc}\n" + ) + return 1 + except (LaunchError, OSError, ValueError, KeyError) as exc: + sys.stderr.write(f"worker 启动拒绝: {exc}\n") + return 1 + + sys.stdout.write( + json.dumps( + output, + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ) + + "\n" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/ack/scripts/validate_tasks.py b/skills/ack/scripts/validate_tasks.py index ae6dc9f..c429067 100755 --- a/skills/ack/scripts/validate_tasks.py +++ b/skills/ack/scripts/validate_tasks.py @@ -31,6 +31,7 @@ from yaml_subset import ( load_yaml_subset, make_unique_pyyaml_loader, ) +from worker_profiles import validate_routing_document STATUS_ENUM = { "open", @@ -46,7 +47,16 @@ MAX_ROUNDS = 3 KNOWLEDGE_KINDS = {"guardrail", "pitfall", "verification"} KNOWLEDGE_CHECK_RESULTS = {"passed", "failed", "not_applicable"} KNOWLEDGE_REF_RE = re.compile(r"^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$") +TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$") +SEMVER_RE = re.compile( + r"^(0|[1-9][0-9]*)\." + r"(0|[1-9][0-9]*)\." + r"(0|[1-9][0-9]*)" + r"(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" + r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) KNOWLEDGE_SCOPE_FIELDS = { "components", "paths", @@ -76,6 +86,14 @@ KNOWLEDGE_CHECK_FIELDS = { "checkedBy", "checkedAt", } +DISPATCH_FIELDS = { + "taskId", + "dispatchId", + "worker", + "developer", + "test", + "rounds", +} KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = { "kind", "title", @@ -461,6 +479,22 @@ def validate_builtin(data: dict) -> list[str]: "project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml" ) + ack_version = data.get("ackVersion") + version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None + if "ackVersion" in data and version_match is None: + errors.append("ackVersion 必须是合法 SemVer(例如 0.10.0)") + routing_required = ( + (isinstance(project, dict) and "orchestration" in project) + or "workerReceipts" in data + or ( + version_match is not None + and (int(version_match.group(1)), int(version_match.group(2))) + >= (0, 10) + ) + ) + if routing_required: + errors.extend(validate_routing_document(data)) + if "summary" in data: summary = data["summary"] if not isinstance(summary, dict): @@ -494,6 +528,10 @@ def validate_builtin(data: dict) -> list[str]: errors.append(f"{where}: id 必须是非空字符串") else: where = f"tasks[{i}] {tid}" + if routing_required and TASK_ID_RE.fullmatch(tid) is None: + errors.append( + f"{where}: v0.10 自动路由 id 只允许字母、数字、点、下划线和连字符" + ) if tid in seen_ids: errors.append(f"{where}: id 重复") seen_ids.add(tid) @@ -533,6 +571,12 @@ def validate_builtin(data: dict) -> list[str]: dispatch = {} else: dispatch = task["dispatch"] + reject_unknown_fields( + dispatch, + DISPATCH_FIELDS, + f"{where}.dispatch", + errors, + ) validate_string_fields( dispatch, {"taskId", "dispatchId", "worker"}, diff --git a/skills/ack/scripts/validate_worker_command.py b/skills/ack/scripts/validate_worker_command.py index a1a28c2..7ae159f 100755 --- a/skills/ack/scripts/validate_worker_command.py +++ b/skills/ack/scripts/validate_worker_command.py @@ -1,171 +1,30 @@ #!/usr/bin/env python3 -"""校验 Music Pilot 的 Developer/Test worker 启动命令。 +"""Fail-closed migration shim for ACK's removed free-form command validator. -用法: - python3 validate_worker_command.py --role developer --command '' - python3 validate_worker_command.py --role test --command '' - python3 validate_worker_command.py --role developer --upgraded --command '' - python3 validate_worker_command.py --self-test - -退出码:0 通过 / 1 规则不通过 / 2 用法或命令解析错误。 +ACK v0.10 no longer accepts a shell command as worker configuration. Keep this +file only so older documentation or automation fails with a precise migration +message instead of silently retaining the unsafe path. """ from __future__ import annotations -import argparse -import shlex import sys -from pathlib import Path -CODEX_YOLO = "--dangerously-bypass-approvals-and-sandbox" -CURSOR_YOLO = "--yolo" - - -def option_value(tokens: list[str], *names: str) -> str | None: - for index, token in enumerate(tokens): - for name in names: - if token == name and index + 1 < len(tokens): - return tokens[index + 1] - prefix = f"{name}=" - if token.startswith(prefix): - return token[len(prefix) :] - return None - - -def codex_effort(tokens: list[str]) -> str | None: - configs: list[str] = [] - for index, token in enumerate(tokens): - if token in {"-c", "--config"} and index + 1 < len(tokens): - configs.append(tokens[index + 1]) - elif token.startswith("--config="): - configs.append(token.split("=", 1)[1]) - for config in configs: - if config.startswith("model_reasoning_effort="): - return config.split("=", 1)[1].strip('"\'') - return None - - -def validate(role: str, command: str, upgraded: bool = False) -> list[str]: - try: - tokens = shlex.split(command) - except ValueError as exc: - return [f"命令无法解析:{exc}"] - if not tokens: - return ["启动命令不能为空"] - - executable = Path(tokens[0]).name - errors: list[str] = [] - - if upgraded and role != "developer": - errors.append("只有 Developer 可以使用 --upgraded") - - if executable == "codex": - if CODEX_YOLO not in tokens: - errors.append(f"Codex worker 必须包含 {CODEX_YOLO}") - - model = option_value(tokens, "-m", "--model") - effort = codex_effort(tokens) - if upgraded: - expected_model = "gpt-5.6-sol" - allowed_efforts = {"high", "xhigh"} - elif role == "developer": - expected_model = "gpt-5.6-terra" - allowed_efforts = {"medium"} - else: - expected_model = "gpt-5.6-luna" - allowed_efforts = {"low"} - - if model != expected_model: - errors.append( - f"Codex {role} 模型应为 {expected_model},实际为 {model or '未指定'}" - ) - if effort not in allowed_efforts: - expected = "/".join(sorted(allowed_efforts)) - errors.append( - f"Codex {role} reasoning effort 应为 {expected},实际为 {effort or '未指定'}" - ) - elif executable == "cursor-agent": - if upgraded: - errors.append("Cursor worker 不使用 Codex --upgraded 映射") - if CURSOR_YOLO not in tokens: - errors.append(f"Cursor worker 必须显式包含 {CURSOR_YOLO}") - model = option_value(tokens, "--model") - if model != "auto": - errors.append(f"Cursor {role} 模型应为 auto,实际为 {model or '未指定'}") - else: - errors.append(f"不支持的 worker CLI:{executable};只允许 codex 或 cursor-agent") - - return errors - - -def run_self_test() -> int: - cases = [ - ( - "codex developer", - "developer", - f"codex {CODEX_YOLO} -m gpt-5.6-terra -c model_reasoning_effort=medium", - False, - True, - ), - ( - "codex test", - "test", - f"codex {CODEX_YOLO} -m gpt-5.6-luna -c model_reasoning_effort=low", - False, - True, - ), - ( - "codex upgraded developer", - "developer", - f"codex {CODEX_YOLO} -m gpt-5.6-sol -c model_reasoning_effort=high", - True, - True, - ), - ("cursor worker", "test", "cursor-agent --yolo --model auto", False, True), - ("naked codex", "developer", "codex", False, False), - ( - "wrong codex role model", - "test", - f"codex {CODEX_YOLO} -m gpt-5.6-terra -c model_reasoning_effort=medium", - False, - False, - ), - ("cursor without yolo", "developer", "cursor-agent --model auto", False, False), - ] - failures: list[str] = [] - for name, role, command, upgraded, expected_pass in cases: - passed = not validate(role, command, upgraded) - if passed != expected_pass: - failures.append(name) - if failures: - sys.stderr.write("worker 命令校验器自测失败:" + ", ".join(failures) + "\n") - return 1 - print(f"worker 命令校验器自测通过:{len(cases)} 项") - return 0 +MIGRATION_MESSAGE = ( + "ACK v0.10 已停用自由 worker command 校验器;请在 " + "docs/ack/tasks.yaml 的 project.orchestration 中声明 profile,并仅调用 " + "scripts/launch_worker.py profile-hash|plan|launch。" +) def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="校验 Developer/Test worker 启动命令") - parser.add_argument("--role", choices=("developer", "test")) - parser.add_argument("--command") - parser.add_argument("--upgraded", action="store_true", help="校验升级后的 Codex Developer") - parser.add_argument("--self-test", action="store_true") - args = parser.parse_args(argv) - - if args.self_test: - return run_self_test() - if not args.role or not args.command: - parser.error("非自测模式必须同时提供 --role 和 --command") - - errors = validate(args.role, args.command, args.upgraded) - if errors: - sys.stderr.write("worker 启动命令校验失败:\n") - for error in errors: - sys.stderr.write(f" - {error}\n") - return 1 - print(f"worker 启动命令校验通过:role={args.role}") - return 0 + arguments = list(sys.argv[1:] if argv is None else argv) + if arguments == ["--self-test"]: + print("legacy worker command 路径已 fail-closed") + return 0 + sys.stderr.write(MIGRATION_MESSAGE + "\n") + return 2 if __name__ == "__main__": diff --git a/skills/ack/scripts/worker_profiles.py b/skills/ack/scripts/worker_profiles.py new file mode 100644 index 0000000..9d46631 --- /dev/null +++ b/skills/ack/scripts/worker_profiles.py @@ -0,0 +1,992 @@ +#!/usr/bin/env python3 +"""Strict, zero-dependency primitives for ACK worker routing. + +This module deliberately validates structured data rather than accepting a +shell command. It does not invoke Orca, resolve executables, inspect Git, or +write ``tasks.yaml``. A launcher can use the validated profile and the exact +argv renderer below, then persist its externally observed facts as a receipt. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from datetime import datetime +from pathlib import Path +from typing import Any + + +PROFILE_VERSION = 1 +RECEIPT_VERSION = 1 +LAUNCH_PROTOCOL_VERSION = 1 +MAX_ROUNDS = 3 + +ROLES = frozenset({"developer", "test"}) +CLIS = frozenset({"codex", "cursor-agent"}) +TIERS = frozenset({"standard", "strong"}) +REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"}) +PERMISSION_MODES = frozenset({"read-only", "workspace-write"}) +ORCHESTRATION_MODES = frozenset({"orca", "manual"}) +DEFAULT_KEYS = frozenset({"developer", "test", "developerUpgraded"}) + +ORCHESTRATION_FIELDS = frozenset( + { + "profileVersion", + "mode", + "allowedWorktrees", + "modelAllowlist", + "profiles", + "defaults", + } +) +PROFILE_FIELDS = frozenset( + { + "role", + "cli", + "tier", + "model", + "reasoningEffort", + "permissionMode", + } +) +RECEIPT_FIELDS = frozenset( + { + "receiptVersion", + "id", + "launchId", + "profileId", + "profileHash", + "launchFingerprint", + "slot", + "createdFor", + "worktree", + "requested", + "binding", + "createdAt", + "receiptHash", + } +) +CREATED_FOR_FIELDS = frozenset({"taskId", "attemptId", "role"}) +WORKTREE_FIELDS = frozenset( + { + "path", + "device", + "inode", + "gitCommonDir", + "gitCommonDevice", + "gitCommonInode", + } +) +REQUESTED_FIELDS = frozenset( + { + "cli", + "tier", + "model", + "reasoningEffort", + "permissionMode", + "executable", + "executableDevice", + "executableInode", + "cliVersion", + "argv", + "argvHash", + "environmentPolicy", + } +) +BINDING_FIELDS = frozenset( + { + "orchestrator", + "runtimeId", + "handle", + "incarnationId", + "observedWorktreePath", + "connected", + "writable", + "boundAt", + } +) +ROLE_DISPATCH_FIELDS = frozenset( + {"profileId", "receiptId", "attemptId", "taskId", "dispatchId"} +) + +PROFILE_ID_RE = re.compile(r"^[a-z][a-z0-9-]{1,63}$") +MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$") +RECEIPT_ID_RE = re.compile(r"^WR-[0-9a-f]{64}$") +LAUNCH_ID_RE = re.compile(r"^[0-9a-f]{64}$") +TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-3]$") +SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") + + +def _canonical_json(value: Any) -> str: + """Return the one JSON representation used by all hashes in this module.""" + + try: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + except (RecursionError, TypeError, ValueError) as exc: + raise ValueError(f"value is not canonical JSON data: {exc}") from exc + + +def canonical_sha256(value: Any) -> str: + """Hash canonical UTF-8 JSON and return ``sha256:``.""" + + digest = hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest() + return f"sha256:{digest}" + + +def _unknown_fields(value: dict[str, Any], allowed: frozenset[str], where: str) -> list[str]: + unknown = set(value) - allowed + return [ + f"{where}: unknown field {field!r}" + for field in sorted(unknown, key=repr) + ] + + +def _missing_fields(value: dict[str, Any], required: frozenset[str], where: str) -> list[str]: + return [f"{where}: missing field {field!r}" for field in sorted(required - set(value))] + + +def _is_nonempty_text(value: Any, *, max_length: int = 1024) -> bool: + return ( + isinstance(value, str) + and bool(value.strip()) + and len(value) <= max_length + and all(ord(character) >= 32 and ord(character) != 127 for character in value) + ) + + +def _is_absolute_safe_path(value: Any) -> bool: + if not _is_nonempty_text(value, max_length=4096): + return False + path = Path(value) + return ( + path.is_absolute() + and value != os.path.sep + and not value.startswith("//") + and ".." not in path.parts + and os.path.normpath(value) == value + ) + + +def _is_nonnegative_int(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def _is_positive_int(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value > 0 + + +def _is_timestamp(value: Any) -> bool: + if not isinstance(value, str): + return False + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return False + return parsed.tzinfo is not None + + +def validate_profile(profile: Any, *, where: str = "profile") -> list[str]: + """Validate one strict worker profile without consulting its allowlist.""" + + if not isinstance(profile, dict): + return [f"{where}: must be an object"] + + errors = _unknown_fields(profile, PROFILE_FIELDS, where) + errors.extend(_missing_fields(profile, PROFILE_FIELDS, where)) + + role = profile.get("role") + cli = profile.get("cli") + tier = profile.get("tier") + model = profile.get("model") + effort = profile.get("reasoningEffort") + permission = profile.get("permissionMode") + + if not isinstance(role, str) or role not in ROLES: + errors.append(f"{where}.role: must be developer/test") + if not isinstance(cli, str) or cli not in CLIS: + errors.append(f"{where}.cli: must be codex/cursor-agent") + if not isinstance(tier, str) or tier not in TIERS: + errors.append(f"{where}.tier: must be standard/strong") + if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None: + errors.append(f"{where}.model: must be a safe model ID") + if not isinstance(permission, str) or permission not in PERMISSION_MODES: + errors.append( + f"{where}.permissionMode: must be read-only/workspace-write" + ) + + if cli == "codex": + if not isinstance(effort, str) or effort not in REASONING_EFFORTS: + errors.append( + f"{where}.reasoningEffort: Codex requires low/medium/high/xhigh" + ) + elif cli == "cursor-agent" and effort is not None: + errors.append(f"{where}.reasoningEffort: Cursor requires null") + + if role == "test" and tier != "standard": + errors.append(f"{where}.tier: Test may only use standard") + if tier == "strong" and role != "developer": + errors.append(f"{where}.tier: strong may only be used by Developer") + + return errors + + +def _validate_model_allowlist(value: Any, where: str) -> list[str]: + if not isinstance(value, dict): + return [f"{where}: must be an object"] + + errors: list[str] = [] + for cli in sorted(set(value) - CLIS, key=repr): + errors.append(f"{where}: unknown CLI {cli!r}") + for cli, roles in value.items(): + cli_where = f"{where}.{cli}" + if cli not in CLIS: + continue + if not isinstance(roles, dict): + errors.append(f"{cli_where}: must be an object") + continue + if not roles: + errors.append(f"{cli_where}: must not be empty") + for role in sorted(set(roles) - ROLES, key=repr): + errors.append(f"{cli_where}: unknown role {role!r}") + for role, tiers in roles.items(): + role_where = f"{cli_where}.{role}" + if role not in ROLES: + continue + if not isinstance(tiers, dict): + errors.append(f"{role_where}: must be an object") + continue + if not tiers: + errors.append(f"{role_where}: must not be empty") + for tier in sorted(set(tiers) - TIERS, key=repr): + errors.append(f"{role_where}: unknown tier {tier!r}") + if role == "test" and "strong" in tiers: + errors.append(f"{role_where}: Test cannot define a strong allowlist") + for tier, models in tiers.items(): + tier_where = f"{role_where}.{tier}" + if tier not in TIERS: + continue + if not isinstance(models, list) or not models: + errors.append(f"{tier_where}: must be a non-empty model list") + continue + seen: set[str] = set() + for index, model in enumerate(models): + item_where = f"{tier_where}[{index}]" + if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None: + errors.append(f"{item_where}: must be a safe model ID") + elif model in seen: + errors.append(f"{item_where}: duplicate model {model!r}") + else: + seen.add(model) + return errors + + +def _allowed_models( + allowlist: Any, + cli: Any, + role: Any, + tier: Any, +) -> list[str] | None: + if not isinstance(allowlist, dict): + return None + roles = allowlist.get(cli) + if not isinstance(roles, dict): + return None + tiers = roles.get(role) + if not isinstance(tiers, dict): + return None + models = tiers.get(tier) + return models if isinstance(models, list) else None + + +def validate_orchestration( + orchestration: Any, + *, + where: str = "project.orchestration", +) -> list[str]: + """Validate the complete strict routing configuration.""" + + if not isinstance(orchestration, dict): + return [f"{where}: must be an object"] + + errors = _unknown_fields(orchestration, ORCHESTRATION_FIELDS, where) + errors.extend(_missing_fields(orchestration, ORCHESTRATION_FIELDS, where)) + + profile_version = orchestration.get("profileVersion") + mode = orchestration.get("mode") + allowed_worktrees = orchestration.get("allowedWorktrees") + allowlist = orchestration.get("modelAllowlist") + profiles = orchestration.get("profiles") + defaults = orchestration.get("defaults") + + if profile_version != PROFILE_VERSION or isinstance(profile_version, bool): + errors.append(f"{where}.profileVersion: must be {PROFILE_VERSION}") + if not isinstance(mode, str) or mode not in ORCHESTRATION_MODES: + errors.append(f"{where}.mode: must be orca/manual") + + if not isinstance(allowed_worktrees, list): + errors.append(f"{where}.allowedWorktrees: must be a list") + else: + if mode == "orca" and not allowed_worktrees: + errors.append(f"{where}.allowedWorktrees: Orca mode requires at least one path") + seen_worktrees: set[str] = set() + for index, worktree in enumerate(allowed_worktrees): + item_where = f"{where}.allowedWorktrees[{index}]" + if not _is_absolute_safe_path(worktree): + errors.append(f"{item_where}: must be a safe absolute path other than root") + elif worktree in seen_worktrees: + errors.append(f"{item_where}: duplicate worktree {worktree!r}") + else: + seen_worktrees.add(worktree) + + errors.extend(_validate_model_allowlist(allowlist, f"{where}.modelAllowlist")) + + valid_profiles: dict[str, dict[str, Any]] = {} + if not isinstance(profiles, dict): + errors.append(f"{where}.profiles: must be an object") + else: + if mode == "orca" and not profiles: + errors.append(f"{where}.profiles: Orca mode requires profiles") + for profile_id, profile in profiles.items(): + profile_where = f"{where}.profiles.{profile_id}" + if not isinstance(profile_id, str) or PROFILE_ID_RE.fullmatch(profile_id) is None: + errors.append(f"{where}.profiles: invalid profile ID {profile_id!r}") + continue + profile_errors = validate_profile(profile, where=profile_where) + errors.extend(profile_errors) + if profile_errors or not isinstance(profile, dict): + continue + valid_profiles[profile_id] = profile + models = _allowed_models( + allowlist, + profile.get("cli"), + profile.get("role"), + profile.get("tier"), + ) + if profile.get("model") not in (models or []): + errors.append( + f"{profile_where}.model: {profile.get('model')!r} is not allowed " + "for its cli/role/tier" + ) + + if not isinstance(defaults, dict): + errors.append(f"{where}.defaults: must be an object") + else: + for default_key in sorted(set(defaults) - DEFAULT_KEYS, key=repr): + errors.append(f"{where}.defaults: unknown key {default_key!r}") + if mode == "orca": + for role in sorted(ROLES - set(defaults)): + errors.append(f"{where}.defaults: missing role {role!r}") + for default_key, profile_id in defaults.items(): + default_where = f"{where}.defaults.{default_key}" + if default_key not in DEFAULT_KEYS: + continue + if not isinstance(profile_id, str) or PROFILE_ID_RE.fullmatch(profile_id) is None: + errors.append(f"{default_where}: must be a profile ID") + continue + profile = valid_profiles.get(profile_id) + if profile is None: + errors.append(f"{default_where}: unknown or invalid profile {profile_id!r}") + continue + expected_role = "developer" if default_key == "developerUpgraded" else default_key + expected_tier = "strong" if default_key == "developerUpgraded" else "standard" + if profile.get("role") != expected_role: + errors.append(f"{default_where}: profile role must be {expected_role}") + if profile.get("tier") != expected_tier: + errors.append( + f"{default_where}: default profile must use {expected_tier} tier" + ) + if profile.get("permissionMode") not in PERMISSION_MODES: + errors.append(f"{default_where}: default profile has unsafe permissions") + + return errors + + +def profile_hash( + profile: dict[str, Any], + *, + profile_version: int = PROFILE_VERSION, +) -> str: + """Return the canonical hash of a valid strict profile.""" + + errors = validate_profile(profile) + if errors: + raise ValueError("invalid profile: " + "; ".join(errors)) + if ( + not isinstance(profile_version, int) + or isinstance(profile_version, bool) + or profile_version < 1 + ): + raise ValueError("profile_version must be a positive integer") + return canonical_sha256( + { + "profileVersion": profile_version, + "profile": profile, + } + ) + + +def render_worker_argv( + profile: dict[str, Any], + executable: str, + worktree: str, +) -> list[str]: + """Render the only argv shapes allowed by routing profile version 1.""" + + errors = validate_profile(profile) + if errors: + raise ValueError("invalid profile: " + "; ".join(errors)) + if not _is_absolute_safe_path(executable): + raise ValueError("executable must be a safe absolute path other than root") + if Path(executable).name != profile["cli"]: + raise ValueError("executable basename must match profile.cli") + if not _is_absolute_safe_path(worktree): + raise ValueError("worktree must be a safe absolute path other than root") + + model = profile["model"] + permission = profile["permissionMode"] + if profile["cli"] == "codex": + return [ + executable, + "--strict-config", + "--model", + model, + "--config", + f"model_reasoning_effort={profile['reasoningEffort']}", + "--sandbox", + permission, + "--ask-for-approval", + "never", + "--cd", + worktree, + ] + + argv = [executable, "--model", model] + if permission == "read-only": + argv.extend(["--mode", "plan"]) + else: + argv.append("--auto-review") + argv.extend(["--sandbox", "enabled", "--workspace", worktree]) + return argv + + +def receipt_hash(receipt: dict[str, Any]) -> str: + """Hash every receipt field except the self-referential ``receiptHash``.""" + + if not isinstance(receipt, dict): + raise ValueError("receipt must be an object") + payload = {key: value for key, value in receipt.items() if key != "receiptHash"} + return canonical_sha256(payload) + + +def _validate_created_for(value: Any, where: str) -> list[str]: + if not isinstance(value, dict): + return [f"{where}: must be an object"] + errors = _unknown_fields(value, CREATED_FOR_FIELDS, where) + errors.extend(_missing_fields(value, CREATED_FOR_FIELDS, where)) + task_id = value.get("taskId") + attempt_id = value.get("attemptId") + if not isinstance(task_id, str) or TASK_ID_RE.fullmatch(task_id) is None: + errors.append(f"{where}.taskId: must be a safe task ID") + if not isinstance(attempt_id, str) or ATTEMPT_ID_RE.fullmatch(attempt_id) is None: + errors.append(f"{where}.attemptId: must use -A1..A3") + elif isinstance(task_id, str) and not attempt_id.startswith(f"{task_id}-A"): + errors.append(f"{where}.attemptId: must belong to taskId") + role = value.get("role") + if not isinstance(role, str) or role not in ROLES: + errors.append(f"{where}.role: must be developer/test") + return errors + + +def _validate_worktree_snapshot(value: Any, where: str) -> list[str]: + if not isinstance(value, dict): + return [f"{where}: must be an object"] + errors = _unknown_fields(value, WORKTREE_FIELDS, where) + errors.extend(_missing_fields(value, WORKTREE_FIELDS, where)) + for field in ("path", "gitCommonDir"): + if not _is_absolute_safe_path(value.get(field)): + errors.append(f"{where}.{field}: must be a safe absolute path other than root") + for field in ("device", "gitCommonDevice"): + if not _is_nonnegative_int(value.get(field)): + errors.append(f"{where}.{field}: must be a non-negative integer") + for field in ("inode", "gitCommonInode"): + if not _is_positive_int(value.get(field)): + errors.append(f"{where}.{field}: must be a positive integer") + return errors + + +def _validate_requested(value: Any, where: str) -> list[str]: + if not isinstance(value, dict): + return [f"{where}: must be an object"] + errors = _unknown_fields(value, REQUESTED_FIELDS, where) + errors.extend(_missing_fields(value, REQUESTED_FIELDS, where)) + cli = value.get("cli") + tier = value.get("tier") + permission = value.get("permissionMode") + if not isinstance(cli, str) or cli not in CLIS: + errors.append(f"{where}.cli: must be codex/cursor-agent") + if not isinstance(tier, str) or tier not in TIERS: + errors.append(f"{where}.tier: must be standard/strong") + model = value.get("model") + if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None: + errors.append(f"{where}.model: must be a safe model ID") + effort = value.get("reasoningEffort") + if cli == "codex" and ( + not isinstance(effort, str) or effort not in REASONING_EFFORTS + ): + errors.append(f"{where}.reasoningEffort: invalid Codex effort") + if cli == "cursor-agent" and effort is not None: + errors.append(f"{where}.reasoningEffort: Cursor requires null") + if not isinstance(permission, str) or permission not in PERMISSION_MODES: + errors.append(f"{where}.permissionMode: must be read-only/workspace-write") + executable = value.get("executable") + if not _is_absolute_safe_path(executable): + errors.append(f"{where}.executable: must be a safe absolute path") + elif isinstance(cli, str) and cli in CLIS and Path(executable).name != cli: + errors.append(f"{where}.executable: basename must match cli") + if not _is_nonnegative_int(value.get("executableDevice")): + errors.append(f"{where}.executableDevice: must be a non-negative integer") + if not _is_positive_int(value.get("executableInode")): + errors.append(f"{where}.executableInode: must be a positive integer") + if not _is_nonempty_text(value.get("cliVersion"), max_length=256): + errors.append(f"{where}.cliVersion: must be non-empty single-line text") + argv = value.get("argv") + if ( + not isinstance(argv, list) + or len(argv) < 2 + or any(not isinstance(arg, str) for arg in argv) + ): + errors.append(f"{where}.argv: must be a string array with at least 2 items") + argv_hash = value.get("argvHash") + if not isinstance(argv_hash, str) or SHA256_RE.fullmatch(argv_hash) is None: + errors.append(f"{where}.argvHash: must be a canonical sha256 hex digest") + elif isinstance(argv, list) and all(isinstance(arg, str) for arg in argv): + if argv_hash != canonical_sha256(argv): + errors.append(f"{where}.argvHash: does not match argv") + if value.get("environmentPolicy") != "per-cli-allowlist-v1": + errors.append( + f"{where}.environmentPolicy: must be 'per-cli-allowlist-v1'" + ) + return errors + + +def _validate_binding(value: Any, where: str) -> list[str]: + if not isinstance(value, dict): + return [f"{where}: must be an object"] + errors = _unknown_fields(value, BINDING_FIELDS, where) + errors.extend(_missing_fields(value, BINDING_FIELDS, where)) + if value.get("orchestrator") != "orca": + errors.append(f"{where}.orchestrator: must be orca") + for field in ("runtimeId", "handle", "incarnationId"): + if not _is_nonempty_text(value.get(field), max_length=512): + errors.append(f"{where}.{field}: must be non-empty single-line text") + if not _is_absolute_safe_path(value.get("observedWorktreePath")): + errors.append(f"{where}.observedWorktreePath: must be a safe absolute path") + for field in ("connected", "writable"): + if value.get(field) is not True: + errors.append(f"{where}.{field}: must be true") + if not _is_timestamp(value.get("boundAt")): + errors.append(f"{where}.boundAt: must be a timezone-aware ISO 8601 timestamp") + return errors + + +def validate_worker_receipt( + receipt: Any, + *, + orchestration: dict[str, Any] | None = None, + task_ids: set[str] | None = None, + where: str = "workerReceipt", +) -> list[str]: + """Validate one audit receipt's structure/checksums and routing references. + + This is not origin authentication: ``receiptHash`` is unkeyed, so callers + must never use this result alone to authorize reuse of an old terminal. + """ + + if not isinstance(receipt, dict): + return [f"{where}: must be an object"] + errors = _unknown_fields(receipt, RECEIPT_FIELDS, where) + errors.extend(_missing_fields(receipt, RECEIPT_FIELDS, where)) + + version = receipt.get("receiptVersion") + if version != RECEIPT_VERSION or isinstance(version, bool): + errors.append(f"{where}.receiptVersion: must be {RECEIPT_VERSION}") + for field in ("id", "launchId", "profileId"): + value = receipt.get(field) + matcher = { + "id": RECEIPT_ID_RE, + "launchId": LAUNCH_ID_RE, + "profileId": PROFILE_ID_RE, + }[field] + if not isinstance(value, str) or matcher.fullmatch(value) is None: + errors.append(f"{where}.{field}: invalid identifier") + for field in ("profileHash", "launchFingerprint", "receiptHash"): + value = receipt.get(field) + if not isinstance(value, str) or SHA256_RE.fullmatch(value) is None: + errors.append(f"{where}.{field}: must be a canonical sha256 hex digest") + slot = receipt.get("slot") + if not isinstance(slot, int) or isinstance(slot, bool) or not 1 <= slot <= 99: + errors.append(f"{where}.slot: must be an integer from 1 to 99") + receipt_id = receipt.get("id") + launch_id = receipt.get("launchId") + if ( + isinstance(receipt_id, str) + and RECEIPT_ID_RE.fullmatch(receipt_id) + and isinstance(launch_id, str) + and LAUNCH_ID_RE.fullmatch(launch_id) + and receipt_id != f"WR-{launch_id}" + ): + errors.append(f"{where}.id: must equal 'WR-' + launchId") + + created_for = receipt.get("createdFor") + worktree = receipt.get("worktree") + requested = receipt.get("requested") + binding = receipt.get("binding") + errors.extend(_validate_created_for(created_for, f"{where}.createdFor")) + errors.extend(_validate_worktree_snapshot(worktree, f"{where}.worktree")) + errors.extend(_validate_requested(requested, f"{where}.requested")) + errors.extend(_validate_binding(binding, f"{where}.binding")) + if not _is_timestamp(receipt.get("createdAt")): + errors.append(f"{where}.createdAt: must be a timezone-aware ISO 8601 timestamp") + + if isinstance(created_for, dict) and task_ids is not None: + task_id = created_for.get("taskId") + if isinstance(task_id, str) and task_id not in task_ids: + errors.append(f"{where}.createdFor.taskId: unknown task {task_id!r}") + + profile: dict[str, Any] | None = None + if orchestration is not None and not isinstance(orchestration, dict): + errors.append(f"{where}: orchestration must be an object") + elif isinstance(orchestration, dict): + profiles = orchestration.get("profiles") + profile_id = receipt.get("profileId") + candidate = profiles.get(profile_id) if ( + isinstance(profiles, dict) and isinstance(profile_id, str) + ) else None + if isinstance(candidate, dict): + profile = candidate + else: + errors.append(f"{where}.profileId: unknown profile {profile_id!r}") + allowed = orchestration.get("allowedWorktrees") + if isinstance(worktree, dict) and isinstance(allowed, list): + if worktree.get("path") not in allowed: + errors.append(f"{where}.worktree.path: is not in allowedWorktrees") + + if profile is not None: + try: + expected_profile_hash = profile_hash( + profile, + profile_version=orchestration.get( + "profileVersion", + PROFILE_VERSION, + ), + ) + except ValueError: + errors.append(f"{where}.profileId: referenced profile is invalid") + else: + if receipt.get("profileHash") != expected_profile_hash: + errors.append(f"{where}.profileHash: does not match profile") + + if isinstance(created_for, dict) and created_for.get("role") != profile.get("role"): + errors.append(f"{where}.createdFor.role: does not match profile") + if isinstance(requested, dict): + for field in ( + "cli", + "tier", + "model", + "reasoningEffort", + "permissionMode", + ): + if requested.get(field) != profile.get(field): + errors.append(f"{where}.requested.{field}: does not match profile") + executable = requested.get("executable") + worktree_path = worktree.get("path") if isinstance(worktree, dict) else None + if isinstance(executable, str) and isinstance(worktree_path, str): + try: + expected_argv = render_worker_argv(profile, executable, worktree_path) + except ValueError: + errors.append(f"{where}.requested.argv: cannot render referenced profile") + else: + if requested.get("argv") != expected_argv: + errors.append(f"{where}.requested.argv: does not match exact renderer") + + if isinstance(binding, dict) and isinstance(worktree, dict): + if binding.get("observedWorktreePath") != worktree.get("path"): + errors.append(f"{where}.binding.observedWorktreePath: does not match worktree.path") + + if ( + isinstance(created_for, dict) + and isinstance(worktree, dict) + and isinstance(requested, dict) + ): + try: + expected_fingerprint = canonical_sha256( + { + "protocolVersion": LAUNCH_PROTOCOL_VERSION, + "backend": "orca", + "profileId": receipt.get("profileId"), + "profileHash": receipt.get("profileHash"), + "createdFor": created_for, + "worktree": worktree, + "requested": requested, + "slot": slot, + } + ) + except ValueError: + errors.append(f"{where}.launchFingerprint: cannot hash launch facts") + else: + if receipt.get("launchFingerprint") != expected_fingerprint: + errors.append( + f"{where}.launchFingerprint: does not match launch facts" + ) + + try: + expected_receipt_hash = receipt_hash(receipt) + except ValueError: + errors.append(f"{where}: must contain canonical JSON data") + else: + if receipt.get("receiptHash") != expected_receipt_hash: + errors.append(f"{where}.receiptHash: does not match receipt") + return errors + + +def validate_worker_receipts( + receipts: Any, + orchestration: dict[str, Any] | None = None, + *, + task_ids: set[str] | None = None, + where: str = "workerReceipts", +) -> list[str]: + """Validate the top-level receipt list and reject duplicate identities.""" + + if not isinstance(receipts, list): + return [f"{where}: must be a list"] + errors: list[str] = [] + seen_ids: set[str] = set() + seen_launch_ids: set[str] = set() + for index, receipt in enumerate(receipts): + receipt_where = f"{where}[{index}]" + errors.extend( + validate_worker_receipt( + receipt, + orchestration=orchestration, + task_ids=task_ids, + where=receipt_where, + ) + ) + if not isinstance(receipt, dict): + continue + receipt_id = receipt.get("id") + if isinstance(receipt_id, str): + if receipt_id in seen_ids: + errors.append(f"{receipt_where}.id: duplicate receipt ID {receipt_id!r}") + seen_ids.add(receipt_id) + launch_id = receipt.get("launchId") + if isinstance(launch_id, str): + if launch_id in seen_launch_ids: + errors.append(f"{receipt_where}.launchId: duplicate launch ID {launch_id!r}") + seen_launch_ids.add(launch_id) + return errors + + +def _validate_dispatch_links( + tasks: Any, + receipts: Any, + orchestration: Any, +) -> list[str]: + """Cross-link role dispatch records to persisted audit receipts.""" + + if not isinstance(tasks, list): + return [] + receipt_by_id: dict[str, dict[str, Any]] = {} + if isinstance(receipts, list): + for receipt in receipts: + if not isinstance(receipt, dict): + continue + receipt_id = receipt.get("id") + if isinstance(receipt_id, str) and receipt_id not in receipt_by_id: + receipt_by_id[receipt_id] = receipt + profiles = orchestration.get("profiles") if isinstance(orchestration, dict) else None + + errors: list[str] = [] + for task_index, task in enumerate(tasks): + if not isinstance(task, dict): + continue + ack_task_id = task.get("id") + dispatch = task.get("dispatch") + if not isinstance(dispatch, dict): + continue + for role in sorted(ROLES): + if role not in dispatch: + continue + role_dispatch = dispatch.get(role) + where = f"tasks[{task_index}].dispatch.{role}" + if not isinstance(role_dispatch, dict): + errors.append(f"{where}: must be an object") + continue + errors.extend(_unknown_fields(role_dispatch, ROLE_DISPATCH_FIELDS, where)) + errors.extend(_missing_fields(role_dispatch, ROLE_DISPATCH_FIELDS, where)) + + profile_id = role_dispatch.get("profileId") + if profile_id is not None: + if not isinstance(profile_id, str) or PROFILE_ID_RE.fullmatch(profile_id) is None: + errors.append(f"{where}.profileId: must be null or a profile ID") + else: + profile = profiles.get(profile_id) if isinstance(profiles, dict) else None + if not isinstance(profile, dict): + errors.append(f"{where}.profileId: unknown profile {profile_id!r}") + elif profile.get("role") != role: + errors.append(f"{where}.profileId: profile role must be {role}") + + runtime_values: list[Any] = [] + for field in ("taskId", "dispatchId"): + runtime_value = role_dispatch.get(field) + runtime_values.append(runtime_value) + if runtime_value is not None and not _is_nonempty_text( + runtime_value, + max_length=512, + ): + errors.append( + f"{where}.{field}: must be null or non-empty single-line text" + ) + if (runtime_values[0] is None) != (runtime_values[1] is None): + errors.append( + f"{where}: taskId and dispatchId must both be null or both be set" + ) + + receipt_id = role_dispatch.get("receiptId") + attempt_id = role_dispatch.get("attemptId") + current_task_attempts = { + f"{ack_task_id}-A{round_number}" for round_number in range(1, 4) + } + if attempt_id is not None: + if ( + not isinstance(attempt_id, str) + or ATTEMPT_ID_RE.fullmatch(attempt_id) is None + ): + errors.append( + f"{where}.attemptId: must be null or use -A1..A3" + ) + elif ( + isinstance(ack_task_id, str) + and attempt_id not in current_task_attempts + ): + errors.append( + f"{where}.attemptId: must belong to current ACK task {ack_task_id!r}" + ) + if receipt_id is None: + if attempt_id is not None: + errors.append( + f"{where}.attemptId: must be null when receiptId is null" + ) + if any(value is not None for value in runtime_values): + errors.append( + f"{where}.receiptId: is required before runtime dispatch IDs" + ) + continue + if attempt_id is None: + errors.append( + f"{where}.attemptId: is required when receiptId is set" + ) + if not isinstance(receipt_id, str) or RECEIPT_ID_RE.fullmatch(receipt_id) is None: + errors.append(f"{where}.receiptId: must be null or a receipt ID") + continue + receipt = receipt_by_id.get(receipt_id) + if receipt is None: + errors.append(f"{where}.receiptId: unknown receipt {receipt_id!r}") + continue + if receipt.get("profileId") != profile_id: + errors.append(f"{where}.profileId: does not match referenced receipt") + created_for = receipt.get("createdFor") + receipt_task_id = ( + created_for.get("taskId") if isinstance(created_for, dict) else None + ) + receipt_role = created_for.get("role") if isinstance(created_for, dict) else None + receipt_attempt_id = ( + created_for.get("attemptId") if isinstance(created_for, dict) else None + ) + if receipt_task_id != ack_task_id: + errors.append( + f"{where}.receiptId: referenced receipt task must be current " + f"ACK task {ack_task_id!r}" + ) + if receipt_role != role: + errors.append(f"{where}.receiptId: referenced receipt role must be {role}") + if receipt_attempt_id != attempt_id: + errors.append( + f"{where}.attemptId: does not match referenced receipt" + ) + return errors + + +def validate_routing_document(data: Any) -> list[str]: + """Validate routing and receipts embedded in an ACK tasks document.""" + + if not isinstance(data, dict): + return [": must be an object"] + project = data.get("project") + if not isinstance(project, dict): + return ["project: must be an object"] + if "orchestration" not in project: + return ["project.orchestration: is required"] + orchestration = project.get("orchestration") + errors = validate_orchestration(orchestration) + + task_ids: set[str] = set() + tasks = data.get("tasks") + if isinstance(tasks, list): + for task in tasks: + if isinstance(task, dict) and isinstance(task.get("id"), str): + task_ids.add(task["id"]) + + receipts = data.get("workerReceipts") + if "workerReceipts" not in data: + errors.append("workerReceipts: is required") + else: + errors.extend( + validate_worker_receipts( + receipts, + orchestration if isinstance(orchestration, dict) else None, + task_ids=task_ids, + ) + ) + errors.extend(_validate_dispatch_links(tasks, receipts, orchestration)) + if isinstance(orchestration, dict) and orchestration.get("mode") == "manual": + receipts = data.get("workerReceipts") + if isinstance(receipts, list) and receipts: + errors.append("workerReceipts: manual orchestration requires an empty list") + return errors + + +# Short aliases for callers that treat this module as a builtin validator. +validate_routing = validate_orchestration +validate_receipts = validate_worker_receipts +validate_builtin = validate_routing_document + + +__all__ = [ + "PROFILE_VERSION", + "RECEIPT_VERSION", + "LAUNCH_PROTOCOL_VERSION", + "MAX_ROUNDS", + "canonical_sha256", + "profile_hash", + "receipt_hash", + "render_worker_argv", + "validate_profile", + "validate_orchestration", + "validate_worker_receipt", + "validate_worker_receipts", + "validate_routing_document", + "validate_routing", + "validate_receipts", + "validate_builtin", +] diff --git a/skills/ack/templates/project.template.md b/skills/ack/templates/project.template.md index 6e1affb..88b053b 100644 --- a/skills/ack/templates/project.template.md +++ b/skills/ack/templates/project.template.md @@ -31,13 +31,25 @@ - 派发 prompt 模板:`references/prompt-templates.md` - Orca 编排命令(可选):`references/orca-adapter.md` -## 模型档位(项目可覆盖,默认见 references/model-routing.md) +## Worker 路由 -| 角色 | 默认档位 | 本项目实际 | -|------|----------|------------| -| Coordinator (PM) | 强模型 | `` | -| Test | 中低模型 | `` | -| Developer | 中低模型 | `` | +机器可校验的模型、reasoning effort、权限模式、默认 profile、允许 worktree 和启动 +receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层 +`workerReceipts` 为准。本文件不保存可执行 worker 命令。 + +默认 profile: + +| 角色 | profile ID | 档位 | +|------|------------|------| +| Developer | `codex-dev-standard` | standard | +| Test | `codex-test-standard` | standard | +| Developer 升级 | `codex-dev-strong` | strong | + +项目如改用 Cursor,应修改结构化 profile、allowlist 和 defaults,再运行任务板 +校验;不能在这里粘贴 `orca --command`、Agent CLI 参数或自由 shell。 +每次自动派发必须先审阅 launcher plan,再把其中的 `launchFingerprint` 作为 +`--expected-launch-fingerprint` 启动 fresh worker。v0.10 的 `receiptHash` 只作 +审计 checksum,不授权自动复用旧终端。 ## 路径权限(项目覆盖层,必须填实际路径) @@ -86,6 +98,12 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检 - 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。 - 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 references/model-routing.md)。 +- 自动 worker 只能由 ACK 的 `scripts/launch_worker.py` 按结构化 profile 启动; + 禁止直接拼 `orca terminal create --command`,禁止 `command`、`extraArgs`、`env` + 等第二配置面。 +- v0.10 自动 launcher 只支持 `read-only` 和 `workspace-write`。full-access、 + bypass、YOLO/force 与关闭 sandbox 均 fail closed;需要可信平台审批通道后再开放。 +- v0.10 不自动复用持久化 receipt 指向的旧终端;每次自动派发都重新 plan/launch。 - `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。 - 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Developer 与 Test 都只读, 通过消息回报。 diff --git a/skills/ack/templates/tasks.schema.json b/skills/ack/templates/tasks.schema.json index 880252b..35ac4de 100644 --- a/skills/ack/templates/tasks.schema.json +++ b/skills/ack/templates/tasks.schema.json @@ -12,6 +12,7 @@ "source": { "type": "string" }, "ackVersion": { "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", "description": "接入时所基于的 ack 版本,便于日后 diff 升级" }, "kitVersion": { @@ -35,6 +36,9 @@ "type": "string", "const": "docs/ack/knowledge.yaml", "description": "项目知识护栏库的唯一权威路径" + }, + "orchestration": { + "$ref": "#/definitions/orchestration" } } }, @@ -49,12 +53,538 @@ } }, "statusReference": { "type": "object" }, + "workerReceipts": { + "type": "array", + "items": { "$ref": "#/definitions/workerReceipt" } + }, "tasks": { "type": "array", "items": { "$ref": "#/definitions/task" } } }, + "allOf": [ + { + "if": { + "properties": { + "ackVersion": { + "type": "string", + "pattern": "^(?:0\\.[1-9][0-9]+\\.[0-9]+|[1-9][0-9]*\\.[0-9]+\\.[0-9]+)(?:-|\\+|$)" + } + }, + "required": ["ackVersion"] + }, + "then": { + "required": ["workerReceipts"], + "properties": { + "workerReceipts": {}, + "tasks": { "$ref": "#/definitions/launchableTasks" }, + "project": { + "type": "object", + "required": ["orchestration"], + "properties": { + "orchestration": {} + } + } + } + } + }, + { + "if": { + "properties": { + "project": { + "type": "object", + "required": ["orchestration"], + "properties": { + "orchestration": {} + } + } + }, + "required": ["project"] + }, + "then": { + "required": ["workerReceipts"], + "properties": { + "workerReceipts": {}, + "tasks": { "$ref": "#/definitions/launchableTasks" } + } + } + }, + { + "if": { + "required": ["workerReceipts"], + "properties": { + "workerReceipts": {} + } + }, + "then": { + "properties": { + "tasks": { "$ref": "#/definitions/launchableTasks" }, + "project": { + "type": "object", + "required": ["orchestration"], + "properties": { + "orchestration": {} + } + } + } + } + } + ], "definitions": { + "sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "profileId": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,63}$" + }, + "launchableTasks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" + } + } + } + }, + "modelList": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$" + } + }, + "modelTierAllowlist": { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "properties": { + "standard": { "$ref": "#/definitions/modelList" }, + "strong": { "$ref": "#/definitions/modelList" } + } + }, + "modelRoleAllowlist": { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "properties": { + "developer": { "$ref": "#/definitions/modelTierAllowlist" }, + "test": { "$ref": "#/definitions/modelTierAllowlist" } + } + }, + "modelAllowlist": { + "type": "object", + "additionalProperties": false, + "properties": { + "codex": { "$ref": "#/definitions/modelRoleAllowlist" }, + "cursor-agent": { "$ref": "#/definitions/modelRoleAllowlist" } + } + }, + "workerProfile": { + "type": "object", + "required": [ + "role", + "cli", + "tier", + "model", + "reasoningEffort", + "permissionMode" + ], + "additionalProperties": false, + "properties": { + "role": { + "type": "string", + "enum": ["developer", "test"] + }, + "cli": { + "type": "string", + "enum": ["codex", "cursor-agent"] + }, + "tier": { + "type": "string", + "enum": ["standard", "strong"] + }, + "model": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$" + }, + "reasoningEffort": { + "type": ["string", "null"], + "enum": ["low", "medium", "high", "xhigh", null] + }, + "permissionMode": { + "type": "string", + "enum": ["read-only", "workspace-write"] + } + } + }, + "workerDefaults": { + "type": "object", + "additionalProperties": false, + "properties": { + "developer": { "$ref": "#/definitions/profileId" }, + "test": { "$ref": "#/definitions/profileId" }, + "developerUpgraded": { "$ref": "#/definitions/profileId" } + } + }, + "orchestration": { + "type": "object", + "required": [ + "profileVersion", + "mode", + "allowedWorktrees", + "modelAllowlist", + "profiles", + "defaults" + ], + "additionalProperties": false, + "properties": { + "profileVersion": { + "type": "integer", + "const": 1 + }, + "mode": { + "type": "string", + "enum": ["orca", "manual"] + }, + "allowedWorktrees": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^/" + } + }, + "modelAllowlist": { + "$ref": "#/definitions/modelAllowlist" + }, + "profiles": { + "type": "object", + "propertyNames": { + "$ref": "#/definitions/profileId" + }, + "additionalProperties": { + "$ref": "#/definitions/workerProfile" + } + }, + "defaults": { + "$ref": "#/definitions/workerDefaults" + } + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { "const": "orca" } + }, + "required": ["mode"] + }, + "then": { + "properties": { + "allowedWorktrees": { "type": "array", "minItems": 1 }, + "modelAllowlist": { "type": "object", "minProperties": 1 }, + "profiles": { "type": "object", "minProperties": 1 }, + "defaults": { + "type": "object", + "required": ["developer", "test"], + "properties": { + "developer": {}, + "test": {} + } + } + } + } + } + ] + }, + "worktreeIdentity": { + "type": "object", + "required": [ + "path", + "device", + "inode", + "gitCommonDir", + "gitCommonDevice", + "gitCommonInode" + ], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "pattern": "^/" + }, + "device": { + "type": "integer", + "minimum": 0 + }, + "inode": { + "type": "integer", + "minimum": 1 + }, + "gitCommonDir": { + "type": "string", + "pattern": "^/" + }, + "gitCommonDevice": { + "type": "integer", + "minimum": 0 + }, + "gitCommonInode": { + "type": "integer", + "minimum": 1 + } + } + }, + "receiptRequested": { + "type": "object", + "required": [ + "cli", + "tier", + "model", + "reasoningEffort", + "permissionMode", + "executable", + "executableDevice", + "executableInode", + "cliVersion", + "argv", + "argvHash", + "environmentPolicy" + ], + "additionalProperties": false, + "properties": { + "cli": { + "type": "string", + "enum": ["codex", "cursor-agent"] + }, + "tier": { + "type": "string", + "enum": ["standard", "strong"] + }, + "model": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$" + }, + "reasoningEffort": { + "type": ["string", "null"], + "enum": ["low", "medium", "high", "xhigh", null] + }, + "permissionMode": { + "type": "string", + "enum": ["read-only", "workspace-write"] + }, + "executable": { + "type": "string", + "pattern": "^/" + }, + "executableDevice": { + "type": "integer", + "minimum": 0 + }, + "executableInode": { + "type": "integer", + "minimum": 1 + }, + "cliVersion": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 2, + "items": { + "type": "string" + } + }, + "argvHash": { + "$ref": "#/definitions/sha256" + }, + "environmentPolicy": { + "type": "string", + "const": "per-cli-allowlist-v1" + } + } + }, + "receiptBinding": { + "type": "object", + "required": [ + "orchestrator", + "runtimeId", + "handle", + "incarnationId", + "observedWorktreePath", + "connected", + "writable", + "boundAt" + ], + "additionalProperties": false, + "properties": { + "orchestrator": { + "type": "string", + "const": "orca" + }, + "runtimeId": { + "type": "string", + "minLength": 1 + }, + "handle": { + "type": "string", + "minLength": 1 + }, + "incarnationId": { + "type": "string", + "minLength": 1 + }, + "observedWorktreePath": { + "type": "string" + }, + "connected": { + "type": "boolean" + }, + "writable": { + "type": "boolean" + }, + "boundAt": { + "type": "string", + "minLength": 1 + } + } + }, + "workerReceipt": { + "type": "object", + "required": [ + "receiptVersion", + "id", + "launchId", + "profileId", + "profileHash", + "launchFingerprint", + "slot", + "createdFor", + "worktree", + "requested", + "binding", + "createdAt", + "receiptHash" + ], + "additionalProperties": false, + "properties": { + "receiptVersion": { + "type": "integer", + "const": 1 + }, + "id": { + "type": "string", + "pattern": "^WR-[0-9a-f]{64}$" + }, + "launchId": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "profileId": { + "$ref": "#/definitions/profileId" + }, + "profileHash": { + "$ref": "#/definitions/sha256" + }, + "launchFingerprint": { + "$ref": "#/definitions/sha256" + }, + "slot": { + "type": "integer", + "minimum": 1, + "maximum": 99 + }, + "createdFor": { + "type": "object", + "required": ["taskId", "attemptId", "role"], + "additionalProperties": false, + "properties": { + "taskId": { + "type": "string", + "minLength": 1 + }, + "attemptId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-3]$" + }, + "role": { + "type": "string", + "enum": ["developer", "test"] + } + } + }, + "worktree": { + "$ref": "#/definitions/worktreeIdentity" + }, + "requested": { + "$ref": "#/definitions/receiptRequested" + }, + "binding": { + "$ref": "#/definitions/receiptBinding" + }, + "createdAt": { + "type": "string", + "minLength": 1 + }, + "receiptHash": { + "$ref": "#/definitions/sha256" + } + } + }, + "roleDispatch": { + "type": "object", + "required": ["profileId", "receiptId", "attemptId", "taskId", "dispatchId"], + "additionalProperties": false, + "properties": { + "profileId": { + "type": ["string", "null"], + "pattern": "^[a-z][a-z0-9-]{1,63}$" + }, + "receiptId": { + "type": ["string", "null"], + "pattern": "^WR-[0-9a-f]{64}$" + }, + "attemptId": { + "type": ["string", "null"], + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-3]$" + }, + "taskId": { + "type": ["string", "null"] + }, + "dispatchId": { + "type": ["string", "null"] + } + }, + "allOf": [ + { + "if": { + "properties": { + "receiptId": { "type": "null" } + }, + "required": ["receiptId"] + }, + "then": { + "properties": { + "attemptId": { "type": "null" } + } + }, + "else": { + "properties": { + "profileId": { "type": "string" }, + "attemptId": { "type": "string" } + } + } + } + ] + }, "status": { "type": "string", "enum": [ @@ -76,7 +606,7 @@ "round": { "type": "integer", "minimum": 1, "maximum": 3 }, "attemptId": { "type": "string", - "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-3]$", "description": "稳定逻辑轮次 ID,应为 -A;旧轮次可缺省,但作为知识来源前必须补齐" }, "result": { "type": "string", "enum": ["passed", "failed"] }, @@ -230,11 +760,13 @@ "verification": { "type": "object" }, "dispatch": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "taskId": { "type": ["string", "null"] }, "dispatchId": { "type": ["string", "null"] }, "worker": { "type": ["string", "null"] }, + "developer": { "$ref": "#/definitions/roleDispatch" }, + "test": { "$ref": "#/definitions/roleDispatch" }, "rounds": { "type": "array", "items": { "$ref": "#/definitions/round" } diff --git a/skills/ack/templates/tasks.template.yaml b/skills/ack/templates/tasks.template.yaml index 0d86657..755a703 100644 --- a/skills/ack/templates/tasks.template.yaml +++ b/skills/ack/templates/tasks.template.yaml @@ -10,6 +10,65 @@ project: devWorktree: "" overlayFile: "docs/ack/project.md" knowledgeFile: "docs/ack/knowledge.yaml" + orchestration: + profileVersion: 1 + mode: "orca" + allowedWorktrees: + - "" + modelAllowlist: + codex: + developer: + standard: ["gpt-5.6-terra"] + strong: ["gpt-5.6-sol"] + test: + standard: ["gpt-5.6-luna"] + cursor-agent: + developer: + standard: ["auto"] + test: + standard: ["auto"] + profiles: + codex-dev-standard: + role: "developer" + cli: "codex" + tier: "standard" + model: "gpt-5.6-terra" + reasoningEffort: "medium" + permissionMode: "workspace-write" + codex-test-standard: + role: "test" + cli: "codex" + tier: "standard" + model: "gpt-5.6-luna" + reasoningEffort: "low" + permissionMode: "workspace-write" + codex-dev-strong: + role: "developer" + cli: "codex" + tier: "strong" + model: "gpt-5.6-sol" + reasoningEffort: "high" + permissionMode: "workspace-write" + cursor-dev-standard: + role: "developer" + cli: "cursor-agent" + tier: "standard" + model: "auto" + reasoningEffort: null + permissionMode: "workspace-write" + cursor-test-standard: + role: "test" + cli: "cursor-agent" + tier: "standard" + model: "auto" + reasoningEffort: null + permissionMode: "workspace-write" + defaults: + developer: "codex-dev-standard" + test: "codex-test-standard" + developerUpgraded: "codex-dev-strong" + +workerReceipts: [] summary: verified: [] @@ -62,9 +121,18 @@ tasks: - "" dispatch: - taskId: null - dispatchId: null - worker: null + developer: + profileId: "codex-dev-standard" + receiptId: null + attemptId: null + taskId: null + dispatchId: null + test: + profileId: "codex-test-standard" + receiptId: null + attemptId: null + taskId: null + dispatchId: null rounds: [] resolution: diff --git a/tests/test_ack_launch_worker.py b/tests/test_ack_launch_worker.py new file mode 100644 index 0000000..0520ebe --- /dev/null +++ b/tests/test_ack_launch_worker.py @@ -0,0 +1,1408 @@ +from __future__ import annotations + +import contextlib +import io +import json +import os +import shlex +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +REPO_ROOT = Path(__file__).resolve().parents[1] +ACK_SCRIPTS = REPO_ROOT / "skills" / "ack" / "scripts" +sys.path.insert(0, str(ACK_SCRIPTS)) + +import launch_worker # noqa: E402 +import validate_worker_command # noqa: E402 +import worker_profiles # noqa: E402 + + +def orchestration() -> dict: + return { + "profileVersion": 1, + "mode": "orca", + "allowedWorktrees": ["/repo/worktree"], + "modelAllowlist": { + "codex": { + "developer": { + "standard": ["gpt-5.6-terra"], + }, + "test": { + "standard": ["gpt-5.6-luna"], + }, + } + }, + "profiles": { + "codex-dev-standard": { + "role": "developer", + "cli": "codex", + "tier": "standard", + "model": "gpt-5.6-terra", + "reasoningEffort": "medium", + "permissionMode": "workspace-write", + }, + "codex-test-standard": { + "role": "test", + "cli": "codex", + "tier": "standard", + "model": "gpt-5.6-luna", + "reasoningEffort": "low", + "permissionMode": "workspace-write", + }, + }, + "defaults": { + "developer": "codex-dev-standard", + "test": "codex-test-standard", + }, + } + + +def board(project_root: Path) -> dict: + return { + "project": { + "repoPath": str(project_root), + "orchestration": orchestration(), + }, + "workerReceipts": [], + "tasks": [{"id": "TASK-001"}], + } + + +def worktree_identity(path: Path) -> dict: + metadata = path.stat() + git_path = path / ".git" + git_metadata = git_path.stat() if git_path.exists() else metadata + return { + "path": str(path), + "device": metadata.st_dev, + "inode": metadata.st_ino, + "gitCommonDir": str(git_path if git_path.exists() else path), + "gitCommonDevice": git_metadata.st_dev, + "gitCommonInode": git_metadata.st_ino, + } + + +def plan_for(path: Path, executable: Path) -> dict: + profile = orchestration()["profiles"]["codex-dev-standard"] + identity = worktree_identity(path) + executable_metadata = executable.stat() + argv = worker_profiles.render_worker_argv( + profile, + str(executable), + str(path), + ) + requested = { + "cli": "codex", + "tier": "standard", + "model": "gpt-5.6-terra", + "reasoningEffort": "medium", + "permissionMode": "workspace-write", + "executable": str(executable), + "executableDevice": executable_metadata.st_dev, + "executableInode": executable_metadata.st_ino, + "cliVersion": "codex-cli 1.0", + "argv": argv, + "argvHash": worker_profiles.canonical_sha256(argv), + "environmentPolicy": "per-cli-allowlist-v1", + } + created_for = { + "taskId": "TASK-001", + "attemptId": "TASK-001-A1", + "role": "developer", + } + profile_digest = worker_profiles.profile_hash(profile) + fingerprint = worker_profiles.canonical_sha256( + { + "protocolVersion": 1, + "backend": "orca", + "profileId": "codex-dev-standard", + "profileHash": profile_digest, + "createdFor": created_for, + "worktree": identity, + "requested": requested, + "slot": 1, + } + ) + digest_short = fingerprint.split(":", 1)[1][:10] + return { + "protocolVersion": 1, + "backend": "orca", + "projectRoot": str(path), + "taskId": "TASK-001", + "attemptId": "TASK-001-A1", + "role": "developer", + "profileId": "codex-dev-standard", + "profileHash": profile_digest, + "launchFingerprint": fingerprint, + "worktree": identity, + "requested": requested, + "slot": 1, + "title": f"ACK-DEV-CODEX-STANDARD-{digest_short}-1", + } + + +@contextlib.contextmanager +def post_handle_failure_context( + *, + update_side_effect: object, + close_side_effect: object, +): + with tempfile.TemporaryDirectory() as temporary: + executable = Path(temporary) / "codex" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o700) + plan = plan_for(REPO_ROOT, executable) + close = mock.Mock() + if isinstance(close_side_effect, BaseException): + close.side_effect = close_side_effect + else: + close.return_value = close_side_effect + + with ( + mock.patch.object( + launch_worker.secrets, + "token_hex", + side_effect=["a" * 64, "b" * 64], + ), + mock.patch.object( + launch_worker, + "create_record", + return_value=Path("/tmp/ack-record.json"), + ), + mock.patch.object( + launch_worker, + "resolve_executable", + return_value=Path("/trusted/orca"), + ), + mock.patch.object( + launch_worker, + "run_json", + side_effect=[ + {"ok": True, "_meta": {"runtimeId": "runtime-1"}}, + launch_worker.LaunchError("show failed"), + ], + ), + mock.patch.object( + launch_worker, + "run_orca_create", + return_value=( + { + "ok": True, + "result": {"terminal": {"handle": "term-1"}}, + "_meta": {"runtimeId": "runtime-1"}, + }, + "term-1", + ), + ), + mock.patch.object(launch_worker, "assert_identity_current"), + mock.patch.object(launch_worker, "run_orca_close", new=close), + mock.patch.object( + launch_worker, + "update_record", + side_effect=update_side_effect, + ) as update, + ): + yield plan, close, update + + +class ArgumentBoundaryTests(unittest.TestCase): + def test_rejects_duplicate_separator_and_free_command_arguments(self) -> None: + cases = [ + [ + "profile-hash", + "--project-root", + "/repo", + "--project-root", + "/other", + "--profile-id", + "codex-dev-standard", + ], + [ + "profile-hash", + "--project-root", + "/repo", + "--profile-id", + "codex-dev-standard", + "--", + "forged", + ], + [ + "profile-hash", + "--project-root", + "/repo", + "--profile-id", + "codex-dev-standard", + "--command", + "codex; touch forged", + ], + ] + for arguments in cases: + with self.subTest(arguments=arguments), contextlib.redirect_stderr( + io.StringIO() + ): + self.assertEqual(launch_worker.main(arguments), 2) + + def test_legacy_free_command_path_always_fails_closed(self) -> None: + with ( + contextlib.redirect_stderr(io.StringIO()), + contextlib.redirect_stdout(io.StringIO()), + ): + self.assertEqual( + validate_worker_command.main( + [ + "--role", + "developer", + "--command", + "codex; touch /tmp/forged", + ] + ), + 2, + ) + self.assertEqual(validate_worker_command.main(["--self-test"]), 0) + + def test_launch_requires_reviewed_fingerprint_and_refuses_drift(self) -> None: + fake_plan = {"launchFingerprint": "sha256:" + "a" * 64} + arguments = [ + "launch", + "--project-root", + "/repo", + "--task-id", + "TASK-001", + "--attempt-id", + "TASK-001-A1", + "--role", + "developer", + "--profile-id", + "codex-dev-standard", + "--worktree", + "/repo/worktree", + "--expected-launch-fingerprint", + "sha256:" + "b" * 64, + ] + with ( + mock.patch.object(launch_worker, "build_plan", return_value=fake_plan), + mock.patch.object(launch_worker, "launch_with_orca") as launch, + contextlib.redirect_stderr(io.StringIO()), + ): + self.assertEqual(launch_worker.main(arguments), 1) + launch.assert_not_called() + + +class EnvironmentAndExecutableTests(unittest.TestCase): + def test_environment_splits_control_and_per_cli_credentials(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + hostile_path = str(Path(temporary)) + with mock.patch.dict( + os.environ, + { + "PATH": hostile_path, + "LD_AUDIT": "/tmp/evil.so", + "LD_PRELOAD": "/tmp/evil.so", + "NODE_OPTIONS": "--require=/tmp/evil.js", + "PYTHONPATH": "/tmp/evil", + "CODEX_HOME": "/tmp/evil-codex", + "GIT_SSH_COMMAND": "touch /tmp/evil", + "OPENAI_API_KEY": "test-token", + "AZURE_OPENAI_API_KEY": "azure-token", + "CURSOR_API_KEY": "cursor-token", + "ANTHROPIC_API_KEY": "anthropic-token", + "DBUS_SESSION_BUS_ADDRESS": "unix:path=/tmp/dbus", + "DISPLAY": ":99", + "WAYLAND_DISPLAY": "wayland-99", + "XDG_RUNTIME_DIR": "/tmp/runtime", + "LANG": "C.UTF-8", + }, + clear=True, + ): + control = launch_worker.control_environment() + codex = launch_worker.worker_environment("codex") + cursor = launch_worker.worker_environment("cursor-agent") + + for environment in (control, codex, cursor): + self.assertNotIn(hostile_path, environment["PATH"].split(os.pathsep)) + self.assertEqual(environment["LANG"], "C.UTF-8") + self.assertNotIn("OPENAI_API_KEY", control) + self.assertNotIn("CURSOR_API_KEY", control) + self.assertEqual(codex["OPENAI_API_KEY"], "test-token") + self.assertEqual(codex["AZURE_OPENAI_API_KEY"], "azure-token") + self.assertNotIn("CURSOR_API_KEY", codex) + self.assertEqual(cursor["CURSOR_API_KEY"], "cursor-token") + self.assertNotIn("OPENAI_API_KEY", cursor) + for forbidden in ( + "ANTHROPIC_API_KEY", + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", + "LD_AUDIT", + "LD_PRELOAD", + "NODE_OPTIONS", + "PYTHONPATH", + "CODEX_HOME", + "GIT_SSH_COMMAND", + "WAYLAND_DISPLAY", + "XDG_RUNTIME_DIR", + ): + for environment in (control, codex, cursor): + self.assertNotIn(forbidden, environment) + + def test_executable_resolution_ignores_hostile_path(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fake = Path(temporary) / "git" + fake.write_text("#!/bin/sh\nexit 99\n", encoding="utf-8") + fake.chmod(0o755) + with mock.patch.dict(os.environ, {"PATH": temporary}): + resolved = launch_worker.resolve_executable("git") + + self.assertEqual(resolved, Path("/usr/bin/git")) + + +class PlanTests(unittest.TestCase): + def test_plan_uses_exact_renderer_and_binds_created_for(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + project = Path(temporary).resolve() + executable = project / "codex" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o700) + identity = worktree_identity(project) + routing = orchestration() + routing["allowedWorktrees"] = [str(project)] + task_board = board(project) + task_board["project"]["orchestration"] = routing + + with ( + mock.patch.object( + launch_worker, + "load_authoritative_board", + return_value=(project, task_board), + ), + mock.patch.object( + launch_worker, + "capture_worktree_identity", + return_value=identity, + ), + mock.patch.object( + launch_worker, + "resolve_executable", + return_value=executable, + ), + mock.patch.object( + launch_worker, + "run_text", + return_value="codex-cli 1.0", + ), + ): + plan = launch_worker.build_plan( + project_root_value=str(project), + task_id="TASK-001", + attempt_id="TASK-001-A1", + role="developer", + profile_id="codex-dev-standard", + worktree_value=str(project), + slot=1, + ) + + expected_fingerprint = worker_profiles.canonical_sha256( + { + "protocolVersion": 1, + "backend": "orca", + "profileId": plan["profileId"], + "profileHash": plan["profileHash"], + "createdFor": { + "taskId": "TASK-001", + "attemptId": "TASK-001-A1", + "role": "developer", + }, + "worktree": plan["worktree"], + "requested": plan["requested"], + "slot": 1, + } + ) + self.assertEqual(plan["launchFingerprint"], expected_fingerprint) + self.assertNotIn("--dangerously-bypass-approvals-and-sandbox", plan["requested"]["argv"]) + self.assertNotIn("--yolo", plan["requested"]["argv"]) + + def test_plan_fingerprint_binds_slot(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + project = Path(temporary).resolve() + executable = project / "codex" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o700) + identity = worktree_identity(project) + routing = orchestration() + routing["allowedWorktrees"] = [str(project)] + task_board = board(project) + task_board["project"]["orchestration"] = routing + + with ( + mock.patch.object( + launch_worker, + "load_authoritative_board", + return_value=(project, task_board), + ), + mock.patch.object( + launch_worker, + "capture_worktree_identity", + return_value=identity, + ), + mock.patch.object( + launch_worker, + "resolve_executable", + return_value=executable, + ), + mock.patch.object( + launch_worker, + "run_text", + return_value="codex-cli 1.0", + ), + ): + first = launch_worker.build_plan( + project_root_value=str(project), + task_id="TASK-001", + attempt_id="TASK-001-A1", + role="developer", + profile_id="codex-dev-standard", + worktree_value=str(project), + slot=1, + ) + second = launch_worker.build_plan( + project_root_value=str(project), + task_id="TASK-001", + attempt_id="TASK-001-A1", + role="developer", + profile_id="codex-dev-standard", + worktree_value=str(project), + slot=2, + ) + + self.assertNotEqual(first["launchFingerprint"], second["launchFingerprint"]) + self.assertNotEqual(first["title"], second["title"]) + + +class GitIdentityTests(unittest.TestCase): + def test_registered_worktree_passes_and_git_symlink_impostor_fails(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + base = Path(temporary).resolve() + project = base / "project" + project.mkdir() + subprocess.run( + ["/usr/bin/git", "init", "-q", str(project)], + check=True, + ) + identity = launch_worker.capture_worktree_identity( + project, + str(project), + [str(project)], + ) + self.assertEqual(identity["path"], str(project)) + + impostor = base / "impostor" + impostor.mkdir() + (impostor / ".git").symlink_to(project / ".git") + with self.assertRaisesRegex(launch_worker.LaunchError, "symlink"): + launch_worker.capture_worktree_identity( + project, + str(impostor), + [str(impostor)], + ) + + +class BootstrapProtocolTests(unittest.TestCase): + def test_launcher_uses_shared_protocol_version(self) -> None: + self.assertEqual( + launch_worker.PROTOCOL_VERSION, + worker_profiles.LAUNCH_PROTOCOL_VERSION, + ) + + def test_orca_command_contains_only_fixed_bootstrap_and_opaque_id(self) -> None: + first_id = "a" * 64 + second_id = "b" * 64 + first = shlex.split(launch_worker.build_bootstrap_command(first_id)) + second = shlex.split(launch_worker.build_bootstrap_command(second_id)) + + self.assertEqual(first[0], "exec") + self.assertEqual(first[2], "-I") + self.assertEqual(first[4:6], ["_bootstrap", "--launch-id"]) + self.assertEqual(first[-1], first_id) + self.assertEqual(first[:-1], second[:-1]) + self.assertEqual(second[-1], second_id) + + def test_bootstrap_proof_is_nonce_and_plan_bound(self) -> None: + launch_id = "a" * 64 + fingerprint = "sha256:" + "b" * 64 + nonce = "c" * 64 + proof = launch_worker.bootstrap_ready_proof( + launch_id, + fingerprint, + nonce, + ) + + self.assertRegex(proof, r"^sha256:[0-9a-f]{64}$") + self.assertNotEqual( + proof, + launch_worker.bootstrap_ready_proof( + launch_id, + fingerprint, + "d" * 64, + ), + ) + self.assertNotEqual( + proof, + launch_worker.bootstrap_ready_proof( + launch_id, + "sha256:" + "e" * 64, + nonce, + ), + ) + + def test_spoofed_ready_state_without_proof_is_rejected(self) -> None: + with ( + mock.patch.object( + launch_worker, + "read_record", + return_value={"state": "bootstrap-ready", "bootstrapProof": "forged"}, + ), + self.assertRaisesRegex(launch_worker.LaunchError, "proof"), + ): + launch_worker.wait_for_bootstrap( + "a" * 64, + "sha256:" + "b" * 64, + ) + + def test_bootstrap_executes_exact_argv_without_shell_after_authorization(self) -> None: + launch_id = "a" * 64 + nonce = "b" * 64 + project = REPO_ROOT + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + executable = Path(temporary.name) / "codex" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o700) + plan = plan_for(project, executable) + record = { + "state": "prepared", + "expiresAt": launch_worker.format_timestamp( + launch_worker.utc_now() + + launch_worker.timedelta(seconds=30) + ), + "plan": plan, + "authorizationHash": launch_worker.bootstrap_authorization_hash( + launch_id, + plan["launchFingerprint"], + nonce, + ), + } + + @contextlib.contextmanager + def fake_locked_record(_launch_id: str): + yield record + + process = mock.Mock(pid=1234) + process.wait.return_value = 0 + fake_stdin = io.StringIO(nonce + "\n") + with ( + mock.patch.object(launch_worker, "locked_record", fake_locked_record), + mock.patch.object(launch_worker, "build_plan", return_value=plan), + mock.patch.object(launch_worker, "assert_identity_current"), + mock.patch.object( + launch_worker.select, + "select", + return_value=([fake_stdin], [], []), + ), + mock.patch.object(launch_worker.sys, "stdin", fake_stdin), + mock.patch.object(launch_worker.subprocess, "Popen", return_value=process) as popen, + ): + self.assertEqual(launch_worker.bootstrap_worker(launch_id), 0) + + popen.assert_called_once_with( + plan["requested"]["argv"], + shell=False, + cwd=plan["worktree"]["path"], + env=mock.ANY, + ) + self.assertEqual( + record["bootstrapProof"], + launch_worker.bootstrap_ready_proof( + launch_id, + plan["launchFingerprint"], + nonce, + ), + ) + self.assertEqual(record["state"], "closed") + + def test_bootstrap_cleanup_wins_before_agent_spawn(self) -> None: + launch_id = "a" * 64 + nonce = "b" * 64 + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + executable = Path(temporary.name) / "codex" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o700) + plan = plan_for(REPO_ROOT, executable) + record = { + "state": "prepared", + "expiresAt": launch_worker.format_timestamp( + launch_worker.utc_now() + + launch_worker.timedelta(seconds=30) + ), + "plan": plan, + "authorizationHash": launch_worker.bootstrap_authorization_hash( + launch_id, + plan["launchFingerprint"], + nonce, + ), + } + + @contextlib.contextmanager + def fake_locked_record(_launch_id: str): + yield record + + fake_stdin = io.StringIO(nonce + "\n") + + def parent_marks_cleanup(*_args, **_kwargs): + record.update( + state="indeterminate", + error="parent requested cleanup", + cleanup={ + "confirmed": False, + "reconcileRequired": True, + }, + ) + return ([fake_stdin], [], []) + + with ( + mock.patch.object(launch_worker, "locked_record", fake_locked_record), + mock.patch.object(launch_worker, "build_plan", return_value=plan), + mock.patch.object(launch_worker, "assert_identity_current"), + mock.patch.object( + launch_worker.select, + "select", + side_effect=parent_marks_cleanup, + ), + mock.patch.object(launch_worker.sys, "stdin", fake_stdin), + mock.patch.object(launch_worker.subprocess, "Popen") as popen, + contextlib.redirect_stderr(io.StringIO()), + ): + self.assertEqual(launch_worker.bootstrap_worker(launch_id), 1) + + popen.assert_not_called() + self.assertEqual(record["state"], "indeterminate") + self.assertTrue(record["cleanup"]["reconcileRequired"]) + + +class OrcaCreateTests(unittest.TestCase): + def test_orca_control_json_requires_explicit_ok_true(self) -> None: + valid = json.dumps({"ok": True, "result": {}}) + with mock.patch.object(launch_worker, "run_text", return_value=valid): + self.assertTrue( + launch_worker.run_json(["orca", "status", "--json"], "orca status")[ + "ok" + ] + ) + + unsafe = [ + json.dumps({"result": {}}), + json.dumps({"ok": None, "result": {}}), + json.dumps({"ok": 1, "result": {}}), + ] + for output in unsafe: + with ( + self.subTest(output=output), + mock.patch.object(launch_worker, "run_text", return_value=output), + self.assertRaisesRegex(launch_worker.LaunchError, "ok=true"), + ): + launch_worker.run_json( + ["orca", "status", "--json"], + "orca status", + ) + + def test_create_response_must_have_certain_handle(self) -> None: + valid = subprocess.CompletedProcess( + ["orca"], + 0, + stdout=json.dumps( + { + "ok": True, + "result": {"terminal": {"handle": "term-1"}}, + "_meta": {"runtimeId": "runtime-1"}, + } + ), + stderr="", + ) + with mock.patch.object(launch_worker, "run_process", return_value=valid): + response, handle = launch_worker.run_orca_create(["orca"]) + self.assertEqual(handle, "term-1") + self.assertTrue(response["ok"]) + + unsafe_results = [ + subprocess.CompletedProcess(["orca"], 1, stdout="", stderr="failed"), + subprocess.CompletedProcess(["orca"], 0, stdout="{", stderr=""), + subprocess.CompletedProcess( + ["orca"], + 0, + stdout=json.dumps({"ok": True, "result": {}}), + stderr="", + ), + subprocess.CompletedProcess( + ["orca"], + 0, + stdout=json.dumps( + {"result": {"terminal": {"handle": "term-1"}}} + ), + stderr="", + ), + ] + for completed in unsafe_results: + with ( + self.subTest(completed=completed), + mock.patch.object( + launch_worker, + "run_process", + return_value=completed, + ), + self.assertRaises(launch_worker.IndeterminateLaunch), + ): + launch_worker.run_orca_create(["orca"]) + + def test_create_transport_and_decode_failures_are_indeterminate(self) -> None: + failures = ( + UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"), + subprocess.SubprocessError("transport failed"), + KeyboardInterrupt(), + RecursionError("response nesting too deep"), + ) + for failure in failures: + with ( + self.subTest(failure=type(failure).__name__), + mock.patch.object( + launch_worker, + "run_process", + side_effect=failure, + ), + self.assertRaisesRegex( + launch_worker.IndeterminateLaunch, + "可能已创建终端", + ), + ): + launch_worker.run_orca_create(["orca"]) + + def test_indeterminate_create_records_identity_and_does_not_retry(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + executable = Path(temporary.name) / "codex" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o700) + plan = plan_for(REPO_ROOT, executable) + updates: list[tuple[str, dict]] = [] + + def capture_update(launch_id: str, **changes): + updates.append((launch_id, changes)) + return changes + + with ( + mock.patch.object( + launch_worker.secrets, + "token_hex", + side_effect=["a" * 64, "b" * 64], + ), + mock.patch.object( + launch_worker, + "create_record", + return_value=Path("/tmp/ack-record.json"), + ), + mock.patch.object( + launch_worker, + "resolve_executable", + return_value=Path("/trusted/orca"), + ), + mock.patch.object( + launch_worker, + "run_json", + return_value={"ok": True, "_meta": {"runtimeId": "runtime-1"}}, + ), + mock.patch.object( + launch_worker, + "run_orca_create", + side_effect=launch_worker.IndeterminateLaunch("lost response"), + ) as create, + mock.patch.object(launch_worker, "assert_identity_current"), + mock.patch.object(launch_worker, "update_record", side_effect=capture_update), + ): + with self.assertRaisesRegex( + launch_worker.IndeterminateLaunch, + "launchId=" + "a" * 64, + ): + launch_worker.launch_with_orca(plan) + + self.assertEqual(create.call_count, 1) + self.assertTrue( + any(change.get("state") == "indeterminate" for _, change in updates) + ) + + def test_indeterminate_create_record_failure_does_not_mask_reconcile(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + executable = Path(temporary.name) / "codex" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o700) + plan = plan_for(REPO_ROOT, executable) + + with ( + mock.patch.object( + launch_worker.secrets, + "token_hex", + side_effect=["a" * 64, "b" * 64], + ), + mock.patch.object( + launch_worker, + "create_record", + return_value=Path("/tmp/ack-record.json"), + ), + mock.patch.object( + launch_worker, + "resolve_executable", + return_value=Path("/trusted/orca"), + ), + mock.patch.object( + launch_worker, + "run_json", + return_value={"ok": True, "_meta": {"runtimeId": "runtime-1"}}, + ), + mock.patch.object( + launch_worker, + "run_orca_create", + side_effect=launch_worker.IndeterminateLaunch("lost response"), + ), + mock.patch.object(launch_worker, "assert_identity_current"), + mock.patch.object( + launch_worker, + "update_record", + side_effect=PermissionError("record denied"), + ), + self.assertRaisesRegex( + launch_worker.IndeterminateLaunch, + "reconcile required", + ) as raised, + ): + launch_worker.launch_with_orca(plan) + + detail = str(raised.exception) + self.assertIn("launchId=" + "a" * 64, detail) + self.assertIn("record=/tmp/ack-record.json", detail) + self.assertIn("record denied", detail) + + def test_close_requires_durable_structured_confirmation(self) -> None: + valid = subprocess.CompletedProcess( + ["orca"], + 0, + stdout=json.dumps( + { + "ok": True, + "result": { + "close": { + "handle": "term-1", + "tabId": "tab-1", + "closeMode": "tab", + } + }, + "_meta": {"runtimeId": "runtime-1"}, + } + ), + stderr="", + ) + with mock.patch.object( + launch_worker, + "run_process", + return_value=valid, + ) as run_process: + confirmation = launch_worker.run_orca_close( + Path("/trusted/orca"), + "term-1", + "runtime-1", + ) + + self.assertEqual( + confirmation, + { + "runtimeId": "runtime-1", + "handle": "term-1", + "tabId": "tab-1", + "closeMode": "tab", + }, + ) + close_argv = run_process.call_args.args[0] + self.assertEqual( + close_argv, + [ + "/trusted/orca", + "terminal", + "close", + "--terminal", + "term-1", + "--tab", + "--json", + ], + ) + + unsafe_results = [ + subprocess.CompletedProcess( + ["orca"], + 1, + stdout="", + stderr="close rejected", + ), + subprocess.CompletedProcess( + ["orca"], + 0, + stdout="{", + stderr="", + ), + subprocess.CompletedProcess( + ["orca"], + 0, + stdout=json.dumps( + { + "ok": True, + "result": {"close": {"handle": "term-1"}}, + "_meta": {"runtimeId": "runtime-1"}, + } + ), + stderr="", + ), + subprocess.CompletedProcess( + ["orca"], + 0, + stdout=json.dumps( + { + "ok": True, + "result": { + "close": { + "handle": "term-other", + "tabId": "tab-1", + "closeMode": "tab", + } + }, + "_meta": {"runtimeId": "runtime-1"}, + } + ), + stderr="", + ), + subprocess.CompletedProcess( + ["orca"], + 0, + stdout=json.dumps( + { + "ok": True, + "result": { + "close": { + "handle": "term-1", + "tabId": "tab-1", + "closeMode": "tab", + } + }, + "_meta": {"runtimeId": "runtime-other"}, + } + ), + stderr="", + ), + ] + for completed in unsafe_results: + with ( + self.subTest(completed=completed), + mock.patch.object( + launch_worker, + "run_process", + return_value=completed, + ), + self.assertRaises(launch_worker.IndeterminateLaunch), + ): + launch_worker.run_orca_close( + Path("/trusted/orca"), + "term-1", + "runtime-1", + ) + + def test_post_handle_failure_keeps_original_error_when_close_is_confirmed( + self, + ) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + executable = Path(temporary.name) / "codex" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o700) + plan = plan_for(REPO_ROOT, executable) + updates: list[tuple[str, dict]] = [] + + def capture_update(launch_id: str, **changes): + updates.append((launch_id, changes)) + return changes + + with ( + mock.patch.object( + launch_worker.secrets, + "token_hex", + side_effect=["a" * 64, "b" * 64], + ), + mock.patch.object( + launch_worker, + "create_record", + return_value=Path("/tmp/ack-record.json"), + ), + mock.patch.object( + launch_worker, + "resolve_executable", + return_value=Path("/trusted/orca"), + ), + mock.patch.object( + launch_worker, + "run_json", + side_effect=[ + {"ok": True, "_meta": {"runtimeId": "runtime-1"}}, + launch_worker.LaunchError("show failed"), + ], + ), + mock.patch.object( + launch_worker, + "run_orca_create", + return_value=( + { + "ok": True, + "result": {"terminal": {"handle": "term-1"}}, + "_meta": {"runtimeId": "runtime-1"}, + }, + "term-1", + ), + ), + mock.patch.object(launch_worker, "assert_identity_current"), + mock.patch.object( + launch_worker, + "run_orca_close", + return_value={ + "runtimeId": "runtime-1", + "handle": "term-1", + "tabId": "tab-1", + "closeMode": "tab", + }, + ) as close, + mock.patch.object( + launch_worker, + "update_record", + side_effect=capture_update, + ), + self.assertRaisesRegex(launch_worker.LaunchError, "show failed"), + ): + launch_worker.launch_with_orca(plan) + + close.assert_called_once_with( + Path("/trusted/orca"), + "term-1", + "runtime-1", + ) + self.assertEqual(updates[-1][1]["state"], "failed") + self.assertEqual(updates[-1][1]["error"], "show failed") + self.assertTrue(updates[-1][1]["cleanup"]["confirmed"]) + + def test_post_handle_close_failure_is_indeterminate_and_requires_reconcile( + self, + ) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + executable = Path(temporary.name) / "codex" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o700) + plan = plan_for(REPO_ROOT, executable) + updates: list[tuple[str, dict]] = [] + + def capture_update(launch_id: str, **changes): + updates.append((launch_id, changes)) + return changes + + with ( + mock.patch.object( + launch_worker.secrets, + "token_hex", + side_effect=["a" * 64, "b" * 64], + ), + mock.patch.object( + launch_worker, + "create_record", + return_value=Path("/tmp/ack-record.json"), + ), + mock.patch.object( + launch_worker, + "resolve_executable", + return_value=Path("/trusted/orca"), + ), + mock.patch.object( + launch_worker, + "run_json", + side_effect=[ + {"ok": True, "_meta": {"runtimeId": "runtime-1"}}, + launch_worker.LaunchError("show failed"), + ], + ), + mock.patch.object( + launch_worker, + "run_orca_create", + return_value=( + { + "ok": True, + "result": {"terminal": {"handle": "term-1"}}, + "_meta": {"runtimeId": "runtime-1"}, + }, + "term-1", + ), + ), + mock.patch.object(launch_worker, "assert_identity_current"), + mock.patch.object( + launch_worker, + "run_orca_close", + side_effect=launch_worker.IndeterminateLaunch("close timeout"), + ), + mock.patch.object( + launch_worker, + "update_record", + side_effect=capture_update, + ), + self.assertRaisesRegex( + launch_worker.IndeterminateLaunch, + "reconcile required", + ), + ): + launch_worker.launch_with_orca(plan) + + self.assertEqual(updates[-1][1]["state"], "indeterminate") + self.assertTrue(updates[-1][1]["cleanup"]["reconcileRequired"]) + self.assertIn("show failed", updates[-1][1]["error"]) + self.assertIn("close timeout", updates[-1][1]["error"]) + + def test_pending_record_failure_does_not_skip_post_handle_close(self) -> None: + updates: list[dict] = [] + + def fail_pending(_launch_id: str, **changes): + updates.append(changes) + if len(updates) == 1: + raise PermissionError("pending denied") + return changes + + close_confirmation = { + "runtimeId": "runtime-1", + "handle": "term-1", + "tabId": "tab-1", + "closeMode": "tab", + } + with post_handle_failure_context( + update_side_effect=fail_pending, + close_side_effect=close_confirmation, + ) as (plan, close, _update): + with self.assertRaisesRegex(launch_worker.LaunchError, "show failed"): + launch_worker.launch_with_orca(plan) + + close.assert_called_once_with( + Path("/trusted/orca"), + "term-1", + "runtime-1", + ) + self.assertEqual(len(updates), 2) + self.assertEqual(updates[-1]["state"], "failed") + + def test_close_and_reconcile_record_failures_stay_indeterminate(self) -> None: + updates: list[dict] = [] + + def fail_reconcile(_launch_id: str, **changes): + updates.append(changes) + if len(updates) == 2: + raise OSError("reconcile write failed") + return changes + + with post_handle_failure_context( + update_side_effect=fail_reconcile, + close_side_effect=launch_worker.IndeterminateLaunch("close timeout"), + ) as (plan, close, _update): + with self.assertRaisesRegex( + launch_worker.IndeterminateLaunch, + "reconcile required", + ) as raised: + launch_worker.launch_with_orca(plan) + + close.assert_called_once() + detail = str(raised.exception) + self.assertIn("close timeout", detail) + self.assertIn("reconcile write failed", detail) + self.assertIn("launchId=" + "a" * 64, detail) + self.assertIn("record=/tmp/ack-record.json", detail) + + def test_confirmed_close_with_failed_state_write_is_indeterminate(self) -> None: + updates: list[dict] = [] + + def fail_final(_launch_id: str, **changes): + updates.append(changes) + if len(updates) == 2: + raise PermissionError("final write denied") + return changes + + close_confirmation = { + "runtimeId": "runtime-1", + "handle": "term-1", + "tabId": "tab-1", + "closeMode": "tab", + } + with post_handle_failure_context( + update_side_effect=fail_final, + close_side_effect=close_confirmation, + ) as (plan, close, _update): + with self.assertRaisesRegex( + launch_worker.IndeterminateLaunch, + "reconcile required", + ) as raised: + launch_worker.launch_with_orca(plan) + + close.assert_called_once() + detail = str(raised.exception) + self.assertIn("close confirmed", detail) + self.assertIn("final write denied", detail) + self.assertIn("launchId=" + "a" * 64, detail) + self.assertIn("record=/tmp/ack-record.json", detail) + + def test_successful_launch_binds_terminal_before_nonce_and_receipt(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + executable = Path(temporary.name) / "codex" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o700) + plan = plan_for(REPO_ROOT, executable) + launch_id = "a" * 64 + nonce = "b" * 64 + metadata = { + "handle": "term-1", + "incarnationId": "incarnation-1", + "worktreePath": str(REPO_ROOT), + "connected": True, + "writable": True, + } + + def fake_run_json(argv: list[str], _label: str) -> dict: + if argv[1] == "status": + return {"ok": True, "_meta": {"runtimeId": "runtime-1"}} + if argv[1:3] == ["terminal", "show"]: + return { + "ok": True, + "result": {"terminal": metadata}, + "_meta": {"runtimeId": "runtime-1"}, + } + if argv[1:3] == ["terminal", "send"]: + self.assertEqual(argv[argv.index("--text") + 1], nonce) + return {"ok": True, "_meta": {"runtimeId": "runtime-1"}} + self.fail(f"unexpected Orca call: {argv}") + + with ( + mock.patch.object( + launch_worker.secrets, + "token_hex", + side_effect=[launch_id, nonce], + ), + mock.patch.object( + launch_worker, + "create_record", + return_value=Path("/tmp/ack-record.json"), + ), + mock.patch.object( + launch_worker, + "resolve_executable", + return_value=Path("/trusted/orca"), + ), + mock.patch.object(launch_worker, "run_json", side_effect=fake_run_json), + mock.patch.object( + launch_worker, + "run_orca_create", + return_value=( + { + "ok": True, + "result": {"terminal": {"handle": "term-1"}}, + "_meta": {"runtimeId": "runtime-1"}, + }, + "term-1", + ), + ) as create, + mock.patch.object(launch_worker, "assert_identity_current"), + mock.patch.object(launch_worker, "update_record"), + mock.patch.object(launch_worker, "wait_for_bootstrap") as wait, + ): + receipt = launch_worker.launch_with_orca(plan) + + expected_proof = launch_worker.bootstrap_ready_proof( + launch_id, + plan["launchFingerprint"], + nonce, + ) + wait.assert_called_once_with(launch_id, expected_proof) + create_argv = create.call_args.args[0] + command = create_argv[create_argv.index("--command") + 1] + command_tokens = shlex.split(command) + self.assertEqual(command_tokens[-1], launch_id) + self.assertNotIn(plan["requested"]["model"], command_tokens) + self.assertNotIn(plan["taskId"], command_tokens) + self.assertEqual(receipt["binding"]["handle"], "term-1") + self.assertEqual( + receipt["receiptHash"], + worker_profiles.receipt_hash(receipt), + ) + + +class RecordSecurityTests(unittest.TestCase): + def test_bootstrap_update_cannot_overwrite_cleanup_reconciliation(self) -> None: + record = { + "state": "indeterminate", + "error": "reconcile required", + "cleanup": { + "confirmed": False, + "reconcileRequired": True, + }, + } + + @contextlib.contextmanager + def fake_locked_record(_launch_id: str): + yield record + + with mock.patch.object( + launch_worker, + "locked_record", + fake_locked_record, + ): + result = launch_worker.update_record( + "a" * 64, + preserve_cleanup_state=True, + state="closed", + error=None, + ) + + self.assertEqual(result["state"], "indeterminate") + self.assertEqual(result["error"], "reconcile required") + self.assertTrue(result["cleanup"]["reconcileRequired"]) + + def test_record_requires_exact_private_mode_and_single_link(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + launch_id = "a" * 64 + record = root / f"{launch_id}.json" + record.write_text( + json.dumps({"launchId": launch_id}), + encoding="utf-8", + ) + record.chmod(0o644) + with self.assertRaisesRegex(launch_worker.LaunchError, "0600"): + launch_worker._read_record_file(record) + + record.chmod(0o600) + hardlink = root / "hardlink.json" + os.link(record, hardlink) + with self.assertRaisesRegex(launch_worker.LaunchError, "hard link"): + launch_worker._read_record_file(record) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ack_skill.py b/tests/test_ack_skill.py index eb5a563..591563d 100644 --- a/tests/test_ack_skill.py +++ b/tests/test_ack_skill.py @@ -34,6 +34,16 @@ class AckSkillContentTests(unittest.TestCase): self.assertIn('display_name: "ACK"', metadata) self.assertIn("allow_implicit_invocation: false", metadata) + def test_model_routing_is_fingerprint_bound_and_does_not_trust_receipt_reuse(self) -> None: + content = ( + REPO_ROOT / "skills" / "ack" / "references" / "model-routing.md" + ).read_text(encoding="utf-8") + + self.assertIn("--expected-launch-fingerprint", content) + self.assertIn("allowlist-v1", content) + self.assertIn("禁止根据持久化 receipt 自动复用", content) + self.assertIn("launcher 身份证明", content) + def test_ack_knowledge_resources_and_version_are_present(self) -> None: ack_dir = REPO_ROOT / "skills" / "ack" @@ -44,9 +54,17 @@ class AckSkillContentTests(unittest.TestCase): "scripts/validate_knowledge.py", "scripts/select_knowledge.py", "scripts/run_verification.py", + "scripts/worker_profiles.py", + "scripts/launch_worker.py", ): self.assertTrue((ack_dir / relative_path).is_file(), relative_path) - self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.9.0") + self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.10.0") + self.assertIn( + 'ackVersion: "<接入时的 ack skill 版本>"', + (ack_dir / "templates" / "tasks.template.yaml").read_text( + encoding="utf-8" + ), + ) if __name__ == "__main__": diff --git a/tests/test_ack_tasks_validation.py b/tests/test_ack_tasks_validation.py index bbb3638..3e048f5 100644 --- a/tests/test_ack_tasks_validation.py +++ b/tests/test_ack_tasks_validation.py @@ -1,6 +1,9 @@ from __future__ import annotations +import copy +import importlib.util import json +import re import subprocess import sys import tempfile @@ -12,6 +15,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] VALIDATOR = REPO_ROOT / "skills" / "ack" / "scripts" / "validate_tasks.py" EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml" +SCHEMA = REPO_ROOT / "skills" / "ack" / "templates" / "tasks.schema.json" def valid_knowledge_board() -> dict: @@ -59,6 +63,21 @@ def valid_knowledge_board() -> dict: } +def valid_manual_routing_board() -> dict: + board = valid_knowledge_board() + board["ackVersion"] = "0.10.0" + board["project"]["orchestration"] = { + "profileVersion": 1, + "mode": "manual", + "allowedWorktrees": [], + "modelAllowlist": {}, + "profiles": {}, + "defaults": {}, + } + board["workerReceipts"] = [] + return board + + class AckTaskValidationTests(unittest.TestCase): def run_validator( self, @@ -129,6 +148,160 @@ class AckTaskValidationTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("任务板校验通过", result.stdout) + def test_v010_requires_structured_routing_but_v009_remains_readable(self) -> None: + current = valid_knowledge_board() + current["ackVersion"] = "0.10.0" + self.assert_board_rejected_in_all_modes( + current, + "project.orchestration: is required", + ) + + legacy = valid_knowledge_board() + legacy["ackVersion"] = "0.9.0" + self.assert_board_accepted_in_all_modes(legacy) + + def test_builtin_rejects_invalid_ack_semver_in_all_modes(self) -> None: + for invalid in ("0.10", "00.10.0", "0.10.01", "v0.10.0", "0.10.0-"): + with self.subTest(ack_version=invalid): + board = valid_knowledge_board() + board["ackVersion"] = invalid + self.assert_board_rejected_in_all_modes( + board, + "ackVersion 必须是合法 SemVer", + ) + + def test_schema_declares_semver_routing_and_attempt_contracts(self) -> None: + schema = json.loads(SCHEMA.read_text(encoding="utf-8")) + ack_pattern = re.compile(schema["properties"]["ackVersion"]["pattern"]) + for valid in ("0.9.0", "0.10.0", "0.11.2-alpha.1+build.7", "1.0.0"): + with self.subTest(valid_semver=valid): + self.assertIsNotNone(ack_pattern.fullmatch(valid)) + for invalid in ("0.10", "00.10.0", "0.10.01", "v0.10.0", "0.10.0-"): + with self.subTest(invalid_semver=invalid): + self.assertIsNone(ack_pattern.fullmatch(invalid)) + + current_gate, orchestration_gate, receipts_gate = schema["allOf"] + current_pattern = re.compile( + current_gate["if"]["properties"]["ackVersion"]["pattern"] + ) + for current in ("0.10.0", "0.99.1", "1.0.0", "12.34.56+build"): + self.assertIsNotNone(current_pattern.search(current)) + self.assertIsNone(current_pattern.search("0.9.99")) + self.assertIn("workerReceipts", current_gate["then"]["required"]) + self.assertIn( + "workerReceipts", + current_gate["then"]["properties"], + ) + self.assertIn( + "orchestration", + current_gate["then"]["properties"]["project"]["required"], + ) + self.assertIn( + "orchestration", + current_gate["then"]["properties"]["project"]["properties"], + ) + self.assertIn("workerReceipts", orchestration_gate["then"]["required"]) + self.assertIn( + "workerReceipts", + orchestration_gate["then"]["properties"], + ) + self.assertIn( + "orchestration", + receipts_gate["then"]["properties"]["project"]["required"], + ) + self.assertIn( + "orchestration", + receipts_gate["then"]["properties"]["project"]["properties"], + ) + for gate in (current_gate, orchestration_gate, receipts_gate): + self.assertEqual( + gate["then"]["properties"]["tasks"]["$ref"], + "#/definitions/launchableTasks", + ) + + role_dispatch = schema["definitions"]["roleDispatch"] + self.assertIn("attemptId", role_dispatch["required"]) + receipt_rule = role_dispatch["allOf"][0] + self.assertEqual( + receipt_rule["then"]["properties"]["attemptId"]["type"], + "null", + ) + self.assertEqual( + receipt_rule["else"]["properties"]["attemptId"]["type"], + "string", + ) + + @unittest.skipUnless( + importlib.util.find_spec("jsonschema") is not None, + "jsonschema is required for the schema-only contract test", + ) + def test_schema_alone_enforces_v010_and_mutual_routing_presence(self) -> None: + import jsonschema # type: ignore + + schema = json.loads(SCHEMA.read_text(encoding="utf-8")) + validator = jsonschema.Draft7Validator(schema) + + def messages(board: dict) -> list[str]: + return [error.message for error in validator.iter_errors(board)] + + base = { + "version": 1, + "ackVersion": "0.9.0", + "project": {"name": "demo"}, + "tasks": [], + } + self.assertEqual(messages(base), []) + + for current in ("0.10.0", "0.11.2-alpha.1+build.7", "1.0.0"): + with self.subTest(ack_version=current): + board = copy.deepcopy(base) + board["ackVersion"] = current + errors = messages(board) + self.assertIn("'workerReceipts' is a required property", errors) + self.assertIn("'orchestration' is a required property", errors) + + manual = { + "profileVersion": 1, + "mode": "manual", + "allowedWorktrees": [], + "modelAllowlist": {}, + "profiles": {}, + "defaults": {}, + } + orchestration_only = copy.deepcopy(base) + orchestration_only["project"]["orchestration"] = manual + self.assertIn( + "'workerReceipts' is a required property", + messages(orchestration_only), + ) + + receipts_only = copy.deepcopy(base) + receipts_only["workerReceipts"] = [] + self.assertIn( + "'orchestration' is a required property", + messages(receipts_only), + ) + + complete = copy.deepcopy(orchestration_only) + complete["ackVersion"] = "0.10.0+routing.1" + complete["workerReceipts"] = [] + self.assertEqual(messages(complete), []) + + def test_v010_task_ids_match_launcher_while_legacy_ids_remain_readable( + self, + ) -> None: + current = valid_manual_routing_board() + current["tasks"][0]["id"] = "BUG/001" + self.assert_board_rejected_in_all_modes( + current, + "v0.10 自动路由 id 只允许字母、数字、点、下划线和连字符", + ) + + legacy = valid_knowledge_board() + legacy["ackVersion"] = "0.9.0" + legacy["tasks"][0]["id"] = "BUG/001" + self.assert_board_accepted_in_all_modes(legacy) + def test_knowledge_applied_and_checks_must_reference_selected_knowledge(self) -> None: result = self.run_validator( """ @@ -582,6 +755,18 @@ class AckTaskValidationTests(unittest.TestCase): "T-RESOLUTION.resolution: 必须是对象", ) + def test_dispatch_rejects_free_command_field_without_jsonschema(self) -> None: + board = valid_knowledge_board() + board["tasks"][0]["dispatch"] = { + "command": "cursor-agent --yolo", + "rounds": [], + } + + self.assert_board_rejected_in_all_modes( + board, + "dispatch: 未知字段 'command'", + ) + def test_valid_optional_schema_fields_pass_in_all_modes(self) -> None: board = valid_knowledge_board() board.update( diff --git a/tests/test_ack_worker_profiles.py b/tests/test_ack_worker_profiles.py new file mode 100644 index 0000000..4819e4d --- /dev/null +++ b/tests/test_ack_worker_profiles.py @@ -0,0 +1,895 @@ +from __future__ import annotations + +import copy +import sys +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_DIR = REPO_ROOT / "skills" / "ack" / "scripts" +sys.path.insert(0, str(SCRIPTS_DIR)) + +import worker_profiles # noqa: E402 + + +def valid_orchestration() -> dict: + return { + "profileVersion": 1, + "mode": "orca", + "allowedWorktrees": ["/repo/demo"], + "modelAllowlist": { + "codex": { + "developer": { + "standard": ["gpt-safe-dev"], + "strong": ["gpt-safe-strong"], + }, + "test": {"standard": ["gpt-safe-test"]}, + }, + "cursor-agent": { + "developer": {"standard": ["cursor-auto"]}, + "test": {"standard": ["cursor-auto"]}, + }, + }, + "profiles": { + "codex-dev-standard": { + "role": "developer", + "cli": "codex", + "tier": "standard", + "model": "gpt-safe-dev", + "reasoningEffort": "medium", + "permissionMode": "workspace-write", + }, + "codex-dev-strong": { + "role": "developer", + "cli": "codex", + "tier": "strong", + "model": "gpt-safe-strong", + "reasoningEffort": "high", + "permissionMode": "workspace-write", + }, + "cursor-test-standard": { + "role": "test", + "cli": "cursor-agent", + "tier": "standard", + "model": "cursor-auto", + "reasoningEffort": None, + "permissionMode": "read-only", + }, + "cursor-dev-standard": { + "role": "developer", + "cli": "cursor-agent", + "tier": "standard", + "model": "cursor-auto", + "reasoningEffort": None, + "permissionMode": "workspace-write", + }, + }, + "defaults": { + "developer": "codex-dev-standard", + "test": "cursor-test-standard", + "developerUpgraded": "codex-dev-strong", + }, + } + + +def valid_receipt(orchestration: dict | None = None) -> dict: + routing = orchestration or valid_orchestration() + profile_id = "codex-dev-standard" + profile = routing["profiles"][profile_id] + executable = "/usr/local/bin/codex" + worktree_path = "/repo/demo" + argv = worker_profiles.render_worker_argv(profile, executable, worktree_path) + launch_id = "a" * 64 + requested = { + "cli": profile["cli"], + "tier": profile["tier"], + "model": profile["model"], + "reasoningEffort": profile["reasoningEffort"], + "permissionMode": profile["permissionMode"], + "executable": executable, + "executableDevice": 8, + "executableInode": 201, + "cliVersion": "codex 1.0.0", + "argv": argv, + "argvHash": worker_profiles.canonical_sha256(argv), + "environmentPolicy": "per-cli-allowlist-v1", + } + worktree = { + "path": worktree_path, + "device": 8, + "inode": 101, + "gitCommonDir": "/repo/demo/.git", + "gitCommonDevice": 8, + "gitCommonInode": 102, + } + receipt = { + "receiptVersion": 1, + "id": f"WR-{launch_id}", + "launchId": launch_id, + "profileId": profile_id, + "profileHash": worker_profiles.profile_hash(profile), + "launchFingerprint": worker_profiles.canonical_sha256( + { + "protocolVersion": 1, + "backend": "orca", + "profileId": profile_id, + "profileHash": worker_profiles.profile_hash(profile), + "createdFor": { + "taskId": "TASK-001", + "attemptId": "TASK-001-A1", + "role": "developer", + }, + "worktree": worktree, + "requested": requested, + "slot": 1, + } + ), + "slot": 1, + "createdFor": { + "taskId": "TASK-001", + "attemptId": "TASK-001-A1", + "role": "developer", + }, + "worktree": worktree, + "requested": requested, + "binding": { + "orchestrator": "orca", + "runtimeId": "runtime-001", + "handle": "terminal-001", + "incarnationId": "incarnation-001", + "observedWorktreePath": worktree_path, + "connected": True, + "writable": True, + "boundAt": "2026-07-31T12:00:01+08:00", + }, + "createdAt": "2026-07-31T12:00:00+08:00", + "receiptHash": "", + } + receipt["receiptHash"] = worker_profiles.receipt_hash(receipt) + return receipt + + +class CanonicalHashTests(unittest.TestCase): + def test_hash_is_prefixed_order_independent_and_content_sensitive(self) -> None: + first = worker_profiles.canonical_sha256( + {"b": [2, 1], "a": {"enabled": True}} + ) + reordered = worker_profiles.canonical_sha256( + {"a": {"enabled": True}, "b": [2, 1]} + ) + changed = worker_profiles.canonical_sha256( + {"a": {"enabled": False}, "b": [2, 1]} + ) + + self.assertEqual(first, reordered) + self.assertNotEqual(first, changed) + self.assertRegex(first, r"^sha256:[0-9a-f]{64}$") + + def test_hash_rejects_non_json_and_non_finite_values(self) -> None: + with self.assertRaises(ValueError): + worker_profiles.canonical_sha256({"bad": object()}) + with self.assertRaises(ValueError): + worker_profiles.canonical_sha256({"bad": float("nan")}) + cyclic: list = [] + cyclic.append(cyclic) + with self.assertRaises(ValueError): + worker_profiles.canonical_sha256(cyclic) + + def test_profile_hash_rejects_invalid_profile(self) -> None: + profile = valid_orchestration()["profiles"]["codex-dev-standard"] + profile["command"] = "codex; touch forged" + + with self.assertRaises(ValueError): + worker_profiles.profile_hash(profile) + + def test_profile_hash_binds_profile_version(self) -> None: + profile = valid_orchestration()["profiles"]["codex-dev-standard"] + + self.assertNotEqual( + worker_profiles.profile_hash(profile, profile_version=1), + worker_profiles.profile_hash(profile, profile_version=2), + ) + + +class ProfileValidationTests(unittest.TestCase): + def test_valid_orchestration_passes(self) -> None: + self.assertEqual( + worker_profiles.validate_orchestration(valid_orchestration()), + [], + ) + + def test_manual_mode_accepts_empty_structured_routing(self) -> None: + routing = { + "profileVersion": 1, + "mode": "manual", + "allowedWorktrees": [], + "modelAllowlist": {}, + "profiles": {}, + "defaults": {}, + } + + self.assertEqual(worker_profiles.validate_orchestration(routing), []) + + def test_partial_empty_allowlist_nodes_are_rejected(self) -> None: + routing = { + "profileVersion": 1, + "mode": "manual", + "allowedWorktrees": [], + "modelAllowlist": {"codex": {}}, + "profiles": {}, + "defaults": {}, + } + + errors = worker_profiles.validate_orchestration(routing) + + self.assertTrue(any("modelAllowlist.codex: must not be empty" in error for error in errors)) + + def test_profile_rejects_all_command_shaped_and_unknown_fields(self) -> None: + forbidden = ("command", "args", "env", "executable", "argv", "extraArgs") + for field in forbidden: + with self.subTest(field=field): + routing = valid_orchestration() + routing["profiles"]["codex-dev-standard"][field] = "forged" + errors = worker_profiles.validate_orchestration(routing) + self.assertTrue(any(f"unknown field '{field}'" in error for error in errors)) + + def test_routing_rejects_unknown_top_level_fields(self) -> None: + routing = valid_orchestration() + routing["command"] = "codex" + + errors = worker_profiles.validate_orchestration(routing) + + self.assertIn("project.orchestration: unknown field 'command'", errors) + + def test_model_id_rejects_shell_and_whitespace_syntax(self) -> None: + for model in ( + "safe;touch-forged", + "safe && forged", + "$(touch-forged)", + "`touch-forged`", + "safe\nforged", + "--dangerously-bypass-approvals-and-sandbox", + ): + with self.subTest(model=model): + routing = valid_orchestration() + routing["profiles"]["codex-dev-standard"]["model"] = model + errors = worker_profiles.validate_orchestration(routing) + self.assertTrue(any("safe model ID" in error for error in errors)) + + def test_only_safe_permission_modes_are_accepted(self) -> None: + for permission in ( + "danger-full-access", + "full-access", + "yolo", + "bypass", + "never", + ): + with self.subTest(permission=permission): + routing = valid_orchestration() + routing["profiles"]["codex-dev-standard"][ + "permissionMode" + ] = permission + errors = worker_profiles.validate_orchestration(routing) + self.assertTrue(any("read-only/workspace-write" in error for error in errors)) + + def test_reasoning_effort_is_required_for_codex_and_null_for_cursor(self) -> None: + codex = valid_orchestration() + codex["profiles"]["codex-dev-standard"]["reasoningEffort"] = None + cursor = valid_orchestration() + cursor["profiles"]["cursor-test-standard"]["reasoningEffort"] = "low" + + codex_errors = worker_profiles.validate_orchestration(codex) + cursor_errors = worker_profiles.validate_orchestration(cursor) + + self.assertTrue(any("Codex requires" in error for error in codex_errors)) + self.assertTrue(any("Cursor requires null" in error for error in cursor_errors)) + + def test_test_cannot_use_strong_tier(self) -> None: + routing = valid_orchestration() + profile = routing["profiles"]["cursor-test-standard"] + profile["tier"] = "strong" + routing["modelAllowlist"]["cursor-agent"]["test"]["strong"] = [ + "cursor-auto" + ] + + errors = worker_profiles.validate_orchestration(routing) + + self.assertTrue(any("Test may only use standard" in error for error in errors)) + self.assertTrue(any("Test cannot define a strong allowlist" in error for error in errors)) + + def test_profile_model_must_match_exact_cli_role_tier_allowlist(self) -> None: + routing = valid_orchestration() + routing["profiles"]["codex-dev-standard"]["model"] = "other-safe-model" + + errors = worker_profiles.validate_orchestration(routing) + + self.assertTrue(any("is not allowed for its cli/role/tier" in error for error in errors)) + + def test_defaults_require_matching_role_and_standard_tier(self) -> None: + wrong_role = valid_orchestration() + wrong_role["defaults"]["test"] = "codex-dev-standard" + strong_default = valid_orchestration() + strong_default["defaults"]["developer"] = "codex-dev-strong" + + role_errors = worker_profiles.validate_orchestration(wrong_role) + tier_errors = worker_profiles.validate_orchestration(strong_default) + + self.assertTrue(any("profile role must be test" in error for error in role_errors)) + self.assertTrue(any("default profile must use standard" in error for error in tier_errors)) + + def test_developer_upgraded_default_requires_developer_strong(self) -> None: + valid = valid_orchestration() + wrong_tier = valid_orchestration() + wrong_tier["defaults"]["developerUpgraded"] = "codex-dev-standard" + wrong_role = valid_orchestration() + wrong_role["defaults"]["developerUpgraded"] = "cursor-test-standard" + + self.assertEqual(worker_profiles.validate_orchestration(valid), []) + tier_errors = worker_profiles.validate_orchestration(wrong_tier) + role_errors = worker_profiles.validate_orchestration(wrong_role) + self.assertTrue(any("must use strong tier" in error for error in tier_errors)) + self.assertTrue(any("profile role must be developer" in error for error in role_errors)) + + def test_malformed_scalar_types_return_errors_instead_of_raising(self) -> None: + for field in ("role", "cli", "tier", "reasoningEffort", "permissionMode"): + with self.subTest(profile_field=field): + profile = valid_orchestration()["profiles"]["codex-dev-standard"] + profile[field] = [] + self.assertTrue(worker_profiles.validate_profile(profile)) + + routing = valid_orchestration() + routing["mode"] = [] + self.assertTrue(worker_profiles.validate_orchestration(routing)) + + receipt = valid_receipt() + receipt["profileId"] = [] + receipt["requested"]["cli"] = [] + receipt["requested"]["tier"] = [] + receipt["requested"]["permissionMode"] = [] + receipt["receiptHash"] = worker_profiles.receipt_hash(receipt) + self.assertTrue(worker_profiles.validate_worker_receipt(receipt)) + self.assertTrue( + worker_profiles.validate_worker_receipt(receipt, orchestration=[]) + ) + + def test_orca_requires_defaults_profiles_and_allowed_worktree(self) -> None: + routing = valid_orchestration() + routing["allowedWorktrees"] = [] + routing["profiles"] = {} + routing["defaults"] = {} + + errors = worker_profiles.validate_orchestration(routing) + + self.assertTrue(any("requires at least one path" in error for error in errors)) + self.assertTrue(any("Orca mode requires profiles" in error for error in errors)) + self.assertTrue(any("missing role 'developer'" in error for error in errors)) + self.assertTrue(any("missing role 'test'" in error for error in errors)) + + +class ArgvRendererTests(unittest.TestCase): + def test_codex_exact_safe_argv(self) -> None: + profile = valid_orchestration()["profiles"]["codex-dev-standard"] + + argv = worker_profiles.render_worker_argv( + profile, + "/usr/local/bin/codex", + "/repo/demo", + ) + + self.assertEqual( + argv, + [ + "/usr/local/bin/codex", + "--strict-config", + "--model", + "gpt-safe-dev", + "--config", + "model_reasoning_effort=medium", + "--sandbox", + "workspace-write", + "--ask-for-approval", + "never", + "--cd", + "/repo/demo", + ], + ) + + def test_cursor_read_only_exact_safe_argv(self) -> None: + profile = valid_orchestration()["profiles"]["cursor-test-standard"] + + argv = worker_profiles.render_worker_argv( + profile, + "/usr/local/bin/cursor-agent", + "/repo/demo", + ) + + self.assertEqual( + argv, + [ + "/usr/local/bin/cursor-agent", + "--model", + "cursor-auto", + "--mode", + "plan", + "--sandbox", + "enabled", + "--workspace", + "/repo/demo", + ], + ) + + def test_cursor_workspace_write_adds_auto_review_without_yolo(self) -> None: + profile = valid_orchestration()["profiles"]["cursor-dev-standard"] + + argv = worker_profiles.render_worker_argv( + profile, + "/usr/local/bin/cursor-agent", + "/repo/demo", + ) + + self.assertEqual( + argv, + [ + "/usr/local/bin/cursor-agent", + "--model", + "cursor-auto", + "--auto-review", + "--sandbox", + "enabled", + "--workspace", + "/repo/demo", + ], + ) + self.assertNotIn("--yolo", argv) + self.assertNotIn("--force", argv) + + def test_renderer_rejects_wrong_executable_or_unsafe_worktree(self) -> None: + profile = valid_orchestration()["profiles"]["codex-dev-standard"] + + with self.assertRaises(ValueError): + worker_profiles.render_worker_argv( + profile, + "/tmp/cursor-agent", + "/repo/demo", + ) + with self.assertRaises(ValueError): + worker_profiles.render_worker_argv( + profile, + "/usr/local/bin/codex", + "/repo/demo/../outside", + ) + for worktree in ("/repo/./demo", "/repo/demo/", "//repo/demo"): + with self.subTest(worktree=worktree), self.assertRaises(ValueError): + worker_profiles.render_worker_argv( + profile, + "/usr/local/bin/codex", + worktree, + ) + + +class ReceiptValidationTests(unittest.TestCase): + def test_valid_receipt_is_bound_to_profile_and_routing(self) -> None: + routing = valid_orchestration() + receipt = valid_receipt(routing) + + self.assertEqual( + worker_profiles.validate_worker_receipt( + receipt, + orchestration=routing, + task_ids={"TASK-001"}, + ), + [], + ) + self.assertEqual(receipt["receiptHash"], worker_profiles.receipt_hash(receipt)) + + def test_receipt_rejects_unknown_fields_at_every_strict_level(self) -> None: + cases = ( + ((), "command"), + (("createdFor",), "command"), + (("worktree",), "command"), + (("requested",), "command"), + (("binding",), "command"), + ) + for path, field in cases: + with self.subTest(path=path): + routing = valid_orchestration() + receipt = valid_receipt(routing) + target = receipt + for component in path: + target = target[component] + target[field] = "forged" + receipt["receiptHash"] = worker_profiles.receipt_hash(receipt) + + errors = worker_profiles.validate_worker_receipt( + receipt, + orchestration=routing, + task_ids={"TASK-001"}, + ) + + self.assertTrue(any("unknown field 'command'" in error for error in errors)) + + def test_receipt_rejects_profile_and_exact_argv_drift(self) -> None: + routing = valid_orchestration() + receipt = valid_receipt(routing) + receipt["requested"]["model"] = "gpt-safe-strong" + receipt["requested"]["argv"][3] = "gpt-safe-strong" + receipt["requested"]["argvHash"] = worker_profiles.canonical_sha256( + receipt["requested"]["argv"] + ) + receipt["receiptHash"] = worker_profiles.receipt_hash(receipt) + + errors = worker_profiles.validate_worker_receipt( + receipt, + orchestration=routing, + task_ids={"TASK-001"}, + ) + + self.assertTrue(any("requested.model: does not match profile" in error for error in errors)) + self.assertTrue( + any( + "requested.argv: does not match exact renderer" in error + for error in errors + ) + ) + + def test_receipt_rejects_tampering_without_recomputed_hash(self) -> None: + routing = valid_orchestration() + receipt = valid_receipt(routing) + receipt["binding"]["handle"] = "forged-handle" + + errors = worker_profiles.validate_worker_receipt( + receipt, + orchestration=routing, + task_ids={"TASK-001"}, + ) + + self.assertTrue(any("receiptHash: does not match receipt" in error for error in errors)) + + def test_receipt_rejects_out_of_budget_attempt_empty_argv_and_id_drift(self) -> None: + routing = valid_orchestration() + + attempt = valid_receipt(routing) + attempt["createdFor"]["attemptId"] = "TASK-001-A4" + attempt["receiptHash"] = worker_profiles.receipt_hash(attempt) + attempt_errors = worker_profiles.validate_worker_receipt( + attempt, + orchestration=routing, + task_ids={"TASK-001"}, + ) + + empty_argv = valid_receipt(routing) + empty_argv["requested"]["argv"] = [] + empty_argv["requested"]["argvHash"] = worker_profiles.canonical_sha256([]) + empty_argv["receiptHash"] = worker_profiles.receipt_hash(empty_argv) + argv_errors = worker_profiles.validate_worker_receipt( + empty_argv, + orchestration=routing, + task_ids={"TASK-001"}, + ) + + wrong_id = valid_receipt(routing) + wrong_id["id"] = "WR-" + "b" * 64 + wrong_id["receiptHash"] = worker_profiles.receipt_hash(wrong_id) + id_errors = worker_profiles.validate_worker_receipt( + wrong_id, + orchestration=routing, + task_ids={"TASK-001"}, + ) + + self.assertTrue(any("A1..A3" in error for error in attempt_errors)) + self.assertTrue(any("at least 2 items" in error for error in argv_errors)) + self.assertTrue(any("must equal 'WR-' + launchId" in error for error in id_errors)) + + def test_receipt_rejects_rehashed_launch_fingerprint_tampering(self) -> None: + routing = valid_orchestration() + receipt = valid_receipt(routing) + receipt["launchFingerprint"] = worker_profiles.canonical_sha256( + {"forged": True} + ) + receipt["receiptHash"] = worker_profiles.receipt_hash(receipt) + + errors = worker_profiles.validate_worker_receipt( + receipt, + orchestration=routing, + task_ids={"TASK-001"}, + ) + + self.assertTrue( + any( + "launchFingerprint: does not match launch facts" in error + for error in errors + ) + ) + + def test_receipt_slot_is_bounded_and_bound_into_launch_fingerprint(self) -> None: + routing = valid_orchestration() + invalid = valid_receipt(routing) + invalid["slot"] = 0 + invalid["receiptHash"] = worker_profiles.receipt_hash(invalid) + invalid_errors = worker_profiles.validate_worker_receipt( + invalid, + orchestration=routing, + task_ids={"TASK-001"}, + ) + + drifted = valid_receipt(routing) + drifted["slot"] = 2 + drifted["receiptHash"] = worker_profiles.receipt_hash(drifted) + drifted_errors = worker_profiles.validate_worker_receipt( + drifted, + orchestration=routing, + task_ids={"TASK-001"}, + ) + + self.assertTrue(any("slot: must be" in error for error in invalid_errors)) + self.assertTrue( + any( + "launchFingerprint: does not match launch facts" in error + for error in drifted_errors + ) + ) + + def test_receipt_with_non_json_data_returns_errors(self) -> None: + receipt = valid_receipt() + receipt["requested"]["argv"] = [object()] + + errors = worker_profiles.validate_worker_receipt(receipt) + + self.assertTrue(any("must contain canonical JSON data" in error for error in errors)) + + def test_receipt_rejects_wrong_worktree_task_and_binding(self) -> None: + routing = valid_orchestration() + receipt = valid_receipt(routing) + receipt["worktree"]["path"] = "/repo/other" + receipt["binding"]["observedWorktreePath"] = "/repo/elsewhere" + receipt["receiptHash"] = worker_profiles.receipt_hash(receipt) + + errors = worker_profiles.validate_worker_receipt( + receipt, + orchestration=routing, + task_ids={"OTHER-TASK"}, + ) + + self.assertTrue(any("unknown task 'TASK-001'" in error for error in errors)) + self.assertTrue(any("is not in allowedWorktrees" in error for error in errors)) + self.assertTrue(any("does not match worktree.path" in error for error in errors)) + + def test_receipt_requires_safe_environment_and_live_binding(self) -> None: + routing = valid_orchestration() + receipt = valid_receipt(routing) + receipt["requested"]["environmentPolicy"] = "inherit-all" + receipt["binding"]["connected"] = False + receipt["binding"]["writable"] = False + receipt["receiptHash"] = worker_profiles.receipt_hash(receipt) + + errors = worker_profiles.validate_worker_receipt( + receipt, + orchestration=routing, + task_ids={"TASK-001"}, + ) + + self.assertTrue(any("per-cli-allowlist-v1" in error for error in errors)) + self.assertTrue(any("binding.connected: must be true" in error for error in errors)) + self.assertTrue(any("binding.writable: must be true" in error for error in errors)) + + def test_receipt_list_rejects_duplicate_receipt_and_launch_ids(self) -> None: + routing = valid_orchestration() + first = valid_receipt(routing) + duplicate = copy.deepcopy(first) + + errors = worker_profiles.validate_worker_receipts( + [first, duplicate], + routing, + task_ids={"TASK-001"}, + ) + + self.assertTrue(any("duplicate receipt ID" in error for error in errors)) + self.assertTrue(any("duplicate launch ID" in error for error in errors)) + + def test_document_requires_top_level_receipts_and_routing(self) -> None: + missing = { + "version": 1, + "project": {"name": "demo"}, + "tasks": [], + } + + errors = worker_profiles.validate_routing_document(missing) + + self.assertEqual(errors, ["project.orchestration: is required"]) + + def test_valid_document_passes_and_manual_receipts_fail(self) -> None: + routing = valid_orchestration() + document = { + "version": 1, + "project": {"name": "demo", "orchestration": routing}, + "workerReceipts": [valid_receipt(routing)], + "tasks": [{"id": "TASK-001", "title": "demo", "status": "open"}], + } + self.assertEqual(worker_profiles.validate_routing_document(document), []) + + manual = { + "version": 1, + "project": { + "name": "demo", + "orchestration": { + "profileVersion": 1, + "mode": "manual", + "allowedWorktrees": [], + "modelAllowlist": {}, + "profiles": {}, + "defaults": {}, + }, + }, + "workerReceipts": [valid_receipt(routing)], + "tasks": [{"id": "TASK-001", "title": "demo", "status": "open"}], + } + manual_errors = worker_profiles.validate_routing_document(manual) + self.assertTrue( + any("manual orchestration requires an empty list" in error for error in manual_errors) + ) + + def test_dispatch_receipt_allows_two_phase_for_exact_task_attempt_binding(self) -> None: + routing = valid_orchestration() + receipt = valid_receipt(routing) + role_dispatch = { + "profileId": receipt["profileId"], + "receiptId": receipt["id"], + "attemptId": "TASK-001-A1", + "taskId": None, + "dispatchId": None, + } + document = { + "version": 1, + "project": {"name": "demo", "orchestration": routing}, + "workerReceipts": [receipt], + "tasks": [ + { + "id": "TASK-001", + "title": "created for", + "status": "dispatched", + "dispatch": {"developer": role_dispatch}, + }, + ], + } + + self.assertEqual(worker_profiles.validate_routing_document(document), []) + role_dispatch["taskId"] = "orca-task-002" + role_dispatch["dispatchId"] = "orca-dispatch-002" + self.assertEqual(worker_profiles.validate_routing_document(document), []) + + def test_dispatch_rejects_unknown_or_mismatched_receipt(self) -> None: + routing = valid_orchestration() + receipt = valid_receipt(routing) + role_dispatch = { + "profileId": "codex-dev-strong", + "receiptId": receipt["id"], + "attemptId": "TASK-001-A1", + "taskId": None, + "dispatchId": None, + } + document = { + "version": 1, + "project": {"name": "demo", "orchestration": routing}, + "workerReceipts": [receipt], + "tasks": [ + { + "id": "TASK-001", + "title": "demo", + "status": "open", + "dispatch": {"developer": role_dispatch}, + } + ], + } + + errors = worker_profiles.validate_routing_document(document) + self.assertTrue( + any("profileId: does not match referenced receipt" in error for error in errors) + ) + role_dispatch["receiptId"] = f"WR-{'b' * 64}" + errors = worker_profiles.validate_routing_document(document) + self.assertTrue(any("unknown receipt" in error for error in errors)) + + def test_dispatch_rejects_cross_task_role_and_attempt_receipts(self) -> None: + routing = valid_orchestration() + receipt = valid_receipt(routing) + link = { + "profileId": receipt["profileId"], + "receiptId": receipt["id"], + "attemptId": "TASK-001-A1", + "taskId": None, + "dispatchId": None, + } + document = { + "version": 1, + "project": {"name": "demo", "orchestration": routing}, + "workerReceipts": [receipt], + "tasks": [ + {"id": "TASK-001", "title": "receipt owner", "status": "open"}, + { + "id": "TASK-002", + "title": "must not reuse receipt", + "status": "dispatched", + "dispatch": {"developer": link}, + }, + ], + } + + errors = worker_profiles.validate_routing_document(document) + self.assertTrue(any("must be current ACK task 'TASK-002'" in error for error in errors)) + self.assertTrue(any("must belong to current ACK task 'TASK-002'" in error for error in errors)) + + document["tasks"][1]["id"] = "TASK-001" + link["attemptId"] = "TASK-001-A2" + errors = worker_profiles.validate_routing_document(document) + self.assertTrue(any("does not match referenced receipt" in error for error in errors)) + + link["attemptId"] = "TASK-001-A1" + document["tasks"][1]["dispatch"] = {"test": link} + errors = worker_profiles.validate_routing_document(document) + self.assertTrue(any("referenced receipt role must be test" in error for error in errors)) + + def test_dispatch_attempt_presence_tracks_receipt_presence(self) -> None: + routing = valid_orchestration() + receipt = valid_receipt(routing) + link = { + "profileId": receipt["profileId"], + "receiptId": None, + "attemptId": "TASK-001-A1", + "taskId": None, + "dispatchId": None, + } + document = { + "version": 1, + "project": {"name": "demo", "orchestration": routing}, + "workerReceipts": [receipt], + "tasks": [ + { + "id": "TASK-001", + "title": "demo", + "status": "open", + "dispatch": {"developer": link}, + } + ], + } + + errors = worker_profiles.validate_routing_document(document) + self.assertTrue(any("must be null when receiptId is null" in error for error in errors)) + + link["receiptId"] = receipt["id"] + link["attemptId"] = None + errors = worker_profiles.validate_routing_document(document) + self.assertTrue(any("is required when receiptId is set" in error for error in errors)) + + def test_dispatch_requires_receipt_before_paired_runtime_ids(self) -> None: + routing = valid_orchestration() + document = { + "version": 1, + "project": {"name": "demo", "orchestration": routing}, + "workerReceipts": [], + "tasks": [ + { + "id": "TASK-001", + "title": "demo", + "status": "dispatched", + "dispatch": { + "developer": { + "profileId": "codex-dev-standard", + "receiptId": None, + "attemptId": None, + "taskId": "orca-task-001", + "dispatchId": None, + } + }, + } + ], + } + + errors = worker_profiles.validate_routing_document(document) + + self.assertTrue(any("must both be null or both be set" in error for error in errors)) + self.assertTrue(any("required before runtime dispatch IDs" in error for error in errors)) + + +if __name__ == "__main__": + unittest.main()