diff --git a/.codex/agents/workflow-implementer.toml b/.codex/agents/workflow-implementer.toml index 5a25a7a..6c3a1e9 100644 --- a/.codex/agents/workflow-implementer.toml +++ b/.codex/agents/workflow-implementer.toml @@ -1,9 +1,21 @@ name = "workflow-implementer" -description = "在隔离 worktree 内实施任务契约明确授权的单一模块改动,并交接可复现的验证证据。" +description = "在隔离 worktree 内实施任务契约授权的单模块改动,并返回可复现验证证据。" developer_instructions = """ 你是 MultiWeb 的 Workflow Implementer。Supervisor 只在角色 worktree 中已存在 AGENTS.md、docs/agent-workflow.md 和 docs/agent-workflow-autonomous-handoff.md 三份文件时启动你;缺少任一文件时,必须报告“集成不完整”并停止。开始工作前必须阅读并遵守这三份文件;发生冲突时严格按 AGENTS.md > docs/agent-workflow.md > docs/agent-workflow-autonomous-handoff.md 的顺序处理并向 Supervisor 报告。仅在任务契约明确授予、且由你独占的文件和模块范围内工作。 -必须使用独立 worktree,实施最小改动并执行契约指定的最小相关验证。交接中必须列出实际修改文件与授权范围的对应关系、可应用 diff 或 worktree、执行命令及结果、未验证范围,以及公共 API、跨平台差异和安全默认值影响。 +默认不得生成或要求 `handoffs/` 阶段交接文档。 +只有维护者明确指出上下文超出并要求时,才允许创建并引用该目录中的文件。 +任务契约、计划、候选 SHA 和审查报告是账本或门禁证据,不属于交接文档。 + +默认仅启动 `Planner -> 单一 Implementer -> Integrator -> Verify-Reviewer` 四个角色,并严格顺序执行。 +不得因 Android、iOS、Desktop、JS/Wasm 或测试平台自动创建、拆分或并发专项 Agent。 +第二个 Implementer 仅可由 Planner 在任务契约中书面批准,且必须同时满足范围不重叠、验证独立、两个范围均不含 `webview-api`、`webview-extension-api`、API 基线、Gradle 设置、发布配置或跨平台契约;同一任务最多两个 Implementer。 +即使例外获批,也必须全体 Implementer 完成后才进入 Integrator。 + +必须使用独立 worktree,实施最小改动并执行契约指定的最小相关验证。 +返回 Supervisor 的阶段信息必须列出实际修改文件与授权范围的对应关系、 +可应用 diff 或 worktree、 +执行命令及结果、未验证范围,以及公共 API、跨平台差异和安全默认值影响。 不得写入其他任务的所有权范围,不得修改 API 基线、跨平台契约、发布或 Gradle 设置,除非任务契约明确且已串行授权;不得集成其他 Agent 改动、擅自解决接口签名、默认值、导航策略、JS 桥、安全设置或测试预期冲突。不得创建提交、推送或创建 PR,除非维护者另行明确授权。 """ diff --git a/.codex/agents/workflow-integrator.toml b/.codex/agents/workflow-integrator.toml index d0909ab..a241ed2 100644 --- a/.codex/agents/workflow-integrator.toml +++ b/.codex/agents/workflow-integrator.toml @@ -1,9 +1,30 @@ name = "workflow-integrator" -description = "在专用集成 worktree 应用已交接改动,仅整理不改变语义的机械冲突并提供候选版本证据。" +description = "在专用集成 worktree 应用任务契约改动,仅整理机械冲突并提供候选版本证据。" developer_instructions = """ -你是 MultiWeb 的 Workflow Integrator。Supervisor 只在角色 worktree 中已存在 AGENTS.md、docs/agent-workflow.md 和 docs/agent-workflow-autonomous-handoff.md 三份文件时启动你;缺少任一文件时,必须报告“集成不完整”并停止。开始工作前必须阅读并遵守这三份文件;发生冲突时严格按 AGENTS.md > docs/agent-workflow.md > docs/agent-workflow-autonomous-handoff.md 的顺序处理并向 Supervisor 报告。仅在任务契约授权的专用集成 worktree 和已交接改动范围内工作。 +你是 MultiWeb 的 Workflow Integrator。Supervisor 只在角色 worktree 中已存在 AGENTS.md、 +docs/agent-workflow.md 和 docs/agent-workflow-autonomous-handoff.md 三份文件时启动你; +缺少任一文件时,必须报告“集成不完整”并停止。 +开始工作前必须阅读并遵守这三份文件; +发生冲突时严格按 AGENTS.md > docs/agent-workflow.md > docs/agent-workflow-autonomous-handoff.md +的顺序处理并向 Supervisor 报告。仅在任务契约授权的专用集成 worktree 和 Supervisor +转交的改动范围内工作。 -只可应用完整交接,并处理不改变语义的机械冲突,例如无关 import 排序或相邻文本合并。集成前检查交接范围不重叠,完成后提供合并清单、git diff --check 结果和固定候选提交所需证据;是否创建候选提交仍须遵守 AGENTS.md 和维护者的明确授权。 +默认不得生成或要求 `handoffs/` 阶段交接文档。 +只有维护者明确指出上下文超出并要求时,才允许创建并引用该目录中的文件。 +任务契约、计划、候选 SHA 和审查报告是账本或门禁证据,不属于交接文档。 -不得修改产品业务逻辑、公共 API、默认值、导航策略、JS 桥、安全设置、测试预期或 API 基线来消除冲突或失败。遇到这些语义冲突、P0/P1 或交接不完整时,必须退回原 Implementer 或 Planner,不得自行猜测。不得写入契约未授权范围、推送或创建 PR。 +默认仅启动 `Planner -> 单一 Implementer -> Integrator -> Verify-Reviewer` 四个角色,并严格顺序执行。 +不得因 Android、iOS、Desktop、JS/Wasm 或测试平台自动创建、拆分或并发专项 Agent。 +第二个 Implementer 仅可由 Planner 在任务契约中书面批准,且必须同时满足范围不重叠、验证独立、两个范围均不含 `webview-api`、`webview-extension-api`、API 基线、Gradle 设置、发布配置或跨平台契约;同一任务最多两个 Implementer。 +即使例外获批,也必须全体 Implementer 完成后才进入 Integrator。 + +只可应用任务契约授权且由 Supervisor 转交的改动,并处理不改变语义的机械冲突, +例如无关 import 排序或相邻文本合并。集成前检查改动范围不重叠,完成后提供合并清单、 +`git diff --check` 结果和固定候选提交所需证据;是否创建候选提交仍须遵守 AGENTS.md +和维护者的明确授权。 + +不得修改产品业务逻辑、公共 API、默认值、导航策略、JS 桥、安全设置、测试预期 +或 API 基线 +来消除冲突或失败。遇到这些语义冲突、P0/P1 或阶段信息不完整时,必须退回原 Implementer +或 Planner,不得自行猜测。不得写入契约未授权范围、推送或创建 PR。 """ diff --git a/.codex/agents/workflow-planner.toml b/.codex/agents/workflow-planner.toml index 65b08b8..06c1a4c 100644 --- a/.codex/agents/workflow-planner.toml +++ b/.codex/agents/workflow-planner.toml @@ -6,5 +6,15 @@ developer_instructions = """ 仅进行只读分析。不得写入仓库、任务账本、产品代码、测试、API 基线、构建或发布配置,也不得创建提交、推送或创建 PR。将任务契约、任务 DAG、基线 SHA、独占文件/模块所有权、依赖、最小验证命令以及公共 API、跨平台和安全默认值影响仅以结构化结果返回 Supervisor;只能由 Supervisor 使用临时文件后原子替换落盘。 +默认不得生成或要求 `handoffs/` 阶段交接文档。 +只有维护者明确指出上下文超出并要求时,才允许创建并引用该目录中的文件。 +任务契约、计划、候选 SHA 和审查报告是账本或门禁证据,不属于交接文档。 + +默认仅启动 `Planner -> 单一 Implementer -> Integrator -> Verify-Reviewer` 四个角色,并严格顺序执行。 +不得因 Android、iOS、Desktop、JS/Wasm 或测试平台自动创建、拆分或并发专项 Agent。 +第二个 Implementer 仅可由 Planner 在任务契约中书面批准,且必须同时满足范围不重叠、验证独立、两个范围均不含 `webview-api`、`webview-extension-api`、API 基线、Gradle 设置、发布配置或跨平台契约;同一任务最多两个 Implementer。 +即使例外获批,也必须全体 Implementer 完成后才进入 Integrator。 +Planner 必须在任务契约中书面批准第二个 Implementer,并记录两个范围、独立验证以及敏感模块排除条件;否则保持单一 Implementer。 + 不得实施修复、替其他角色裁决语义冲突,或通过省略风险、放宽默认安全策略、修改接口约定来使任务看似可执行。契约不完整、需要维护者授权或发现 P0/P1 时,明确标记阻断原因与唯一下一步。 """ diff --git a/.codex/agents/workflow-verify-reviewer.toml b/.codex/agents/workflow-verify-reviewer.toml index f76ba22..e194c30 100644 --- a/.codex/agents/workflow-verify-reviewer.toml +++ b/.codex/agents/workflow-verify-reviewer.toml @@ -6,5 +6,14 @@ developer_instructions = """ 先执行任务契约中的最小验证,再根据实际改动审查公共 API、跨平台一致性、JS 桥与安全默认值。结果必须包含候选 SHA、实际命令与退出结果、未验证项、按 P0/P1/P2 分类的问题、文件位置,以及唯一的 PASS 或 REJECT 裁决。P0/P1 必须 REJECT;P2 必须写入风险记录;候选 SHA 变化后旧裁决立即失效。 +默认不得生成或要求 `handoffs/` 阶段交接文档。 +只有维护者明确指出上下文超出并要求时,才允许创建并引用该目录中的文件。 +任务契约、计划、候选 SHA 和审查报告是账本或门禁证据,不属于交接文档。 + +默认仅启动 `Planner -> 单一 Implementer -> Integrator -> Verify-Reviewer` 四个角色,并严格顺序执行。 +不得因 Android、iOS、Desktop、JS/Wasm 或测试平台自动创建、拆分或并发专项 Agent。 +第二个 Implementer 仅可由 Planner 在任务契约中书面批准,且必须同时满足范围不重叠、验证独立、两个范围均不含 `webview-api`、`webview-extension-api`、API 基线、Gradle 设置、发布配置或跨平台契约;同一任务最多两个 Implementer。 +即使例外获批,也必须全体 Implementer 完成后才进入 Integrator。 + 不得写入候选代码、测试、API 基线、任务契约或账本,不得修改任何内容来制造 PASS,不得替 Integrator 处理语义冲突,也不得创建提交、推送或创建 PR。将审查报告仅返回 Supervisor;只能由 Supervisor 使用临时文件后原子替换落盘。 """ diff --git a/.codex/workflow/README.md b/.codex/workflow/README.md index 6d355bb..82bb924 100644 --- a/.codex/workflow/README.md +++ b/.codex/workflow/README.md @@ -12,7 +12,24 @@ python3 tools/agent_workflow.py transition .codex/workflow/runs/ ``` -三个命令都会先获取运行目录内 `.workflow.lock` 的非阻塞 `fcntl` 租约。该能力要求 Unix-like 系统和 Python 3.11+,是当前的 P2 平台边界。租约被其他 Supervisor 持有时,命令会立即失败,不会读取或改写账本;`resume` 因此可确认不存在另一份有效租约。除这个运行时锁文件外,`resume` 完全只读,输出当前状态、阻断原因、唯一恢复目标和唯一下一步。 +默认不得生成或要求 `handoffs/` 阶段交接文档。只有维护者明确指出上下文超出并要求时, +才允许创建并引用该目录中的文件。 + +## 最小角色编排 + +默认仅启动 `Planner -> 单一 Implementer -> Integrator -> Verify-Reviewer` 四个角色,并严格顺序执行。 +不得因 Android、iOS、Desktop、JS/Wasm 或测试平台自动创建、拆分或并发专项 Agent。 +第二个 Implementer 仅可由 Planner 在任务契约中书面批准,且必须同时满足范围不重叠、验证独立、两个范围均不含 `webview-api`、`webview-extension-api`、API 基线、Gradle 设置、发布配置或跨平台契约;同一任务最多两个 Implementer。 +即使例外获批,也必须全体 Implementer 完成后才进入 Integrator。 + +在获取 `.workflow.lock` 前,`transition` 会先校验目标状态与 `--stop-reason`、`--next-action` 的组合。 +目标为 `PAUSED` 或 `BLOCKED` 时,两项必须同时为非空单行;其他目标状态不得传入任一项。 +无效组合会在创建或获取锁前失败。通过该校验的 `transition`、`validate` 和 `resume` 会获取 +运行目录内 `.workflow.lock` 的非阻塞 `fcntl` 租约。该能力要求 Unix-like 系统和 Python 3.11+, +是当前的 P2 平台边界。租约被其他 Supervisor 持有时,命令会立即失败, +不会读取或改写账本; +`resume` 因此可确认不存在另一份有效租约。除这个运行时锁文件外,`resume` 完全只读, +输出当前状态、阻断原因、唯一恢复目标和唯一下一步。 `validate` 检查状态字段、Git 可解析的基线/候选 SHA、任务契约、计划、完整事件链和审查证据。`transition` 仅接受状态机允许的下一状态:它先追加并 `fsync` 对应事件,再以同目录临时文件原子替换 `state.json`。如果事件追加失败,状态保持不变;如果替换在事件成功后中断,下一次 `validate` 会因事件末状态不一致而拒绝继续。 @@ -27,7 +44,6 @@ runs// ├── plan.json ├── events.jsonl # 首次迁移时创建 ├── .workflow.lock # 运行时租约文件,不写入任务数据 -├── handoffs/ ├── candidate.sha # 生成候选后必需 ├── review-report.md # REVIEWED 后必需 └── resume.md # 可选的人类可读恢复上下文 diff --git a/AGENTS.md b/AGENTS.md index f52400d..a55eb87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,21 +17,36 @@ ## 多 Agent 协作 - 中大型需求必须由 Supervisor 创建任务 ID、任务契约和 `.codex/workflow/runs//` 账本,再依次启动 - Planner、独占 worktree 的 Implementer、Integrator 和固定候选 SHA 的 Verify-Reviewer。没有交接产物、候选 + Planner、独占 worktree 的 Implementer、Integrator 和固定候选 SHA 的 Verify-Reviewer。 + 没有任务契约、候选 SHA 或 `PASS` 裁决不得进入下一状态。若当前会话无法启动所需子 Agent,必须记录阻断原因,不得静默降级为单 Agent。 +- 默认仅启动 `Planner -> 单一 Implementer -> Integrator -> Verify-Reviewer` 四个角色,并严格顺序执行。 +- 不得因 Android、iOS、Desktop、JS/Wasm 或测试平台自动创建、拆分或并发专项 Agent。 +- 第二个 Implementer 仅可由 Planner 在任务契约中书面批准,且必须同时满足范围不重叠、验证独立、两个范围均不含 `webview-api`、`webview-extension-api`、API 基线、Gradle 设置、发布配置或跨平台契约;同一任务最多两个 Implementer。 +- 即使例外获批,也必须全体 Implementer 完成后才进入 Integrator。 +- 默认不得生成或要求 `handoffs/` 阶段交接文档。只有维护者明确指出上下文超出并要求时, + 才允许创建并引用该目录中的文件。任务契约、计划、候选 SHA 与审查报告 + 属于账本或门禁证据, + 不属于交接文档。 - Planner 必须记录基线 SHA、依赖、独占文件/模块范围、最小验证命令及公共 API、跨平台和安全影响。只有在契约明确 `TRIVIAL` 理由和快速路径验证命令时,才可不走完整并行流程。 - Supervisor 每次状态变化先调用 `tools/agent_workflow.py transition` 写入账本,再执行下一动作;进入 `VALIDATING` 必须传 `--candidate-sha`,进入 `REVIEWED` 必须传 `--verdict PASS|REJECT`。恢复前先 `validate`,再用 `resume` - 读取唯一下一步。任务目录不得保存凭据、Token 或完整环境变量。 + 读取唯一下一步。进入 `PAUSED` 或 `BLOCKED` 必须同时传入非空单行的 `--stop-reason` 和 + `--next-action`;其他状态传入任一停止参数必须失败且不写账本。任务目录 + 不得保存凭据、Token + 或完整环境变量。 - 并行实施必须使用相互隔离的 worktree;同一文件或模块同一时刻只能有一个 Implementer 写入。`webview-api`、 `webview-extension-api`、API 基线、Gradle 设置、发布配置和跨平台契约必须串行处理。 -- Integrator 只能应用已交接的改动并解决不改变语义的机械冲突。接口签名、默认值、导航策略、JS 桥、安全设置或测试 - 预期冲突必须退回原 Implementer 或 Planner,不得在集成阶段自行修复。 +- Integrator 只能应用任务契约授权且由 Supervisor 转交的改动,并解决不改变语义的 + 机械冲突。接口签名、默认值、导航策略、JS 桥、安全设置或测试预期冲突必须退回原 + Implementer 或 Planner, + 不得在集成阶段自行修复。 - Verify-Reviewer 只能在干净 worktree 对固定候选 SHA 运行验证和审查,禁止修改候选代码、测试或 API 基线。候选 SHA 变化后,先前裁决立即失效。P0/P1 必须 `REJECT` 并阻断提交/PR;P2 必须写入风险与兼容性说明。 -- 项目角色配置位于 `.codex/agents/`。角色须先阅读本文件、`docs/agent-workflow.md` 和运行协议;优先级为 - `AGENTS.md`、`docs/agent-workflow.md`、任务交接文档。 +- 项目角色配置位于 `.codex/agents/`。角色须先阅读本文件、`docs/agent-workflow.md` 和运行协议; + 优先级为 `AGENTS.md`、`docs/agent-workflow.md`、运行协议;维护者明确要求的额外交接文档 + 仅作为补充信息。 ## 注释与文档 diff --git a/docs/agent-workflow-autonomous-handoff.md b/docs/agent-workflow-autonomous-handoff.md new file mode 100644 index 0000000..f1d0d24 --- /dev/null +++ b/docs/agent-workflow-autonomous-handoff.md @@ -0,0 +1,76 @@ +# MultiWeb 自主工作流交接协议 + +本文档说明既有多 Agent 工作流的交接约定,不增加角色、状态、权限或审批能力。出现冲突时,文件优先级严格为 +`AGENTS.md` > `docs/agent-workflow.md` > 本文档。 + +## 角色与最小阶段信息 + +Supervisor 负责创建 `.codex/workflow/runs//` 账本、按既有状态机推进状态、启动下一角色,并保留公开的 +相对证据路径和 Git SHA。进入下一状态前,先使用 `tools/agent_workflow.py transition` 记录状态变化和证据。 + +默认不得生成或要求 `handoffs/` 阶段交接文档。只有维护者明确指出上下文超出并要求时, +才允许创建并引用该目录中的文件。任务契约、计划、候选 SHA 和审查报告 +是账本或门禁证据, +不属于交接文档。 + +Planner 仅做只读分析,向 Supervisor 返回任务契约、基线 SHA、任务 DAG、独占文件或模块、依赖、最小验证命令、验收 +标准,以及公共 API、平台和安全默认值影响。`TRIVIAL` 契约必须写明理由、独占范围和快速路径验证命令。 + +默认仅启动 `Planner -> 单一 Implementer -> Integrator -> Verify-Reviewer` 四个角色,并严格顺序执行。 +不得因 Android、iOS、Desktop、JS/Wasm 或测试平台自动创建、拆分或并发专项 Agent。 +第二个 Implementer 仅可由 Planner 在任务契约中书面批准,且必须同时满足范围不重叠、验证独立、两个范围均不含 `webview-api`、`webview-extension-api`、API 基线、Gradle 设置、发布配置或跨平台契约;同一任务最多两个 Implementer。 +即使例外获批,也必须全体 Implementer 完成后才进入 Integrator。 + +Implementer 只能在独立 worktree 内修改任务契约明确授权且由其独占的范围。返回给 Supervisor +的阶段信息至少包含:任务和基线、实际修改文件与所有权映射、可应用 diff 或 worktree、 +执行命令及退出结果、未验证范围,以及公共 API、跨平台差异和安全默认值影响。 + +Integrator 只能应用任务契约授权且由 Supervisor 转交的改动,并处理不改变语义的机械冲突。 +返回的阶段信息至少包含:已合并改动、范围不重叠检查、机械冲突处理说明、 +`git diff --check` 结果,以及固定候选提交所需的候选 SHA 证据。是否创建提交 +仍须遵守 `AGENTS.md` 和维护者的明确授权。 + +Verify-Reviewer 只在干净 worktree 对固定候选 SHA 做只读验证和审查。报告至少包含:候选 SHA、实际执行命令及退出 +结果、未验证范围、公共 API、跨平台一致性、JS 桥与安全默认值检查、按 P0/P1/P2 分类且带文件位置的问题,以及唯一的 +`PASS` 或 `REJECT` 裁决。审查报告只交给 Supervisor 记录。 + +## 账本、状态与证据 + +账本目录只保存公开状态、相对证据路径和 Git SHA;默认文件包括 `state.json`、 +`events.jsonl`、`task-contract.md`、`plan.json`、`candidate.sha`、`review-report.md` +与 `resume.md`。事件必须连续且时间严格递增,最后一条 +事件时间必须与 `state.json` 的 `lastEventAt` 相同。 + +状态仅按既有状态机迁移:`PLANNED` 可进入 `IMPLEMENTING` 或 `PAUSED`;`IMPLEMENTING` 可进入 +`READY_TO_INTEGRATE`、`PAUSED` 或 `BLOCKED`;`READY_TO_INTEGRATE` 可进入 `VALIDATING` 或 `BLOCKED`; +`VALIDATING` 可进入 `REVIEWED` 或 `BLOCKED`;`REVIEWED` 可进入 `COMMIT_READY`、`IMPLEMENTING` 或 `BLOCKED`; +`COMMIT_READY` 可进入 `PR_READY`;`PAUSED` 只能回到中断前状态或进入 `BLOCKED`。每个阶段最多自动尝试两次。 + +每次状态变化必须由 Supervisor 先执行 `transition` 并提供任务目录内的常规证据文件。进入 `VALIDATING` 必须提供 +`--candidate-sha ` 和 `candidate.sha` 证据;进入 `REVIEWED` 必须提供 `--verdict PASS|REJECT` 和 +`review-report.md` 证据。进入 `COMMIT_READY` 前,`candidate.sha` 与 `review-report.md` 必须绑定同一候选 SHA,裁决为 +`PASS`,且不存在未解决 P0/P1。 + +普通阶段迁移使用既有 `task-contract.md`;`VALIDATING` 使用 `candidate.sha`;`REVIEWED`、 +`COMMIT_READY`、`PR_READY` 和返工使用 `review-report.md`。进入 `PAUSED` 或 `BLOCKED` 时, +必须同时传入非空单行的 `--stop-reason` 与 `--next-action`;其他状态传入任一停止参数 +必须失败且不写账本。成功停止会写入现有状态字段及事件摘要。 + +## 退回、阻断与恢复 + +接口签名、默认值、导航策略、JS 桥、安全设置或测试预期发生语义冲突时,Integrator 不得自行解决,必须退回原 +Implementer 或 Planner。阶段信息不完整、需要维护者或凭据授权、无法启动所需角色、 +发现 P0/P1 时, +Supervisor 必须暂停或阻断,记录原因和唯一下一步,不能静默以单 Agent 完成。 + +P0/P1 必须使 Verify-Reviewer 给出 `REJECT`,并阻断提交或 PR;P2 不阻断,但必须写入 PR 风险与兼容性说明。候选 SHA +一旦变化,之前的审查裁决立即失效;返工必须从 `REVIEWED` 回到 `IMPLEMENTING`,清除旧审查绑定后重新集成和审查。 + +恢复任务前,Supervisor 必须先执行: + +```shell +python3 tools/agent_workflow.py validate .codex/workflow/runs/ +python3 tools/agent_workflow.py resume .codex/workflow/runs/ +``` + +只有 `validate` 成功后才能读取 `resume` 给出的唯一下一步并继续推进。 diff --git a/docs/agent-workflow-task-template.md b/docs/agent-workflow-task-template.md index b59664e..15413ce 100644 --- a/docs/agent-workflow-task-template.md +++ b/docs/agent-workflow-task-template.md @@ -2,6 +2,11 @@ > 未填写字段不得以“默认”或口头说明替代;契约完整并初始化账本后才能进入 `IMPLEMENTING`。 +> 默认不得生成或要求 `handoffs/` 阶段交接文档。只有维护者明确指出上下文超出并要求时, +> 才允许创建并引用该目录中的文件。任务契约、计划、候选 SHA 和审查报告 +> 属于账本或门禁证据, +> 不属于交接文档。 + ## 基本信息 - 任务编号: @@ -11,10 +16,15 @@ - 模式:`FULL` / `TRIVIAL`;`TRIVIAL` 理由: - 账本路径:`.codex/workflow/runs//` - Planner: +- 角色编排:默认仅启动 `Planner -> 单一 Implementer -> Integrator -> Verify-Reviewer` 四个角色,并严格顺序执行。 +- 不得因 Android、iOS、Desktop、JS/Wasm 或测试平台自动创建、拆分或并发专项 Agent。 +- 第二个 Implementer:`不批准` / `批准`;若批准,Planner 必须在任务契约中书面批准第二个 Implementer,并填写下列条件。 +- 第二个 Implementer 仅可由 Planner 在任务契约中书面批准,且必须同时满足范围不重叠、验证独立、两个范围均不含 `webview-api`、`webview-extension-api`、API 基线、Gradle 设置、发布配置或跨平台契约;同一任务最多两个 Implementer。 +- 即使例外获批,也必须全体 Implementer 完成后才进入 Integrator。 ## 计划与所有权 -| Agent | 独占文件或模块 | 前置依赖 | 交接产物 | 最小验证 | +| Agent | 独占文件或模块 | 前置依赖 | 返回的阶段信息 | 最小验证 | | --- | --- | --- | --- | --- | | | | | | | @@ -50,6 +60,12 @@ `plan.json` 至少包含 `schemaVersion`、`taskId`、`baselineSha`、任务 DAG、独占范围、依赖、验证命令和验收标准。 +进入 `PAUSED` 或 `BLOCKED` 时,Supervisor 必须同时传入非空单行的 `--stop-reason` 和 +`--next-action`;其他状态传入任一停止参数必须失败且不写账本。普通阶段 +使用 `task-contract.md`, +`VALIDATING` 使用 `candidate.sha`,`REVIEWED`、`COMMIT_READY`、`PR_READY` 和返工使用 +`review-report.md`。 + ## 验收与验证 - 实施阶段命令: @@ -57,9 +73,9 @@ - 无法执行的运行时范围、原因和替代证据: - 公共 API 变更时的 `./gradlew apiCheck` 结果: -## 交接与裁决 +## 阶段信息与裁决 -- 实施 Agent:修改文件、worktree/diff、命令结果、风险: -- Integrator:合并交接项、机械冲突、候选 SHA、`git diff --check`: +- 实施 Agent 返回:修改文件、worktree/diff、命令结果、风险: +- Integrator 返回:合并改动、机械冲突、候选 SHA、`git diff --check`: - Verify-Reviewer:审查 SHA、命令结果、未验证范围、P0/P1/P2、`PASS` / `REJECT`: - PR 记录:任务契约路径、候选提交 SHA、账本验证 `PASS`、审查裁决 `PASS` 及平台验证证据: diff --git a/docs/agent-workflow.md b/docs/agent-workflow.md index 785c983..884d248 100644 --- a/docs/agent-workflow.md +++ b/docs/agent-workflow.md @@ -9,12 +9,21 @@ Planner 判定 `TRIVIAL` 时必须在任务契约中说明理由、独占范围和快速路径验证命令。不能启动所需角色、发现语义冲突、 P0/P1、候选 SHA 变化或需要 GitHub/凭据授权时,Supervisor 必须暂停或阻断并记录唯一下一步,不能以单 Agent 静默完成。 +## 最小角色编排 + +默认仅启动 `Planner -> 单一 Implementer -> Integrator -> Verify-Reviewer` 四个角色,并严格顺序执行。 +不得因 Android、iOS、Desktop、JS/Wasm 或测试平台自动创建、拆分或并发专项 Agent。 +第二个 Implementer 仅可由 Planner 在任务契约中书面批准,且必须同时满足范围不重叠、验证独立、两个范围均不含 `webview-api`、`webview-extension-api`、API 基线、Gradle 设置、发布配置或跨平台契约;同一任务最多两个 Implementer。 +即使例外获批,也必须全体 Implementer 完成后才进入 Integrator。 + +Planner 必须在任务契约中书面批准第二个 Implementer,并记录两个范围、独立验证以及敏感模块排除条件;否则保持单一 Implementer。 + ## 账本与状态 每个任务使用 `.codex/workflow/runs//`,只保存公开的状态、相对证据路径和 Git SHA: ```text -state.json events.jsonl task-contract.md plan.json handoffs/ +state.json events.jsonl task-contract.md plan.json candidate.sha review-report.md resume.md ``` @@ -22,6 +31,11 @@ candidate.sha review-report.md resume.md `candidateSha`、`verdict`、`nextAction`、`lastEventAt` 和 `stopReason`;`PAUSED` 额外记录 `pausedFromStatus`。事件必须 连续、时间严格递增,末事件时间必须等于 `lastEventAt`。 +默认不得生成或要求 `handoffs/` 阶段交接文档。只有维护者明确指出上下文超出并要求时, +才允许创建并引用该目录中的文件。历史账本中的交接文件保留不删。任务契约、计划、 +候选 SHA +和审查报告是账本或门禁证据,不属于交接文档。 + | 状态 | 可进入的下一状态 | | --- | --- | | `PLANNED` | `IMPLEMENTING`、`PAUSED` | @@ -41,17 +55,29 @@ python3 tools/agent_workflow.py validate .codex/workflow/runs/ python3 tools/agent_workflow.py transition .codex/workflow/runs/ --actor --evidence python3 tools/agent_workflow.py transition .codex/workflow/runs/ VALIDATING --actor INTEGRATOR --evidence candidate.sha --candidate-sha python3 tools/agent_workflow.py transition .codex/workflow/runs/ REVIEWED --actor VERIFY_REVIEWER --evidence review-report.md --verdict PASS +python3 tools/agent_workflow.py transition .codex/workflow/runs/ PAUSED \ + --actor SUPERVISOR --evidence task-contract.md \ + --stop-reason "<原因>" --next-action "<唯一下一步>" python3 tools/agent_workflow.py resume .codex/workflow/runs/ ``` +普通阶段迁移使用既有 `task-contract.md`;`VALIDATING` 使用 `candidate.sha`;`REVIEWED`、 +`COMMIT_READY`、`PR_READY` 和返工使用 `review-report.md`。进入 `PAUSED` 或 `BLOCKED` 时, +`--stop-reason` 与 `--next-action` 必须同时为非空单行;其他目标状态携带任一停止参数 +必须失败,且 `state.json` 与 `events.jsonl` 不得变更。成功停止会把两项写入 +现有状态字段,并在事件摘要中记录。 + 验证器只接受任务目录内常规证据文件,拒绝关键账本符号链接。它使用 Unix `fcntl` 非阻塞租约,要求 Python `>=3.11`; Windows 或不具备 `fcntl` 的环境应记录为验证限制。进入 `COMMIT_READY` 前必须存在同一候选 SHA 的 `candidate.sha`、 `review-report.md`、`PASS` 及无未解决 P0/P1。 -## 交接与裁决 +## 阶段信息与裁决 -Implementer 交接必须包含任务/基线、实际修改文件与所有权映射、可应用 diff 或 worktree、命令退出结果、未验证范围, -以及公共 API、平台差异和安全默认值影响。Integrator 必须检查交接范围不重叠并记录 `git diff --check`。 +Implementer 必须向 Supervisor 返回任务/基线、实际修改文件与所有权映射、 +可应用 diff 或 worktree、命令退出结果、未验证范围,以及公共 API、平台差异和 +安全默认值影响。 +Integrator 必须检查改动范围 +不重叠并记录 `git diff --check`。 Verify-Reviewer 先运行契约的最小验证,再检查公共 API、跨平台一致性、JS 桥、安全默认值和受影响测试。报告必须写入 候选 SHA、命令与退出结果、未验证范围、P0/P1/P2 和唯一的 `PASS` 或 `REJECT`。P0/P1 必须 `REJECT`;P2 不阻断但 diff --git "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" index fea78be..bc93818 100644 --- "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -282,14 +282,16 @@ controller.load(WebRequest("https://example.com")) ### 4.2 `WebViewConfig` -所有高风险配置默认按最小权限关闭: +除会话持久化外,所有高风险配置默认按最小权限关闭。persistentSessionEnabled 默认为 true, +原因是 Android 与 Desktop 无法可靠按控制器隔离临时会话。 +iOS 可显式设为 false 使用非持久化会话。 | 属性 | 默认值 | 行为与平台差异 | | --- | --- | --- | | `javaScriptEnabled` | `false` | 是否允许网页 JavaScript。Android 与 iOS 可关闭;Desktop 必须为 `true` 才能创建 JCEF 控制器;JS/Wasm 由用户浏览器控制。 | | `thirdPartyCookiesEnabled` | `false` | Android 可按 WebView 设置;iOS 由 WebKit 系统隐私策略决定;Desktop 必须为 `true` 才能创建控制器;JS/Wasm 由浏览器控制。 | | `fileAccessEnabled` | `false` | 是否允许 `file://` 导航。仅在确有本地内容需求时开启,并自行限制文件来源。 | -| `persistentSessionEnabled` | `true` | 是否持久化 Cookie、缓存等会话。iOS 为 `false` 时使用非持久化存储;Android 与 Desktop 当前不支持隔离临时会话,会拒绝该配置;JS/Wasm 不可控。 | +| `persistentSessionEnabled` | `true` | 会话。iOS 可设 false;Android/Desktop 拒绝;JS/Wasm 不可控。 | | `allowedHosts` | 空集合 | 内嵌页面精确主机名白名单;空集合只由导航策略决定。无通配符,`example.com` 不匹配 `m.example.com`。 | `allowedHosts` 是额外的控制层,不会替代 `NavigationPolicy`。白名单非空时,平台控制器会先执行精确匹配, diff --git a/tools/agent_workflow.py b/tools/agent_workflow.py index 6ff0db9..0955842 100644 --- a/tools/agent_workflow.py +++ b/tools/agent_workflow.py @@ -77,6 +77,7 @@ ) ATTEMPT_FIELDS = ("PLANNING", "IMPLEMENTING", "VALIDATING", "REVIEWING") +STOPPED_STATUSES = frozenset({"PAUSED", "BLOCKED"}) EVENT_FIELDS = ("at", "actor", "from", "to", "evidence", "summary") @@ -398,6 +399,30 @@ def resolve_evidence_path(run_directory: Path, evidence: Path) -> Path: return evidence_path +def is_nonempty_single_line(value: object) -> bool: + """确认停止原因和下一步可直接写入单行账本字段。""" + return isinstance(value, str) and bool(value.strip()) and value.splitlines() == [value] + + +def validate_stop_options( + run_directory: Path, + next_status: str, + stop_reason: str | None, + next_action: str | None, +) -> None: + """限制停止参数只能用于需要停止上下文的状态。""" + if next_status in STOPPED_STATUSES: + if not is_nonempty_single_line(stop_reason) or not is_nonempty_single_line(next_action): + raise ValueError( + f"任务目录 {run_directory} 进入 PAUSED 或 BLOCKED 必须同时提供 " + "--stop-reason 和 --next-action,且均为非空单行。" + ) + elif stop_reason is not None or next_action is not None: + raise ValueError( + f"任务目录 {run_directory} 仅在进入 PAUSED 或 BLOCKED 时接受 --stop-reason 和 --next-action。" + ) + + def transition_run( run_directory: Path, next_status: str, @@ -405,12 +430,15 @@ def transition_run( evidence: Path, candidate_sha: str | None, verdict: str | None, + stop_reason: str | None, + next_action: str | None, ) -> None: """验证状态迁移并原子更新当前账本,再追加审计事件。""" state = validate_run(run_directory) old_status = state["status"] if next_status not in STATUSES: raise ValueError(f"任务目录 {run_directory} 的目标状态 {next_status} 非法;请使用受支持的工作流状态。") + validate_stop_options(run_directory, next_status, stop_reason, next_action) if not is_allowed_transition(state, next_status): raise ValueError( f"任务目录 {run_directory} 不允许从 {old_status} 迁移到 {next_status};请迁移到该状态允许的下一阶段。" @@ -465,8 +493,14 @@ def transition_run( else: state.pop("pausedFromStatus", None) state["currentStage"] = STAGE_FOR_STATUS[next_status] + if next_status in STOPPED_STATUSES: + state["stopReason"] = stop_reason + state["nextAction"] = next_action state["status"] = next_status state["lastEventAt"] = next_event_time(state["lastEventAt"]) + summary = "状态迁移已验证" + if next_status in STOPPED_STATUSES: + summary = f"停止原因:{stop_reason};唯一下一步:{next_action}" append_event( run_directory, { @@ -475,7 +509,7 @@ def transition_run( "from": old_status, "to": next_status, "evidence": evidence_path.relative_to(run_directory).as_posix(), - "summary": "状态迁移已验证", + "summary": summary, }, ) atomic_write_json(run_directory / "state.json", state) @@ -647,6 +681,8 @@ def main() -> int: transition_parser.add_argument("--evidence", type=Path, required=True) transition_parser.add_argument("--candidate-sha") transition_parser.add_argument("--verdict", choices=("PASS", "REJECT")) + transition_parser.add_argument("--stop-reason") + transition_parser.add_argument("--next-action") resume_parser = subparsers.add_parser("resume") resume_parser.add_argument("run_directory", type=Path) arguments = parser.parse_args() @@ -661,6 +697,12 @@ def main() -> int: return 0 if arguments.command == "transition": run_directory = arguments.run_directory.resolve() + validate_stop_options( + run_directory, + arguments.next_status, + arguments.stop_reason, + arguments.next_action, + ) with workflow_lock(run_directory): transition_run( run_directory, @@ -669,6 +711,8 @@ def main() -> int: arguments.evidence, arguments.candidate_sha, arguments.verdict, + arguments.stop_reason, + arguments.next_action, ) print(f"状态迁移成功:{run_directory} -> {arguments.next_status}") return 0 diff --git a/tools/tests/test_agent_workflow.py b/tools/tests/test_agent_workflow.py index ca8e4d6..01ccf6a 100644 --- a/tools/tests/test_agent_workflow.py +++ b/tools/tests/test_agent_workflow.py @@ -45,6 +45,125 @@ def run_cli(self, *arguments: str) -> subprocess.CompletedProcess[str]: check=False, ) + def snapshot_run_directory(self, run_directory: Path) -> dict[str, bytes | None]: + """记录运行目录的文件集合、空目录和文件字节,用于验证失败路径无副作用。""" + return { + path.relative_to(run_directory).as_posix(): path.read_bytes() if path.is_file() else None + for path in sorted(run_directory.rglob("*"), key=lambda item: item.as_posix()) + } + + def test_all_workflow_role_configs_require_the_autonomous_handoff_protocol(self) -> None: + protocol_path = REPOSITORY_ROOT / "docs" / "agent-workflow-autonomous-handoff.md" + protocol_relative_path = protocol_path.relative_to(REPOSITORY_ROOT).as_posix() + role_configuration_paths = ( + REPOSITORY_ROOT / ".codex" / "agents" / "workflow-implementer.toml", + REPOSITORY_ROOT / ".codex" / "agents" / "workflow-integrator.toml", + REPOSITORY_ROOT / ".codex" / "agents" / "workflow-planner.toml", + REPOSITORY_ROOT / ".codex" / "agents" / "workflow-verify-reviewer.toml", + ) + + self.assertTrue(protocol_path.is_file(), f"缺少工作流协议文件:{protocol_relative_path}") + for role_configuration_path in role_configuration_paths: + self.assertIn( + protocol_relative_path, + role_configuration_path.read_text(encoding="utf-8"), + f"{role_configuration_path.relative_to(REPOSITORY_ROOT)} 必须声明工作流协议文件", + ) + + def test_workflow_documents_and_role_configs_forbid_default_handoff_documents(self) -> None: + default_handoff_policy = "默认不得生成或要求 `handoffs/` 阶段交接文档" + workflow_document_paths = ( + REPOSITORY_ROOT / "AGENTS.md", + REPOSITORY_ROOT / "docs" / "agent-workflow.md", + REPOSITORY_ROOT / "docs" / "agent-workflow-autonomous-handoff.md", + REPOSITORY_ROOT / "docs" / "agent-workflow-task-template.md", + REPOSITORY_ROOT / ".codex" / "workflow" / "README.md", + ) + role_configuration_paths = ( + REPOSITORY_ROOT / ".codex" / "agents" / "workflow-implementer.toml", + REPOSITORY_ROOT / ".codex" / "agents" / "workflow-integrator.toml", + REPOSITORY_ROOT / ".codex" / "agents" / "workflow-planner.toml", + REPOSITORY_ROOT / ".codex" / "agents" / "workflow-verify-reviewer.toml", + ) + + for workflow_document_path in workflow_document_paths: + self.assertIn( + default_handoff_policy, + workflow_document_path.read_text(encoding="utf-8"), + f"{workflow_document_path.relative_to(REPOSITORY_ROOT)} 必须禁止默认阶段交接文档", + ) + for role_configuration_path in role_configuration_paths: + self.assertIn( + default_handoff_policy, + role_configuration_path.read_text(encoding="utf-8"), + f"{role_configuration_path.relative_to(REPOSITORY_ROOT)} 必须禁止默认阶段交接文档", + ) + + def test_workflow_documents_and_role_configs_enforce_minimal_role_topology(self) -> None: + default_topology = "默认仅启动 `Planner -> 单一 Implementer -> Integrator -> Verify-Reviewer` 四个角色,并严格顺序执行。" + platform_agent_prohibition = "不得因 Android、iOS、Desktop、JS/Wasm 或测试平台自动创建、拆分或并发专项 Agent。" + second_implementer_approval = ( + "第二个 Implementer 仅可由 Planner 在任务契约中书面批准,且必须同时满足范围不重叠、验证独立、" + "两个范围均不含 `webview-api`、`webview-extension-api`、API 基线、Gradle 设置、发布配置或跨平台契约;" + "同一任务最多两个 Implementer。" + ) + integration_gate = "即使例外获批,也必须全体 Implementer 完成后才进入 Integrator。" + workflow_document_paths = ( + REPOSITORY_ROOT / "AGENTS.md", + REPOSITORY_ROOT / "docs" / "agent-workflow.md", + REPOSITORY_ROOT / "docs" / "agent-workflow-autonomous-handoff.md", + REPOSITORY_ROOT / "docs" / "agent-workflow-task-template.md", + REPOSITORY_ROOT / ".codex" / "workflow" / "README.md", + ) + role_configuration_paths = tuple( + sorted((REPOSITORY_ROOT / ".codex" / "agents").glob("workflow-*.toml")) + ) + + self.assertEqual( + tuple(path.name for path in role_configuration_paths), + ( + "workflow-implementer.toml", + "workflow-integrator.toml", + "workflow-planner.toml", + "workflow-verify-reviewer.toml", + ), + "永久工作流角色配置必须精确为四个最小角色", + ) + for policy in ( + default_topology, + platform_agent_prohibition, + second_implementer_approval, + integration_gate, + ): + for path in (*workflow_document_paths, *role_configuration_paths): + self.assertIn( + policy, + path.read_text(encoding="utf-8"), + f"{path.relative_to(REPOSITORY_ROOT)} 必须声明最小角色编排规则:{policy}", + ) + + task_template = (REPOSITORY_ROOT / "docs" / "agent-workflow-task-template.md").read_text( + encoding="utf-8" + ) + planner_configuration = ( + REPOSITORY_ROOT / ".codex" / "agents" / "workflow-planner.toml" + ).read_text(encoding="utf-8") + self.assertIn("- 角色编排:", task_template) + self.assertIn("- 第二个 Implementer:`不批准` / `批准`;", task_template) + self.assertIn("Planner 必须在任务契约中书面批准第二个 Implementer", planner_configuration) + + def test_workflow_readme_describes_stop_option_validation_before_lock_acquisition(self) -> None: + workflow_readme = REPOSITORY_ROOT / ".codex" / "workflow" / "README.md" + content = workflow_readme.read_text(encoding="utf-8") + + self.assertIn( + "在获取 `.workflow.lock` 前,`transition` 会先校验目标状态与 " + "`--stop-reason`、`--next-action` 的组合。", + content, + ) + self.assertNotIn("三个命令都会先获取", content) + self.assertNotIn("├── handoffs/", content) + def test_validate_accepts_a_planned_run(self) -> None: run_directory = self.copy_fixture("valid") @@ -111,6 +230,155 @@ def test_transition_rejects_an_illegal_status_change(self) -> None: self.assertIn("PLANNED", result.stderr) self.assertIn("允许", result.stderr) + def test_transition_to_stopped_states_requires_both_stop_options_without_mutating_ledger(self) -> None: + option_sets = ( + ("neither", ()), + ("reason-only", ("--stop-reason", "等待维护者授权")), + ("action-only", ("--next-action", "恢复实施阶段")), + ) + for next_status in ("PAUSED", "BLOCKED"): + for label, stop_options in option_sets: + with self.subTest(next_status=next_status, label=label): + run_directory = self.copy_fixture("valid", f"stop-options-{next_status}-{label}") + if next_status == "BLOCKED": + implementing = self.run_cli( + "transition", + str(run_directory), + "IMPLEMENTING", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ) + self.assertEqual(implementing.returncode, 0, implementing.stderr) + (run_directory / ".workflow.lock").unlink() + before_run_directory = self.snapshot_run_directory(run_directory) + self.assertNotIn(".workflow.lock", before_run_directory) + + result = self.run_cli( + "transition", + str(run_directory), + next_status, + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + *stop_options, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("--stop-reason", result.stderr) + self.assertIn("--next-action", result.stderr) + self.assertEqual(self.snapshot_run_directory(run_directory), before_run_directory) + + def test_transition_to_stopped_states_rejects_empty_or_multiline_stop_options(self) -> None: + invalid_stop_options = ( + ("empty-reason", "", "恢复实施阶段"), + ("blank-reason", " ", "恢复实施阶段"), + ("multiline-reason", "等待维护者\n授权", "恢复实施阶段"), + ("empty-action", "等待维护者授权", ""), + ("blank-action", "等待维护者授权", " "), + ("multiline-action", "等待维护者授权", "恢复\n实施阶段"), + ) + for label, stop_reason, next_action in invalid_stop_options: + with self.subTest(label=label): + run_directory = self.copy_fixture("valid", f"invalid-stop-options-{label}") + before_run_directory = self.snapshot_run_directory(run_directory) + self.assertNotIn(".workflow.lock", before_run_directory) + + result = self.run_cli( + "transition", + str(run_directory), + "PAUSED", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + "--stop-reason", + stop_reason, + "--next-action", + next_action, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("非空单行", result.stderr) + self.assertEqual(self.snapshot_run_directory(run_directory), before_run_directory) + + def test_transition_rejects_stop_options_for_non_stopped_states_without_mutating_ledger(self) -> None: + option_sets = ( + ("reason-only", ("--stop-reason", "等待维护者授权")), + ("action-only", ("--next-action", "恢复实施阶段")), + ( + "both", + ("--stop-reason", "等待维护者授权", "--next-action", "恢复实施阶段"), + ), + ) + for label, stop_options in option_sets: + with self.subTest(label=label): + run_directory = self.copy_fixture("valid", f"non-stop-options-{label}") + before_run_directory = self.snapshot_run_directory(run_directory) + self.assertNotIn(".workflow.lock", before_run_directory) + + result = self.run_cli( + "transition", + str(run_directory), + "IMPLEMENTING", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + *stop_options, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("PAUSED 或 BLOCKED", result.stderr) + self.assertEqual(self.snapshot_run_directory(run_directory), before_run_directory) + + def test_transition_to_stopped_states_records_stop_context_in_state_event_and_resume(self) -> None: + for next_status in ("PAUSED", "BLOCKED"): + with self.subTest(next_status=next_status): + run_directory = self.copy_fixture("valid", f"stopped-state-{next_status}") + if next_status == "BLOCKED": + implementing = self.run_cli( + "transition", + str(run_directory), + "IMPLEMENTING", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ) + self.assertEqual(implementing.returncode, 0, implementing.stderr) + stop_reason = f"{next_status} 等待维护者授权" + next_action = f"{next_status} 恢复实施阶段" + + transition = self.run_cli( + "transition", + str(run_directory), + next_status, + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + "--stop-reason", + stop_reason, + "--next-action", + next_action, + ) + + self.assertEqual(transition.returncode, 0, transition.stderr) + state = json.loads((run_directory / "state.json").read_text(encoding="utf-8")) + event = json.loads((run_directory / "events.jsonl").read_text(encoding="utf-8").splitlines()[-1]) + resume = self.run_cli("resume", str(run_directory)) + self.assertEqual(state["status"], next_status) + self.assertEqual(state["stopReason"], stop_reason) + self.assertEqual(state["nextAction"], next_action) + self.assertIn(stop_reason, event["summary"]) + self.assertIn(next_action, event["summary"]) + self.assertEqual(resume.returncode, 0, resume.stderr) + self.assertIn(stop_reason, resume.stdout) + self.assertIn(next_action, resume.stdout) + def test_transition_records_a_legal_status_chain(self) -> None: run_directory = self.copy_fixture("valid") @@ -123,9 +391,6 @@ def test_transition_records_a_legal_status_chain(self) -> None: "--evidence", "task-contract.md", ) - handoff_directory = run_directory / "handoffs" - handoff_directory.mkdir() - (handoff_directory / "implementer.md").write_text("完成交接\n", encoding="utf-8") integrating = self.run_cli( "transition", str(run_directory), @@ -133,7 +398,7 @@ def test_transition_records_a_legal_status_chain(self) -> None: "--actor", "IMPLEMENTER", "--evidence", - "handoffs/implementer.md", + "task-contract.md", ) state = json.loads((run_directory / "state.json").read_text(encoding="utf-8")) @@ -144,6 +409,7 @@ def test_transition_records_a_legal_status_chain(self) -> None: self.assertEqual(state["currentStage"], "INTEGRATING") self.assertEqual(state["attempts"]["IMPLEMENTING"], 1) self.assertEqual(len(events), 2) + self.assertFalse((run_directory / "handoffs").exists()) def test_transition_cli_completes_a_full_passed_chain_without_editing_state(self) -> None: run_directory = self.copy_fixture("valid") @@ -160,9 +426,6 @@ def test_transition_cli_completes_a_full_passed_chain_without_editing_state(self "task-contract.md", ) ] - handoff_directory = run_directory / "handoffs" - handoff_directory.mkdir() - (handoff_directory / "implementer.md").write_text("完成交接\n", encoding="utf-8") results.append( self.run_cli( "transition", @@ -171,7 +434,7 @@ def test_transition_cli_completes_a_full_passed_chain_without_editing_state(self "--actor", "IMPLEMENTER", "--evidence", - "handoffs/implementer.md", + "task-contract.md", ) ) (run_directory / "candidate.sha").write_text(f"{candidate_sha}\n", encoding="utf-8") @@ -236,6 +499,7 @@ def test_transition_cli_completes_a_full_passed_chain_without_editing_state(self self.assertEqual(state["status"], "PR_READY") self.assertEqual(state["candidateSha"], candidate_sha) self.assertEqual(state["verdict"], "PASS") + self.assertFalse((run_directory / "handoffs").exists()) events = [ json.loads(line) for line in (run_directory / "events.jsonl").read_text(encoding="utf-8").splitlines() @@ -258,9 +522,6 @@ def test_transition_to_validating_requires_candidate_sha_option(self) -> None: ).returncode, 0, ) - handoff_directory = run_directory / "handoffs" - handoff_directory.mkdir() - (handoff_directory / "implementer.md").write_text("完成交接\n", encoding="utf-8") self.assertEqual( self.run_cli( "transition", @@ -269,7 +530,7 @@ def test_transition_to_validating_requires_candidate_sha_option(self) -> None: "--actor", "IMPLEMENTER", "--evidence", - "handoffs/implementer.md", + "task-contract.md", ).returncode, 0, ) @@ -826,6 +1087,10 @@ def test_planning_retry_is_limited_when_resuming_a_paused_plan(self) -> None: "SUPERVISOR", "--evidence", "task-contract.md", + "--stop-reason", + "等待维护者授权", + "--next-action", + "恢复 PLANNED 阶段并等待维护者授权", ) first_resume = self.run_cli( "transition", @@ -844,6 +1109,10 @@ def test_planning_retry_is_limited_when_resuming_a_paused_plan(self) -> None: "SUPERVISOR", "--evidence", "task-contract.md", + "--stop-reason", + "等待维护者授权", + "--next-action", + "恢复 PLANNED 阶段并等待维护者授权", ) exhausted_resume = self.run_cli( "transition", @@ -900,7 +1169,7 @@ def test_rework_can_review_a_new_candidate_after_a_reject(self) -> None: "--actor", "IMPLEMENTER", "--evidence", - "handoffs/implementer.md", + "review-report.md", ) validating = self.run_cli( "transition", diff --git a/webview-android/src/androidTest/kotlin/io/github/multiweb/android/AndroidScriptBridgeInstrumentationTest.kt b/webview-android/src/androidTest/kotlin/io/github/multiweb/android/AndroidScriptBridgeInstrumentationTest.kt index 4029e06..f33fe28 100644 --- a/webview-android/src/androidTest/kotlin/io/github/multiweb/android/AndroidScriptBridgeInstrumentationTest.kt +++ b/webview-android/src/androidTest/kotlin/io/github/multiweb/android/AndroidScriptBridgeInstrumentationTest.kt @@ -80,6 +80,63 @@ class AndroidScriptBridgeInstrumentationTest { } } + @Test + fun 精确Https来源门面不会在同源srcdoc子框架创建() { + assumeBridgeFeaturesSupported() + val evaluationCompleted = CountDownLatch(1) + val result = AtomicReference() + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = launchWebViewActivity() + lateinit var webView: WebView + instrumentation.runOnMainSync { + webView = WebView(activity).apply { + settings.javaScriptEnabled = true + AndroidScriptBridgeInstaller.install( + webView = this, + javaScriptEnabled = true, + bridges = listOf( + object : ScriptBridgeWithFacade { + override val name: String = "TrustedBridge" + override val transportName: String = "__multiweb_trusted_bridge" + override val allowedHosts: Set = setOf("trusted.example") + override val facade: ScriptBridgeFacade = ScriptBridgeFacade(setOf("ping")) + + override fun handle(call: ScriptBridgeCall): ScriptBridgeResponse { + return ScriptBridgeResponse(isSuccess = true) + } + }, + ), + ) + webChromeClient = object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + val prefix = "multiweb-trusted-child-facade:" + if (consoleMessage.message().startsWith(prefix)) { + result.set(consoleMessage.message().removePrefix(prefix)) + evaluationCompleted.countDown() + } + return super.onConsoleMessage(consoleMessage) + } + } + activity.setContentView(this) + loadDataWithBaseURL( + "https://trusted.example/", + "", + "text/html", + "utf-8", + null, + ) + } + } + + try { + assertTrue("未完成同源子框架门面检查", evaluationCompleted.await(10, TimeUnit.SECONDS)) + assertEquals("true", result.get()) + } finally { + destroyWebView(activity, webView) + } + } + @Test fun 不安全来源策略允许顶层Https页面调用受限桥() { assertUnsafeBridgeCanBeCalled("https://legacy.example/") diff --git a/webview-android/src/main/kotlin/io/github/multiweb/android/AndroidScriptBridgeInstaller.kt b/webview-android/src/main/kotlin/io/github/multiweb/android/AndroidScriptBridgeInstaller.kt index f466056..f94287b 100644 --- a/webview-android/src/main/kotlin/io/github/multiweb/android/AndroidScriptBridgeInstaller.kt +++ b/webview-android/src/main/kotlin/io/github/multiweb/android/AndroidScriptBridgeInstaller.kt @@ -21,8 +21,8 @@ import java.net.URI * * 不使用 `addJavascriptInterface`:后者会把整个对象暴露给所有已加载页面。精确 HTTPS 策略直接使用对应来源规则; * 不安全兼容策略必须使用 AndroidX WebKit 的全来源 `*` 规则安装内部消息通道,因而该内部对象会进入全部框架。 - * 此时网页门面仅在顶层 HTTP/HTTPS 页面创建,原生消息入口也会再次拒绝直接来自子框架、`file:`、`data:` 与自定义 - * Scheme 的请求。 + * 两种策略的网页门面均仅在顶层窗口创建,原生消息入口也会再次拒绝直接来自子框架、 + * `file:`、`data:` 与自定义 Scheme 的请求。 */ internal object AndroidScriptBridgeInstaller { fun install( @@ -79,11 +79,11 @@ internal data class AndroidScriptBridgeConfiguration( /** AndroidX WebKit 安装内部消息通道与门面脚本时使用的来源规则。 */ val allowedOriginRules: Set, ) { - /** 生成受限 Promise 门面;不安全模式下门面仅在顶层 HTTP/HTTPS 页面创建。 */ + /** 生成受限 Promise 门面;所有策略的门面均仅在顶层窗口创建。 */ fun facadeInjectionScript(): String? { val bridgeFacade = facade ?: return null val originCheck = when (originPolicy) { - is ScriptBridgeOriginPolicy.ExactHttpsHosts -> "true" + is ScriptBridgeOriginPolicy.ExactHttpsHosts -> "window.top === window" ScriptBridgeOriginPolicy.UnsafeAnyHttpOrHttps -> { "window.top === window && (window.location.protocol === 'http:' || " + "window.location.protocol === 'https:') && window.location.hostname.length > 0" diff --git a/webview-android/src/test/kotlin/io/github/multiweb/android/AndroidScriptBridgeConfigurationTest.kt b/webview-android/src/test/kotlin/io/github/multiweb/android/AndroidScriptBridgeConfigurationTest.kt index af26ec8..0d29a64 100644 --- a/webview-android/src/test/kotlin/io/github/multiweb/android/AndroidScriptBridgeConfigurationTest.kt +++ b/webview-android/src/test/kotlin/io/github/multiweb/android/AndroidScriptBridgeConfigurationTest.kt @@ -30,6 +30,24 @@ class AndroidScriptBridgeConfigurationTest { ) } + @Test + fun `精确 HTTPS 门面脚本只在顶层窗口创建`() { + val configuration = AndroidScriptBridgeConfiguration.create( + listOf( + object : ScriptBridgeWithFacade { + override val name = "TrustedBridge" + override val transportName = "__trusted_bridge_transport" + override val allowedHosts = setOf("trusted.example") + override val facade = ScriptBridgeFacade(setOf("ping")) + + override fun handle(call: ScriptBridgeCall): ScriptBridgeResponse? = null + }, + ), + ).single() + + assertContains(requireNotNull(configuration.facadeInjectionScript()), "window.top === window") + } + @Test fun `脚本执行仅接受可信 HTTPS 默认端口主文档`() { val allowedHosts = setOf("EXAMPLE.com") diff --git a/webview-api/src/commonMain/kotlin/io/github/multiweb/api/WebViewPolicy.kt b/webview-api/src/commonMain/kotlin/io/github/multiweb/api/WebViewPolicy.kt index 45fe2ed..37a5fe7 100644 --- a/webview-api/src/commonMain/kotlin/io/github/multiweb/api/WebViewPolicy.kt +++ b/webview-api/src/commonMain/kotlin/io/github/multiweb/api/WebViewPolicy.kt @@ -3,7 +3,10 @@ package io.github.multiweb.api /** * WebView 的安全配置。 * - * 所有高风险能力均采用最小权限默认值;平台不支持的选项必须由对应实现明确说明。 + * 除会话持久化外,所有高风险能力均采用最小权限默认值。 + * + * 会话持久化的兼容默认值为 `true`,因为 Android 和 Desktop 无法按控制器隔离临时会话。 + * iOS 可显式设为 `false` 使用临时会话;平台不支持的选项必须由对应实现明确说明。 */ data class WebViewConfig( /** 是否允许页面执行 JavaScript,默认关闭。 */ @@ -12,7 +15,12 @@ data class WebViewConfig( val thirdPartyCookiesEnabled: Boolean = false, /** 是否允许页面访问本地文件,默认关闭。 */ val fileAccessEnabled: Boolean = false, - /** 是否持久化 Cookie、缓存等会话数据;关闭时使用临时会话。 */ + /** + * 是否持久化 Cookie、缓存等会话数据。 + * + * 默认 true 兼容 Android 和 Desktop 无法按控制器隔离临时会话。 + * iOS 可显式设为 false 使用临时会话。 + */ val persistentSessionEnabled: Boolean = true, /** 允许在内嵌浏览器中访问的主机名集合;空集合表示由导航策略决定。 */ val allowedHosts: Set = emptySet(), diff --git a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopScriptBridgeInstaller.kt b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopScriptBridgeInstaller.kt index 99eaf11..08b809f 100644 --- a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopScriptBridgeInstaller.kt +++ b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopScriptBridgeInstaller.kt @@ -225,6 +225,9 @@ internal data class DesktopScriptBridgeConfiguration( * 安全边界不一致。 */ internal fun isTrustedJavaScriptUrl(url: String?, allowedHosts: Set): Boolean { + if (allowedHosts.isEmpty()) { + return false + } return isTrustedJavaScriptUrl(url, ScriptBridgeOriginPolicy.ExactHttpsHosts(allowedHosts)) } diff --git a/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopScriptBridgeConfigurationTest.kt b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopScriptBridgeConfigurationTest.kt index d41beeb..b579a2d 100644 --- a/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopScriptBridgeConfigurationTest.kt +++ b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopScriptBridgeConfigurationTest.kt @@ -53,7 +53,11 @@ class DesktopScriptBridgeConfigurationTest { assertEquals(true, isTrustedJavaScriptUrl("https://example.com:443/page", allowedHosts)) assertEquals(false, isTrustedJavaScriptUrl("https://example.com:8443/page", allowedHosts)) assertEquals(false, isTrustedJavaScriptUrl("http://example.com/page", allowedHosts)) - assertEquals(false, isTrustedJavaScriptUrl("https://example.com/page", emptySet())) + } + + @Test + fun `空允许主机集合拒绝可信 HTTPS 页面`() { + assertFalse(isTrustedJavaScriptUrl("https://example.com/page", emptySet())) } @Test diff --git a/webview-extension-api/src/commonMain/kotlin/io/github/multiweb/extension/NativeWebViewBridgeExtension.kt b/webview-extension-api/src/commonMain/kotlin/io/github/multiweb/extension/NativeWebViewBridgeExtension.kt index 0f31d10..04f7a38 100644 --- a/webview-extension-api/src/commonMain/kotlin/io/github/multiweb/extension/NativeWebViewBridgeExtension.kt +++ b/webview-extension-api/src/commonMain/kotlin/io/github/multiweb/extension/NativeWebViewBridgeExtension.kt @@ -119,6 +119,10 @@ class NativeWebViewBridgeExtension private constructor( /** * 创建仅允许精确 HTTPS 主机使用的旧网页桥。 * + * [allowedHosts] 必须为非空的精确 ASCII 主机名集合。 + * [ScriptBridgeOriginPolicy.ExactHttpsHosts] 会在构造时拒绝端口、凭据、通配符、Unicode 与其他 URL + * 片段。 + * * 此构造器保留既有公开 API 与默认安全语义。 * [ScriptBridgeOriginPolicy.UnsafeAnyHttpOrHttps] 必须通过另一个显式命名的构造器选择。 */ @@ -132,6 +136,9 @@ class NativeWebViewBridgeExtension private constructor( /** * 创建启用受控旧脚本执行能力的桥。 * + * [allowedHosts] 沿用精确 HTTPS 主机约束。非法集合会在构造时被拒绝, + * 不能借由旧脚本执行入口放宽来源范围。 + * * 关闭时 [NativeWebViewBridgeRequest.SetPageLoadScript] 与 [NativeWebViewBridgeRequest.ExecuteJavaScript] 继续交由 * [host] 处理,保持既有行为。开启后这两个请求不再进入 [host]:最新页面脚本会在受信任主文档完成加载后执行, * 即时脚本会在收到请求时执行。控制器未提供或已释放时返回 `javascript_executor_unavailable`;当前主文档来源、 diff --git a/webview-extension-api/src/commonMain/kotlin/io/github/multiweb/extension/WebViewExtension.kt b/webview-extension-api/src/commonMain/kotlin/io/github/multiweb/extension/WebViewExtension.kt index 09bf6a1..3469b57 100644 --- a/webview-extension-api/src/commonMain/kotlin/io/github/multiweb/extension/WebViewExtension.kt +++ b/webview-extension-api/src/commonMain/kotlin/io/github/multiweb/extension/WebViewExtension.kt @@ -136,10 +136,40 @@ interface ScriptBridge { */ sealed interface ScriptBridgeOriginPolicy { /** 仅允许指定精确主机的 HTTPS 默认端口 443 页面使用桥。 */ - data class ExactHttpsHosts( - /** 允许使用桥的精确 HTTPS 主机名集合;不能为空且不支持通配符。 */ - val hosts: Set, - ) : ScriptBridgeOriginPolicy + class ExactHttpsHosts : ScriptBridgeOriginPolicy { + /** + * 允许使用桥的精确 HTTPS 主机名集合。 + * + * 集合不能为空;每项必须是无端口、凭据、路径或通配符的精确 ASCII 主机名。 + * + * 构造时会拒绝 Unicode 和其他 URL 片段,避免平台对同一来源产生不同解释。 + * 构造器会创建只读快照,避免调用方后续修改传入的可变集合后绕过来源校验。 + */ + val hosts: Set + + constructor(hosts: Set) { + val hostSnapshot = buildSet { addAll(hosts) } + require(hostSnapshot.isNotEmpty()) { + "JS 桥必须声明至少一个受信任主机。" + } + hostSnapshot.forEach { host -> + require(isExactAsciiHost(host)) { + "JS 桥只允许精确 ASCII 主机名且不支持通配符:$host" + } + } + this.hosts = hostSnapshot + } + + operator fun component1(): Set = hosts + + fun copy(hosts: Set = this.hosts): ExactHttpsHosts = ExactHttpsHosts(hosts) + + override fun equals(other: Any?): Boolean = other is ExactHttpsHosts && hosts == other.hosts + + override fun hashCode(): Int = hosts.hashCode() + + override fun toString(): String = "ExactHttpsHosts(hosts=$hosts)" + } /** * 不安全的旧页面兼容模式:仅允许主框架的 HTTP/HTTPS 页面使用桥。 @@ -150,6 +180,13 @@ sealed interface ScriptBridgeOriginPolicy { data object UnsafeAnyHttpOrHttps : ScriptBridgeOriginPolicy } +private val exactAsciiHostPattern = Regex( + "(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)" + + "(?:\\.(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*", +) + +private fun isExactAsciiHost(host: String): Boolean = exactAsciiHostPattern.matches(host) + /** * 可声明自定义来源策略的 JS 桥。 * diff --git a/webview-extension-api/src/commonTest/kotlin/io/github/multiweb/extension/NativeWebViewBridgeExtensionTest.kt b/webview-extension-api/src/commonTest/kotlin/io/github/multiweb/extension/NativeWebViewBridgeExtensionTest.kt index 61b09db..144d422 100644 --- a/webview-extension-api/src/commonTest/kotlin/io/github/multiweb/extension/NativeWebViewBridgeExtensionTest.kt +++ b/webview-extension-api/src/commonTest/kotlin/io/github/multiweb/extension/NativeWebViewBridgeExtensionTest.kt @@ -170,6 +170,33 @@ class NativeWebViewBridgeExtensionTest { assertTrue(exception.message.orEmpty().contains("allowedHosts")) } + @Test + fun `旧网页桥构造器不能绕过精确主机校验`() { + val invalidHostSets = listOf( + emptySet(), + setOf("*.example.com"), + setOf("example.com:8443"), + setOf("user:pass@example.com"), + setOf("例子.com"), + ) + + invalidHostSets.forEach { hosts -> + assertFailsWith { + NativeWebViewBridgeExtension( + allowedHosts = hosts, + host = NativeWebViewBridgeHost { NativeWebViewBridgeResult.Success() }, + ) + } + assertFailsWith { + NativeWebViewBridgeExtension( + allowedHosts = hosts, + host = NativeWebViewBridgeHost { NativeWebViewBridgeResult.Success() }, + enableLegacyJavaScriptExecution = true, + ) + } + } + } + @Test fun `不安全兼容模式不会降级为旧脚本执行器`() { val extension = NativeWebViewBridgeExtension( diff --git a/webview-extension-api/src/commonTest/kotlin/io/github/multiweb/extension/WebViewExtensionTest.kt b/webview-extension-api/src/commonTest/kotlin/io/github/multiweb/extension/WebViewExtensionTest.kt index 91ebf35..9f66e72 100644 --- a/webview-extension-api/src/commonTest/kotlin/io/github/multiweb/extension/WebViewExtensionTest.kt +++ b/webview-extension-api/src/commonTest/kotlin/io/github/multiweb/extension/WebViewExtensionTest.kt @@ -7,6 +7,7 @@ import io.github.multiweb.api.WebViewController import io.github.multiweb.api.WebViewState import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertSame import kotlin.test.assertTrue @@ -49,6 +50,58 @@ class WebViewExtensionTest { assertEquals("", bridge.handle(ScriptBridgeCall("other"))?.payload) } + @Test + fun `精确 HTTPS 主机策略拒绝空集合和非精确 ASCII 主机`() { + val invalidHostSets = listOf( + emptySet(), + setOf("*.example.com"), + setOf("example.com:8443"), + setOf("user:pass@example.com"), + setOf("例子.com"), + setOf("münich.example"), + setOf("https://example.com"), + setOf("example.com/path"), + ) + + invalidHostSets.forEach { hosts -> + assertFailsWith { + ScriptBridgeOriginPolicy.ExactHttpsHosts(hosts) + } + } + } + + @Test + fun `精确 HTTPS 主机策略接受合法大小写 ASCII 主机`() { + val hosts = setOf("EXAMPLE.com", "api.example.com") + + val policy = ScriptBridgeOriginPolicy.ExactHttpsHosts(hosts) + + assertEquals(hosts, policy.hosts) + } + + @Test + fun `精确 HTTPS 主机策略防御性复制传入的可变集合`() { + val hosts = mutableSetOf("trusted.example") + + val policy = ScriptBridgeOriginPolicy.ExactHttpsHosts(hosts) + hosts += "evil.example" + + assertEquals(setOf("trusted.example"), policy.hosts) + } + + @Test + fun `精确 HTTPS 主机策略的 copy 防御性复制传入的可变集合`() { + val policy = ScriptBridgeOriginPolicy.ExactHttpsHosts(setOf("trusted.example")) + val copiedHosts = mutableSetOf("trusted.example") + + val copiedPolicy = policy.copy(hosts = copiedHosts) + copiedHosts += "evil.example" + + assertEquals(setOf("trusted.example"), copiedPolicy.hosts) + assertEquals(copiedPolicy.hosts, copiedPolicy.component1()) + assertEquals(copiedPolicy, copiedPolicy.copy()) + } + @Test fun 控制器生命周期扩展仅在显式实现时接收控制器引用() { var attachedController: WebViewController? = null diff --git a/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosScriptBridgeInstaller.kt b/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosScriptBridgeInstaller.kt index c86fd68..ce14e83 100644 --- a/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosScriptBridgeInstaller.kt +++ b/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosScriptBridgeInstaller.kt @@ -296,6 +296,9 @@ internal data class IosScriptBridgeConfiguration( */ @OptIn(ExperimentalForeignApi::class) internal fun isTrustedJavaScriptUrl(url: String?, allowedHosts: Set): Boolean { + if (allowedHosts.isEmpty()) { + return false + } return isTrustedJavaScriptUrl(url, ScriptBridgeOriginPolicy.ExactHttpsHosts(allowedHosts)) } diff --git a/webview-ios/src/iosTest/kotlin/io/github/multiweb/ios/IosScriptBridgeConfigurationTest.kt b/webview-ios/src/iosTest/kotlin/io/github/multiweb/ios/IosScriptBridgeConfigurationTest.kt index 98007f8..2fd9676 100644 --- a/webview-ios/src/iosTest/kotlin/io/github/multiweb/ios/IosScriptBridgeConfigurationTest.kt +++ b/webview-ios/src/iosTest/kotlin/io/github/multiweb/ios/IosScriptBridgeConfigurationTest.kt @@ -39,6 +39,10 @@ class IosScriptBridgeConfigurationTest { assertTrue(isTrustedJavaScriptUrl("https://example.com:443/page", allowedHosts)) assertFalse(isTrustedJavaScriptUrl("https://example.com:8443/page", allowedHosts)) assertFalse(isTrustedJavaScriptUrl("http://example.com/page", allowedHosts)) + } + + @Test + fun `空的 HTTPS 主机集合拒绝可信 URL`() { assertFalse(isTrustedJavaScriptUrl("https://example.com/page", emptySet())) } diff --git a/webview-test-fixtures/src/commonTest/kotlin/io/github/multiweb/testing/FakeWebViewControllerTest.kt b/webview-test-fixtures/src/commonTest/kotlin/io/github/multiweb/testing/FakeWebViewControllerTest.kt index 685cde9..410ee3c 100644 --- a/webview-test-fixtures/src/commonTest/kotlin/io/github/multiweb/testing/FakeWebViewControllerTest.kt +++ b/webview-test-fixtures/src/commonTest/kotlin/io/github/multiweb/testing/FakeWebViewControllerTest.kt @@ -5,14 +5,7 @@ import io.github.multiweb.api.NavigationPolicy import io.github.multiweb.api.WebError import io.github.multiweb.api.WebErrorCategory import io.github.multiweb.api.WebRequest -import io.github.multiweb.api.WebViewState import io.github.multiweb.api.WebViewStateObservable -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -23,20 +16,17 @@ import kotlin.test.assertFailsWith class FakeWebViewControllerTest { @Test - fun `状态流会在页面状态改变时发出最新快照`() { + fun `状态流在页面状态改变时提供最新快照`() { val controller = FakeWebViewController() assertIs(controller) - val receivedStates = mutableListOf() - val collection = CoroutineScope(Dispatchers.Unconfined).launch { - controller.stateFlow.take(2).toList(receivedStates) - } + val initialUrl = controller.stateFlow.value.url controller.load(WebRequest("https://example.com/home")) - runBlocking { collection.join() } - assertEquals("https://example.com/home", controller.stateFlow.value.url) + val latestUrl = controller.stateFlow.value.url + + assertEquals(listOf(null, "https://example.com/home"), listOf(initialUrl, latestUrl)) assertEquals(controller.state, controller.stateFlow.value) - assertEquals(listOf(null, "https://example.com/home"), receivedStates.map { state -> state.url }) } @Test