agent-exec transport 8c36954f5b4fe6129e117666f9114c7b/7cc66ad08d16

This commit is contained in:
agent-exec-transport
2026-10-17 06:26:30 +00:00
parent 6767810a44
commit 63e11ea312
15 changed files with 7 additions and 10259 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ policy/delivery.md。
都没有活动的空闲超时,归入 `failed-unknown`。
- 一次尝试只在显式终态失败、进程退出、总超时或上述空闲超时时结束。CLI 可在目标、范围与安全边界内
自主检查、计划、测试并迭代。
- 实施变更只经成功 transport 由 `scripts/agent_exec.py apply` 集成,或由 pre-commit `admit` 按精确内容
- 实施变更只经成功 transport 由 `scripts/agent-exec apply` 集成,或由 pre-commit `admit` 按精确内容
做本地完整性核对。本地 HMAC 历史与该钩子只是本地完整性/篡改证据(SELF),不是 GATE、可信身份,
也不防同一主机 OS 用户;本地记录缺失或无效仍拒绝,交付信任只来自精确 HEAD 的 CI 或独立 REPLAY。
host 直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**` 与
+1 -1
View File
@@ -20,7 +20,7 @@
| standards/multi-repo.md | 跨仓/gitlink | manual;受控历史迁移通道已于 2026-09-30 行使一次(documents/records/2026-09-30-history-migration.md) |
| 工作区回收 | 交付终点 | advisory(task-finish);负例:合入前 finish 拒绝输出 |
| policy/delivery.md(项目收紧) | 需求、长期方向、试验、实施迭代及交付 | goal-first:目标/范围/终点一次确认,范围内连续推进验证、Review、合入和安全收口;实施写入按 agent-per-unit(配置 AGENT-EXECUTION.md;实施/修复/测试正常候选仅为 Cursor、agy、PI,每候选对安全可恢复失败最多 3 次全新尝试,全部安全耗尽后一次 Codex CLI 紧急尝试;Codex 平时只用于规划、设计、计划或独立 Review;显式 fail-stop 立即停止;不限制推理时长、工具次数、规划方式或首次写文件时间;集成须成功 transport 的 agent-exec apply,或 pre-commit admit 精确内容来源;host 豁免仅为 .task/** 与 docs/superpowers/specs/**、plans/**;host 不直接实施;fallback_to_host 为 never;安全耗尽不发出 host-fallback-authorized,终态为 unavailable 或 blocked);仅真实决策点需人工;任务卡提供记录,post-merge 默认只提示,行为回归见 tests/test_task.py |
| AGENT-EXECUTION.md + agent-exec | 项目执行路由 | 本地 SELF:parser/adapter/controller 假 CLI 契约由 tests/test_agent_exec*.py 与 make agent-exec-validate 强制;已安装 provider/model 真实身份待 U5 探针,此前不接受为已就位;远端平台准入仍为 GATE |
| AGENT-EXECUTION.md + agent-exec | 项目执行路由 | 本地 SELF:parser/adapter/controller 假 CLI 契约由共享执行器自身测试强制(Development-Manager internal/agentexec,经 deps/development-manager 钉住),并由 CI 中的 `scripts/agent-exec validate --repo .` 覆盖;本地薄入口拒绝路径由 tests/test_agent_exec_entry.py 强制;已安装 provider/model 真实身份待 U5 探针,此前不接受为已就位;远端平台准入仍为 GATE |
| standards/tool-adaptation.md(draft.8) | 为工具创建规则/钩子实例 | 本组合 CLI/git 级实例已登记:policy/tool-adaptation-matrix.md(A1–A7 逐行定级+正负例证据) |
| 同上(zcode 实例) | zcode 客户端写路径 | policy/tool-adaptation-zcode.md:PreToolUse 写守卫;脚本负例与客户端接线分开,客户端 pending-live |
| core/baseline.md「规范层级」+ documentation.md「记忆分层/机制化」(draft.9) | 层级效力、记忆权威、经验机制化 | manual:本仓 policy/ 仅收紧不放宽;记忆四层按条文执行(任务卡=任务记忆,documents/records=项目记忆,Dev 仓 AGENTS.md 指针已就位) |
+1 -1
View File
@@ -109,7 +109,7 @@ agy `stream-json` 的 `result` 事件或 `json` 输出对象、PI `json` 模式
规范 Git、推送、PR、合入、部署或清理权威。
实施结果进入当前分支,必须来自一次成功 transport,由 host 执行
`scripts/agent_exec.py apply` 在干净分支上快进;或者本次暂存内容通过 pre-commit 调用的
`scripts/agent-exec apply` 在干净分支上快进;或者本次暂存内容通过 pre-commit 调用的
`admit`:非豁免路径与该成功 transport 的路径和精确内容一致,且 transport 的父提交是当前 HEAD。
apply/admit 依赖的本地 HMAC 执行历史与 checkout 钩子只是本地完整性/篡改证据(SELF):同一主机
OS 用户可读取密钥或绕过钩子,因此它不是 GATE、可信身份或防恶意主体的保护。本地记录缺失或无效
+1 -1
View File
@@ -24,7 +24,7 @@
都不是结束条件。CLI 可在目标、范围与安全边界内自主检查、计划、测试并迭代。路由细则见
[迭代交付](delivery.md)。
实施变更进入仓库,只通过成功 transport 的 `scripts/agent_exec.py apply`,或 pre-commit `admit`
实施变更进入仓库,只通过成功 transport 的 `scripts/agent-exec apply`,或 pre-commit `admit`
对暂存内容的本地完整性核对。本地 HMAC 执行历史与该钩子只记作本地完整性/篡改证据(SELF),
不是 GATE、可信身份,也不防同一主机 OS 用户;本地记录缺失或无效仍拒绝,交付信任来自精确 HEAD 的
CI 或独立 REPLAY。host 可以直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**`
+1 -1
View File
@@ -14,7 +14,7 @@
| A5 回收 | 本地命令检查;写者退出须可证明,无法证明时才请求 owner 决策 | 登记根目录、锁/操作状态、父子仓与唯一文件、合入核验 | `tests/test_task.py` |
| A6 秘密 | 本地脱敏及 CI 获取边界;权限与宿主隔离另验 | 命令/输出/摘要脱敏,凭据不持久写 Git 配置 | `tests/test_evidence.py`、`tests/test_ci_credentials.py` |
| A7 证据 | 本地 SELF 收据;外部来源独立核验 | 首次捕获、完整脱敏输出/摘要、对象记录、原样导入 | `tests/test_evidence.py`;CI/审阅链接另核 |
| A8 阶段记录与迭代 | goal-first + agent-per-unit(AGENT-EXECUTION.md);模板默认连续执行,仅真实决策点需人工,没有全流程授权强制;实施/修复/测试仅 Cursor、agy、PI,每候选对安全可恢复失败最多 3 次全新尝试,全部安全耗尽后一次 Codex CLI 紧急尝试;Codex 平时仅规划、设计、计划或独立 Review;显式 fail-stop 立即停止;不限制推理时长、工具次数、规划方式或首次写文件时间;集成经成功 transport 的 agent-exec apply,或 pre-commit admit 精确内容来源;host 豁免仅为 .task/** 与 docs/superpowers/specs/**、plans/**;host 不直接实施;fallback_to_host 为 never;安全耗尽不发出 host-fallback-authorized,终态为 unavailable 或 blocked | policy/delivery.md、AGENT-EXECUTION.md;任务卡分别记录阶段状态、决策点、Agent 实施收据(配置摘要/角色/候选/attempt/CLI-模型/终态/transport/紧急尝试/源码外路径)、Review、冲突、当前 HEAD 的 CI 和合入;post-merge 默认仅提示 | `tests/test_task.py` 核对 goal-first/agent-per-unit 模板、正常路由、尝试上限、fail-stop、空闲超时与 apply/admit 来源表述,及钩子不自动写入;`python3 scripts/agent_exec.py validate` / parser·adapter·controller 假测为本地 SELF 强制行为;已安装 provider/model 身份待 U5,远端平台准入为 GATE;智能体核对授权范围、实施单元边界、Review、冲突及匹配 HEAD 的 CI/平台准入 |
| A8 阶段记录与迭代 | goal-first + agent-per-unit(AGENT-EXECUTION.md);模板默认连续执行,仅真实决策点需人工,没有全流程授权强制;实施/修复/测试仅 Cursor、agy、PI,每候选对安全可恢复失败最多 3 次全新尝试,全部安全耗尽后一次 Codex CLI 紧急尝试;Codex 平时仅规划、设计、计划或独立 Review;显式 fail-stop 立即停止;不限制推理时长、工具次数、规划方式或首次写文件时间;集成经成功 transport 的 agent-exec apply,或 pre-commit admit 精确内容来源;host 豁免仅为 .task/** 与 docs/superpowers/specs/**、plans/**;host 不直接实施;fallback_to_host 为 never;安全耗尽不发出 host-fallback-authorized,终态为 unavailable 或 blocked | policy/delivery.md、AGENT-EXECUTION.md;任务卡分别记录阶段状态、决策点、Agent 实施收据(配置摘要/角色/候选/attempt/CLI-模型/终态/transport/紧急尝试/源码外路径)、Review、冲突、当前 HEAD 的 CI 和合入;post-merge 默认仅提示 | `tests/test_task.py` 核对 goal-first/agent-per-unit 模板、正常路由、尝试上限、fail-stop、空闲超时与 apply/admit 来源表述,及钩子不自动写入;`scripts/agent-exec validate`(CI)与 parser·adapter·controller 假 CLI 契约(Development-Manager internal/agentexec 测试,经 deps/development-manager 钉住)为本地 SELF 强制行为;已安装 provider/model 身份待 U5,远端平台准入为 GATE;智能体核对授权范围、实施单元边界、Review、冲突及匹配 HEAD 的 CI/平台准入 |
| A9 长期方向与后续任务 | manual;模板不校验规划有效性或自动调度任务 | policy/delivery.md;PROJECT.md 提供规划入口,任务卡关联规划/里程碑并记录下一步建议 | `tests/test_task.py` 核对生成字段与未授权默认值;智能体核对当前规划、进展、重复任务和建议依据,新任务仍不自动获得授权 |
| A10 交付窗口 | manual;无自动互斥或接管 | [合入调度](merge-queue.md)与 #518:同一目标一个最终窗口,等待分支不追 main;轮到后同步、固定、验证并由智能体合入 | 每个实际 PR 核对窗口记录、精确 HEAD 三项 GATE、Review 和合入后 main;不要求额外试行或全写者审计 |
| A11 固定后证据 | manual 选择源码外位置;工具捕获绝对未用路径/原始输出;无 OS 冻结 | policy/governance.md;RECEIPT 捕获,后续授权闭合再原样导入 | `tests/test_evidence.py` 固定对象、权限、覆盖拒绝、原样导入与幂等 |
File diff suppressed because it is too large Load Diff
-702
View File
@@ -1,702 +0,0 @@
"""Built-in and controlled-custom CLI adapter registry for agent-exec.
Builds direct argv only (never shell). Probes local executable/version/help;
does not contact providers or launch real agent runs.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from scripts.agent_exec_config import (
ALLOWED_PLACEHOLDERS,
SECRET_VALUE_RE,
ExecutionConfig,
Profile,
)
_PROBE_TIMEOUT_SECONDS = 5
# Conservative bound for embedding unit packets into prompts (policy-compatible).
MAX_UNIT_PACKET_BYTES = 262_144
UNIT_PACKET_DISPLAY_MARKER = "<unit-packet>"
TERMINAL_STATUSES = (
"completed",
"failed",
"blocked",
"cancelled",
"failed-unknown",
"scope-expansion",
)
TERMINAL_RECORD_SCHEMA: dict[str, Any] = {
"type": "object",
"required": [
"terminal_status",
"summary",
"changed_files",
"evidence",
"scope_expansion",
],
"properties": {
"terminal_status": {"enum": list(TERMINAL_STATUSES)},
"summary": {"type": "string"},
"changed_files": {"type": "array", "items": {"type": "string"}},
"evidence": {"type": "array", "items": {"type": "string"}},
"scope_expansion": {"type": ["string", "null"]},
},
}
# Bounds the backwards brace scan over one final message.
_MAX_TERMINAL_TEXT_CHARS = 262_144
_PLACEHOLDER_MAP = {
"${workspace}": "workspace",
"${unit_packet}": "unit_packet",
"${result_path}": "result_path",
"${model}": "model",
}
_CUSTOM_OUTPUT_ARGV: dict[str, tuple[str, ...]] = {
"json": ("--output-format", "json"),
"stream-json": ("--output-format", "stream-json"),
"jsonl": ("--output-format", "jsonl"),
"text": (),
}
# Substrings that must appear in --help before a built-in is available.
_REQUIRED_HELP_MARKERS: dict[str, tuple[str, ...]] = {
"cursor": (
"--print",
"--force",
"--sandbox",
"--trust",
"--workspace",
"--model",
"--output-format",
),
"antigravity": (
"--print",
"--dangerously-skip-permissions",
"--mode",
"--model",
"--output-format",
),
"claude": (
"--print",
"--permission-mode",
"--model",
"--output-format",
),
"codex": (
"exec",
"--sandbox",
"workspace-write",
"--config",
"--model",
"--cd",
"--json",
"--output-last-message",
),
"pi": ("--print", "--approve", "--model", "--mode"),
"kimi": ("--prompt", "--auto", "--model", "--output-format"),
"opencode": (
"--print",
"--yes",
"--workspace",
"--output-format",
),
}
@dataclass(frozen=True)
class AdapterSpec:
name: str
noninteractive_argv: tuple[str, ...]
run_everything_argv: tuple[str, ...]
workspace_flag: str | None
model_flag: str | None
output_argv: tuple[str, ...]
prompt_transport: str
@dataclass(frozen=True)
class AdapterProbe:
available: bool
reason: str | None
executable: str
observed_version: str | None
@dataclass(frozen=True)
class Invocation:
argv: tuple[str, ...]
cwd: Path
stdin: bytes | None
sanitized_display: tuple[str, ...]
BUILTIN_SPECS: dict[str, AdapterSpec] = {
"cursor": AdapterSpec(
name="cursor",
noninteractive_argv=("-p",),
run_everything_argv=("--force", "--sandbox", "enabled", "--trust"),
workspace_flag="--workspace",
model_flag="--model",
output_argv=("--output-format", "stream-json"),
prompt_transport="positional",
),
"antigravity": AdapterSpec(
name="antigravity",
noninteractive_argv=(),
run_everything_argv=(
"--dangerously-skip-permissions",
"--mode",
"auto",
),
workspace_flag=None,
model_flag="--model",
output_argv=("--output-format", "stream-json"),
prompt_transport="print",
),
"claude": AdapterSpec(
name="claude",
noninteractive_argv=("-p",),
run_everything_argv=("--permission-mode", "bypassPermissions"),
workspace_flag=None,
model_flag="--model",
output_argv=("--output-format", "stream-json"),
prompt_transport="positional",
),
"codex": AdapterSpec(
name="codex",
noninteractive_argv=("exec",),
run_everything_argv=(
"--sandbox",
"workspace-write",
"-c",
"approval_policy=never",
),
workspace_flag="--cd",
model_flag="--model",
output_argv=("--json", "--output-last-message"),
prompt_transport="positional",
),
"pi": AdapterSpec(
name="pi",
noninteractive_argv=("-p",),
run_everything_argv=("--approve",),
workspace_flag=None,
model_flag="--model",
output_argv=("--mode", "json"),
prompt_transport="positional",
),
"kimi": AdapterSpec(
name="kimi",
noninteractive_argv=(),
run_everything_argv=("--auto",),
workspace_flag=None,
model_flag="--model",
output_argv=("--output-format", "stream-json"),
prompt_transport="flag",
),
"opencode": AdapterSpec(
name="opencode",
noninteractive_argv=("--print",),
run_everything_argv=("--yes",),
workspace_flag="--workspace",
model_flag="--model",
output_argv=("--output-format", "json"),
prompt_transport="positional",
),
}
def probe_adapter(profile: Profile, config: ExecutionConfig) -> AdapterProbe:
"""Local executable/version/help probe; never contacts a provider."""
del config # reserved for custom-adapter-aware probes in later units
executable = profile.executable
resolved = shutil.which(executable)
if resolved is None:
return AdapterProbe(
available=False,
reason="executable not found on PATH",
executable=executable,
observed_version=None,
)
version = _bounded_probe_output(resolved, ["--version"])
if version is None:
version = _bounded_probe_output(resolved, ["-v"])
if version is None:
version = _bounded_probe_output(resolved, ["-V"])
help_text = _bounded_probe_output(resolved, ["--help"]) or ""
help_text += "\n" + (_bounded_probe_output(resolved, ["-h"]) or "")
# Codex documents exec options under the subcommand.
if profile.adapter == "codex":
help_text += "\n" + (
_bounded_probe_output(resolved, ["exec", "--help"]) or ""
)
markers = _REQUIRED_HELP_MARKERS.get(profile.adapter)
if markers is not None:
missing = [m for m in markers if not _help_has_marker(help_text, m)]
if missing:
return AdapterProbe(
available=False,
reason=(
"unavailable: missing required capability flags in help: "
+ ", ".join(missing)
),
executable=resolved,
observed_version=version,
)
if profile.adapter == "custom":
# Custom adapters are validated at config load; help is advisory only.
pass
return AdapterProbe(
available=True,
reason=None,
executable=resolved,
observed_version=version,
)
def build_invocation(
profile: Profile,
config: ExecutionConfig,
workspace: Path,
unit_packet: Path,
result_path: Path,
) -> Invocation:
"""Compose a direct argv invocation; never uses a shell."""
workspace = Path(workspace)
unit_packet = Path(unit_packet)
result_path = Path(result_path)
packet_text = _read_unit_packet_text(unit_packet)
prompt = _build_bounded_unit_prompt(packet_text)
if profile.adapter == "custom":
return _build_custom_invocation(
profile,
config,
workspace,
unit_packet,
result_path,
packet_text=packet_text,
prompt=prompt,
)
spec = BUILTIN_SPECS.get(profile.adapter)
if spec is None:
raise ValueError(f"unknown adapter: {profile.adapter!r}")
argv: list[str] = [profile.executable]
argv.extend(spec.noninteractive_argv)
argv.extend(spec.run_everything_argv)
if spec.workspace_flag is not None:
argv.extend([spec.workspace_flag, str(workspace)])
if profile.model is not None and spec.model_flag is not None:
argv.extend([spec.model_flag, profile.model])
argv.extend(spec.output_argv)
if spec.name == "codex":
# --output-last-message requires the result path as the next argv item.
argv.append(str(result_path))
stdin: bytes | None = None
prompt_values: set[str] = set()
if spec.prompt_transport == "positional":
argv.append(prompt)
prompt_values.add(prompt)
elif spec.prompt_transport == "stdin":
stdin = packet_text.encode("utf-8")
elif spec.prompt_transport == "flag":
argv.extend(["--prompt", prompt])
prompt_values.add(prompt)
elif spec.prompt_transport == "print":
# agy 1.3.5 consumes the argv item immediately after --print.
argv.extend(["--print", prompt])
prompt_values.add(prompt)
else:
raise ValueError(f"unsupported prompt_transport: {spec.prompt_transport!r}")
argv_t = tuple(argv)
return Invocation(
argv=argv_t,
cwd=workspace,
stdin=stdin,
sanitized_display=_sanitize_display(argv_t, prompt_values=prompt_values),
)
def _build_custom_invocation(
profile: Profile,
config: ExecutionConfig,
workspace: Path,
unit_packet: Path,
result_path: Path,
*,
packet_text: str,
prompt: str,
) -> Invocation:
custom = config.custom_adapters.get(profile.name)
if custom is None:
raise ValueError(
f"profile {profile.name!r} uses custom adapter without definition"
)
values = {
"workspace": str(workspace),
# Placeholder expands to the packet path, not the body.
"unit_packet": str(unit_packet),
"result_path": str(result_path),
"model": profile.model or "",
}
argv: list[str] = [profile.executable]
argv.extend(_expand_argv(custom.base_argv, values, omit_model=False))
argv.extend(_expand_argv(custom.run_everything_argv, values, omit_model=False))
argv.extend(_expand_argv(custom.workspace_argv, values, omit_model=False))
if profile.model is not None:
argv.extend(_expand_argv(custom.model_argv, values, omit_model=False))
# model is None: omit the complete model flag/value pair (entire model_argv).
argv.extend(_CUSTOM_OUTPUT_ARGV.get(custom.output_format, ()))
stdin: bytes | None = None
prompt_values: set[str] = set()
if custom.prompt_transport == "stdin":
stdin = packet_text.encode("utf-8")
elif custom.prompt_transport == "positional":
argv.append(prompt)
prompt_values.add(prompt)
elif custom.prompt_transport == "flag":
argv.extend(["--prompt", prompt])
prompt_values.add(prompt)
else:
raise ValueError(
f"unsupported prompt_transport: {custom.prompt_transport!r}"
)
argv_t = tuple(argv)
return Invocation(
argv=argv_t,
cwd=workspace,
stdin=stdin,
sanitized_display=_sanitize_display(argv_t, prompt_values=prompt_values),
)
def _read_unit_packet_text(path: Path) -> str:
try:
raw = path.read_bytes()
except OSError as exc:
raise ValueError(f"unable to read unit packet: {path}") from exc
if len(raw) > MAX_UNIT_PACKET_BYTES:
raise ValueError(
f"unit packet exceeds size limit of {MAX_UNIT_PACKET_BYTES} bytes"
)
try:
return raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError("unit packet is not valid UTF-8") from exc
def _build_bounded_unit_prompt(packet_text: str) -> str:
schema = json.dumps(TERMINAL_RECORD_SCHEMA, separators=(",", ":"))
return (
"Implement exactly the supplied bounded implementation unit. "
"Work only inside the current workspace. "
"Leave all changes uncommitted. "
"Do not expand scope beyond the unit packet. "
"Return a structured final result matching the unit's required schema.\n\n"
"--- TERMINAL RECORD ---\n\n"
"End your final message with exactly one terminal record: a single-line "
"JSON object that validates against this JSON Schema:\n"
f"{schema}\n"
"Use terminal_status completed only when the unit is fully implemented. "
"Use blocked, cancelled, failed-unknown, failed, or scope-expansion "
"otherwise. A missing or invalid terminal record fails the attempt.\n\n"
"--- BOUNDED IMPLEMENTATION UNIT PACKET ---\n\n"
f"{packet_text}"
)
def extract_terminal_record(text: str | None) -> dict[str, Any] | None:
"""Return the sole terminal record that ends one final message.
Only whitespace may follow the record, and no other valid record may
precede it; anything else fails closed.
"""
if not isinstance(text, str) or not text:
return None
text = text[-_MAX_TERMINAL_TEXT_CHARS:].rstrip()
decoder = json.JSONDecoder()
def decode_at(pos: int) -> tuple[object, int]:
try:
return decoder.raw_decode(text, pos)
except json.JSONDecodeError:
return None, -1
pos = text.rfind("{")
while pos != -1:
candidate, end = decode_at(pos)
if end == len(text) and isinstance(candidate, dict):
break
pos = text.rfind("{", 0, pos)
else:
return None
record = _valid_terminal_record(candidate)
if record is None:
return None
earlier = text.rfind("{", 0, pos)
while earlier != -1:
if _valid_terminal_record(decode_at(earlier)[0]) is not None:
return None
earlier = text.rfind("{", 0, earlier)
return record
def _valid_terminal_record(obj: object) -> dict[str, Any] | None:
if not isinstance(obj, dict):
return None
status = obj.get("terminal_status")
if not isinstance(status, str):
return None
status = status.strip().replace("_", "-")
if status not in TERMINAL_STATUSES:
return None
if not isinstance(obj.get("summary"), str):
return None
for key in ("changed_files", "evidence"):
items = obj.get(key)
if not isinstance(items, list) or any(not isinstance(i, str) for i in items):
return None
if "scope_expansion" not in obj or not (
obj["scope_expansion"] is None or isinstance(obj["scope_expansion"], str)
):
return None
record = dict(obj)
record["terminal_status"] = status
return record
def decode_terminal_record(
adapter: str,
*,
event_lines: Sequence[str | None],
stdout_tail: str,
result_text: str | None,
) -> dict[str, Any] | None:
"""Decode the worker terminal record from the adapter's real output.
``event_lines`` are recent complete stdout lines that may carry a terminal
envelope, oldest first; ``None`` marks an envelope line too large to keep.
Event-stream adapters are read only from their final assistant/result
envelope, so prompt echoes and tool output never count as a record.
"""
if adapter in ("cursor", "claude"):
return extract_terminal_record(_cursor_final_text(event_lines))
if adapter == "antigravity":
return extract_terminal_record(_agy_final_text(event_lines, stdout_tail))
if adapter == "pi":
return extract_terminal_record(_pi_final_text(event_lines))
record = extract_terminal_record(result_text)
if record is None:
record = extract_terminal_record(stdout_tail)
return record
def _event_objects(event_lines: Sequence[str | None]) -> list[dict[str, Any] | None]:
"""Newest-first parsed events; ``None`` keeps the oversized-envelope marker."""
out: list[dict[str, Any] | None] = []
for line in reversed(event_lines):
if line is None:
out.append(None)
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(obj, dict):
out.append(obj)
return out
def _cursor_final_text(event_lines: Sequence[str | None]) -> str | None:
for obj in _event_objects(event_lines):
if obj is None:
return None
if obj.get("type") != "result":
continue
if obj.get("is_error") is not False or obj.get("subtype") != "success":
return None
text = obj.get("result")
return text if isinstance(text, str) else None
return None
def _agy_final_text(
event_lines: Sequence[str | None], stdout_tail: str
) -> str | None:
candidates = _event_objects(event_lines)
try:
whole = json.loads(stdout_tail.strip())
except json.JSONDecodeError:
whole = None
if isinstance(whole, dict):
candidates.append(whole)
for obj in candidates:
if obj is None:
return None
if obj.get("event") == "result":
payload = obj.get("result")
elif "event" not in obj and "response" in obj:
payload = obj
else:
continue
if not isinstance(payload, dict):
return None
if payload.get("is_error") or payload.get("error"):
return None
text = payload.get("response")
return text if isinstance(text, str) else None
return None
def _pi_final_text(event_lines: Sequence[str | None]) -> str | None:
for obj in _event_objects(event_lines):
if obj is None:
return None
if obj.get("type") != "message_end":
continue
message = obj.get("message")
if not isinstance(message, dict) or message.get("role") != "assistant":
continue
if message.get("stopReason") in ("error", "aborted"):
return None
content = message.get("content")
if isinstance(content, str):
return content
if not isinstance(content, list):
return None
return "".join(
block["text"]
for block in content
if isinstance(block, dict)
and block.get("type") == "text"
and isinstance(block.get("text"), str)
)
return None
def _expand_argv(
items: tuple[str, ...],
values: dict[str, str],
*,
omit_model: bool,
) -> list[str]:
out: list[str] = []
for item in items:
if item in ALLOWED_PLACEHOLDERS:
key = _PLACEHOLDER_MAP[item]
if key == "model" and (omit_model or not values.get("model")):
continue
out.append(values[key])
continue
if "${" in item:
# Config validation already rejects non-whole-item placeholders.
raise ValueError(f"placeholder must fill whole argv item: {item!r}")
out.append(item)
return out
def _help_has_marker(help_text: str, marker: str) -> bool:
if marker in help_text:
return True
# Accept short forms when long forms are required markers.
short_aliases = {
"--print": ("-p",),
"--force": ("-f",),
"--model": ("-m",),
"--cd": ("-C",),
"--config": ("-c",),
"--approve": ("-a",),
"--prompt": ("-p",),
}
for alias in short_aliases.get(marker, ()):
if re.search(rf"(^|\s){re.escape(alias)}(\s|,|$)", help_text):
return True
return False
def _bounded_probe_output(executable: str, args: list[str]) -> str | None:
try:
completed = subprocess.run(
[executable, *args],
check=False,
capture_output=True,
text=True,
timeout=_PROBE_TIMEOUT_SECONDS,
shell=False,
)
except (OSError, subprocess.TimeoutExpired):
return None
text = (completed.stdout or "") + (completed.stderr or "")
text = text.strip()
return text or None
_SENSITIVE_FLAG_RE = re.compile(
r"^(?:--api-key|--token|--password|--secret|-H|--header|--authorization)$",
re.IGNORECASE,
)
def _sanitize_display(
argv: tuple[str, ...],
*,
prompt_values: set[str],
) -> tuple[str, ...]:
out: list[str] = []
redact_next = False
for item in argv:
if item in prompt_values:
out.append(UNIT_PACKET_DISPLAY_MARKER)
continue
if redact_next:
out.append("***")
redact_next = False
continue
if _SENSITIVE_FLAG_RE.match(item):
out.append(item)
redact_next = True
continue
if SECRET_VALUE_RE.match(item.strip()):
out.append("***")
continue
out.append(item)
return tuple(out)
__all__ = [
"MAX_UNIT_PACKET_BYTES",
"TERMINAL_RECORD_SCHEMA",
"TERMINAL_STATUSES",
"UNIT_PACKET_DISPLAY_MARKER",
"AdapterProbe",
"AdapterSpec",
"BUILTIN_SPECS",
"Invocation",
"build_invocation",
"decode_terminal_record",
"extract_terminal_record",
"probe_adapter",
]
-735
View File
@@ -1,735 +0,0 @@
"""Markdown extraction and constrained YAML parsing for AGENT-EXECUTION.md."""
from __future__ import annotations
import ast
import re
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any
SCHEMA_V1 = "agent-execution/v1"
CONFIG_NAME = "AGENT-EXECUTION.md"
BLOCK_OPEN = "```agent-execution"
BLOCK_CLOSE = "```"
BUILTIN_ADAPTERS = frozenset(
{"cursor", "antigravity", "claude", "codex", "pi", "kimi", "opencode", "custom"}
)
REQUIRED_ROLES = ("implementation", "repair", "test", "review")
ALLOWED_PLACEHOLDERS = frozenset(
{"${workspace}", "${unit_packet}", "${result_path}", "${model}"}
)
PLACEHOLDER_RE = re.compile(r"\$\{[A-Za-z0-9_]+\}")
PERMISSION_VALUES = frozenset({"run-everything"})
FALLBACK_VALUES = frozenset({"all-unavailable", "never"})
# Implementation/repair/test roles each need exactly one enabled profile per
# adapter here, with or without emergency_profile; review is unrestricted.
NORMAL_ROLE_ADAPTERS = frozenset({"cursor", "antigravity", "pi"})
MAX_ATTEMPTS_PER_PROFILE = 3
PROMPT_TRANSPORTS = frozenset({"stdin", "positional", "flag"})
OUTPUT_FORMATS = frozenset({"json", "stream-json", "jsonl", "text"})
MAX_PARALLEL = 32
MAX_TIMEOUT_SECONDS = 86400
MAX_SUMMARY_BYTES = 1_048_576
MAX_RAW_OUTPUT_BYTES = 104_857_600
# Omitted idle_timeout_seconds uses this cap, then clamps to timeout_seconds.
DEFAULT_IDLE_TIMEOUT_SECONDS = 600
SHELL_META_RE = re.compile(r"[;&|`$<>(){}]|&&|\|\||\$\(")
SECRET_KEY_RE = re.compile(
r"(password|secret|token|api[_-]?key|authorization|credential|passwd)",
re.IGNORECASE,
)
SECRET_VALUE_RE = re.compile(
r"(?i)^(bearer\s+\S+|sk-[A-Za-z0-9_\-]{8,}|[A-Fa-f0-9]{32,})$"
)
ROOT_KEYS = frozenset(
{"schema", "execution", "roles", "profiles", "custom_adapters", "integration"}
)
INTEGRATION_KEYS = frozenset({"exempt_paths", "transport", "admission", "cleanup"})
ENTRYPOINT_KEYS = frozenset({"command"})
# Mirrors the host-managed prefixes enforced by scripts/agent_exec.py
# (_HOST_MANAGED_PREFIXES, the enforcement source). A declared
# integration.exempt_paths must equal this set so it cannot widen host authority.
HOST_EXEMPT_PATHS = frozenset(
{".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"}
)
EXECUTION_KEYS = frozenset(
{
"fresh_process_per_unit",
"permission",
"fallback_to_host",
"max_parallel",
"timeout_seconds",
"idle_timeout_seconds",
"summary_bytes",
"raw_output_bytes",
"max_attempts_per_profile",
"emergency_profile",
}
)
EXECUTION_REQUIRED = EXECUTION_KEYS - frozenset(
{"idle_timeout_seconds", "max_attempts_per_profile", "emergency_profile"}
)
PROFILE_KEYS = frozenset({"adapter", "executable", "model", "enabled"})
CUSTOM_ADAPTER_KEYS = frozenset(
{
"base_argv",
"run_everything_argv",
"workspace_argv",
"model_argv",
"prompt_transport",
"output_format",
}
)
class ConfigError(ValueError):
"""Raised when AGENT-EXECUTION.md cannot be parsed or fails schema checks."""
@dataclass(frozen=True)
class ExecutionPolicy:
fresh_process_per_unit: bool
permission: str
fallback_to_host: str
max_parallel: int
timeout_seconds: int
summary_bytes: int
raw_output_bytes: int
idle_timeout_seconds: int = DEFAULT_IDLE_TIMEOUT_SECONDS
max_attempts_per_profile: int = 1
emergency_profile: str | None = None
@dataclass(frozen=True)
class Profile:
name: str
adapter: str
executable: str
model: str | None
enabled: bool
@dataclass(frozen=True)
class CustomAdapter:
name: str
base_argv: tuple[str, ...]
run_everything_argv: tuple[str, ...]
workspace_argv: tuple[str, ...]
model_argv: tuple[str, ...]
prompt_transport: str
output_format: str
@dataclass(frozen=True)
class IntegrationConfig:
exempt_paths: tuple[str, ...]
transport_command: str
admission_command: str
cleanup_command: str
@dataclass(frozen=True)
class ExecutionConfig:
schema: str
execution: ExecutionPolicy
roles: Mapping[str, tuple[str, ...]]
profiles: Mapping[str, Profile]
custom_adapters: Mapping[str, CustomAdapter]
integration: IntegrationConfig | None = None
def load_config(repo: Path) -> ExecutionConfig:
"""Load and validate the single agent-execution block from repo/AGENT-EXECUTION.md."""
path = Path(repo) / CONFIG_NAME
if not path.is_file():
raise ConfigError(f"missing {CONFIG_NAME}")
text = path.read_text(encoding="utf-8")
raw = _parse_subset_yaml(_extract_block(text))
return _build_config(raw)
def validate_config(repo: Path) -> list[str]:
"""Return an empty list when configuration is valid; raise ConfigError otherwise."""
load_config(repo)
return []
def _extract_block(text: str) -> str:
lines = text.splitlines()
starts: list[int] = []
for i, line in enumerate(lines):
if line.strip() == BLOCK_OPEN:
starts.append(i)
if not starts:
raise ConfigError("missing agent-execution fenced block")
if len(starts) > 1:
raise ConfigError("duplicate agent-execution fenced blocks")
start = starts[0]
end = None
for j in range(start + 1, len(lines)):
if lines[j].strip() == BLOCK_CLOSE:
end = j
break
if end is None:
raise ConfigError("unclosed agent-execution fenced block")
return "\n".join(lines[start + 1 : end])
def _parse_subset_yaml(text: str) -> dict[str, Any]:
if "\t" in text:
raise ConfigError("tabs are not allowed; use two-space indentation")
lines = text.splitlines()
root: dict[str, Any] = {}
stack: list[tuple[int, dict[str, Any]]] = [(-2, root)]
i = 0
while i < len(lines):
raw = lines[i]
if not raw.strip() or raw.lstrip().startswith("#"):
i += 1
continue
indent = len(raw) - len(raw.lstrip(" "))
stripped = raw.lstrip(" ")
if stripped.startswith(("- ", "?", "|", ">", "&", "*", "!")):
raise ConfigError(f"unsupported YAML construct: {raw.strip()!r}")
if re.search(r"(^|\s)<<", stripped):
raise ConfigError(f"unsupported YAML merge/alias: {raw.strip()!r}")
content = raw[indent:]
if ":" not in content:
raise ConfigError(f"expected key: value, got {raw.strip()!r}")
key, _, rest = content.partition(":")
key = key.strip()
rest = rest.strip()
if not key or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
raise ConfigError(f"invalid mapping key: {key!r}")
_reject_secret_key(key)
# Pop to parent whose indent is strictly less than current.
while len(stack) > 1 and stack[-1][0] >= indent:
stack.pop()
parent_indent, parent = stack[-1]
expected = parent_indent + 2
if indent != expected:
raise ConfigError(
f"indentation error: expected {expected} spaces, got {indent}"
)
if key in parent:
raise ConfigError(f"duplicate mapping key: {key!r}")
if rest == "":
child: dict[str, Any] = {}
parent[key] = child
stack.append((indent, child))
i += 1
continue
value = _parse_scalar(rest, key=key)
parent[key] = value
i += 1
return root
def _parse_scalar(text: str, *, key: str) -> Any:
if text.startswith(("|", ">")):
raise ConfigError("multiline scalars are not supported")
if text.startswith("["):
return _parse_flow_sequence(text)
if text in ("true", "false"):
return text == "true"
if text in ("null", "~"):
return None
if re.fullmatch(r"-?\d+", text):
return int(text)
if re.fullmatch(r"\d{4}-\d{2}-\d{2}.*", text):
raise ConfigError(f"implicit dates are not supported: {text!r}")
if text[:1] in ("'", '"'):
value = _parse_quoted_string(text)
else:
if any(ch in text for ch in ":#[]{},"):
raise ConfigError(f"unsafe unquoted scalar: {text!r}")
value = text
_reject_shell_or_secret_value(key, value)
return value
def _parse_quoted_string(text: str) -> str:
quote = text[0]
if quote not in ("'", '"'):
raise ConfigError(f"invalid quoted string: {text!r}")
if quote == "'":
# Single-quoted YAML-ish: only '' escape; keep non-ASCII intact.
if not text.endswith("'") or len(text) < 2:
raise ConfigError(f"unclosed single-quoted string: {text!r}")
inner = text[1:-1]
return inner.replace("''", "'")
# Double-quoted: exact Python/JSON-compatible escapes via ast.literal_eval.
try:
value = ast.literal_eval(text)
except (SyntaxError, ValueError) as exc:
raise ConfigError(f"invalid double-quoted string: {text!r}") from exc
if not isinstance(value, str):
raise ConfigError(f"quoted scalar must be a string: {text!r}")
return value
def _parse_flow_sequence(text: str) -> list[Any]:
if not text.startswith("[") or not text.endswith("]"):
raise ConfigError(f"invalid flow sequence: {text!r}")
inner = text[1:-1].strip()
if not inner:
return []
items: list[Any] = []
buf: list[str] = []
in_quote: str | None = None
escape = False
i = 0
while i < len(inner):
ch = inner[i]
if in_quote:
buf.append(ch)
if escape:
escape = False
elif ch == "\\" and in_quote == '"':
escape = True
elif ch == in_quote:
in_quote = None
i += 1
continue
if ch in ("'", '"'):
in_quote = ch
buf.append(ch)
i += 1
continue
if ch == ",":
items.append(_parse_scalar("".join(buf).strip(), key="list_item"))
buf = []
i += 1
continue
buf.append(ch)
i += 1
if in_quote:
raise ConfigError(f"unclosed quote in flow sequence: {text!r}")
if buf and "".join(buf).strip():
items.append(_parse_scalar("".join(buf).strip(), key="list_item"))
return items
def _reject_secret_key(key: str) -> None:
if SECRET_KEY_RE.search(key):
raise ConfigError(f"credential-looking key rejected: {key!r}")
def _reject_shell_or_secret_value(key: str, value: Any) -> None:
if not isinstance(value, str):
return
# Whole-item placeholders are validated later for argv contexts.
if value in ALLOWED_PLACEHOLDERS:
return
if SHELL_META_RE.search(value):
raise ConfigError(f"shell metacharacter template rejected for {key!r}")
if SECRET_VALUE_RE.match(value.strip()):
raise ConfigError(f"secret-shaped value rejected for {key!r}")
def _build_config(raw: dict[str, Any]) -> ExecutionConfig:
if not isinstance(raw, dict):
raise ConfigError("root must be a mapping")
unknown = set(raw) - ROOT_KEYS
if unknown:
raise ConfigError(f"unknown top-level keys: {sorted(unknown)}")
for required in ("schema", "execution", "roles", "profiles"):
if required not in raw:
raise ConfigError(f"missing required key: {required}")
schema = raw["schema"]
if schema != SCHEMA_V1:
raise ConfigError(f"unknown schema: {schema!r}")
execution = _build_execution(raw["execution"])
profiles = _build_profiles(raw["profiles"])
custom_adapters = _build_custom_adapters(raw.get("custom_adapters", {}))
roles = _build_roles(raw["roles"], profiles)
for profile in profiles.values():
if profile.adapter == "custom":
if profile.name not in custom_adapters:
raise ConfigError(
f"profile {profile.name!r} uses adapter custom without "
f"custom_adapters.{profile.name}"
)
elif profile.adapter not in BUILTIN_ADAPTERS - {"custom"}:
if profile.adapter not in custom_adapters:
raise ConfigError(f"unknown adapter: {profile.adapter!r}")
_validate_emergency_profile(execution, profiles, roles)
_validate_normal_role_adapters(profiles, roles)
_validate_normal_role_exactness(profiles, roles)
integration = None
if "integration" in raw:
integration = _build_integration(raw["integration"])
return ExecutionConfig(
schema=schema,
execution=execution,
roles=roles,
profiles=profiles,
custom_adapters=custom_adapters,
integration=integration,
)
def _build_execution(raw: Any) -> ExecutionPolicy:
if not isinstance(raw, dict):
raise ConfigError("execution must be a mapping")
unknown = set(raw) - EXECUTION_KEYS
if unknown:
raise ConfigError(f"unknown execution keys: {sorted(unknown)}")
missing = EXECUTION_REQUIRED - set(raw)
if missing:
raise ConfigError(f"missing execution keys: {sorted(missing)}")
fresh = raw["fresh_process_per_unit"]
if fresh is not True:
raise ConfigError("fresh_process_per_unit must be true")
permission = raw["permission"]
if permission not in PERMISSION_VALUES:
raise ConfigError(f"unsupported permission: {permission!r}")
fallback = raw["fallback_to_host"]
if fallback not in FALLBACK_VALUES:
raise ConfigError(f"unsupported fallback_to_host: {fallback!r}")
max_parallel = _positive_bounded(
raw["max_parallel"], "max_parallel", 1, MAX_PARALLEL
)
timeout_seconds = _positive_bounded(
raw["timeout_seconds"], "timeout_seconds", 1, MAX_TIMEOUT_SECONDS
)
if "idle_timeout_seconds" in raw:
idle_timeout_seconds = _positive_bounded(
raw["idle_timeout_seconds"],
"idle_timeout_seconds",
1,
timeout_seconds,
)
else:
idle_timeout_seconds = min(DEFAULT_IDLE_TIMEOUT_SECONDS, timeout_seconds)
summary_bytes = _positive_bounded(
raw["summary_bytes"], "summary_bytes", 1, MAX_SUMMARY_BYTES
)
raw_output_bytes = _positive_bounded(
raw["raw_output_bytes"], "raw_output_bytes", 1, MAX_RAW_OUTPUT_BYTES
)
if raw_output_bytes < summary_bytes:
raise ConfigError("raw_output_bytes must be >= summary_bytes")
if "max_attempts_per_profile" in raw:
max_attempts_per_profile = _positive_bounded(
raw["max_attempts_per_profile"],
"max_attempts_per_profile",
1,
MAX_ATTEMPTS_PER_PROFILE,
)
else:
max_attempts_per_profile = 1
if "emergency_profile" in raw:
emergency_profile = raw["emergency_profile"]
if not isinstance(emergency_profile, str) or not emergency_profile:
raise ConfigError("emergency_profile must be a non-empty string")
else:
emergency_profile = None
return ExecutionPolicy(
fresh_process_per_unit=True,
permission=permission,
fallback_to_host=fallback,
max_parallel=max_parallel,
timeout_seconds=timeout_seconds,
summary_bytes=summary_bytes,
raw_output_bytes=raw_output_bytes,
idle_timeout_seconds=idle_timeout_seconds,
max_attempts_per_profile=max_attempts_per_profile,
emergency_profile=emergency_profile,
)
def _validate_emergency_profile(
execution: ExecutionPolicy,
profiles: Mapping[str, Profile],
roles: Mapping[str, tuple[str, ...]],
) -> None:
name = execution.emergency_profile
if name is None:
return
if execution.max_attempts_per_profile != 3:
raise ConfigError(
"max_attempts_per_profile must equal 3 when emergency_profile is configured"
)
profile = profiles.get(name)
if profile is None:
raise ConfigError(f"emergency_profile {name!r} does not exist")
if not profile.enabled:
raise ConfigError(f"emergency_profile {name!r} must be enabled")
if profile.adapter != "codex":
raise ConfigError(f"emergency_profile {name!r} must use adapter codex")
for role in ("implementation", "repair", "test"):
if name in roles[role]:
raise ConfigError(
f"emergency_profile {name!r} must not appear in role {role!r}"
)
def _validate_normal_role_adapters(
profiles: Mapping[str, Profile],
roles: Mapping[str, tuple[str, ...]],
) -> None:
"""Reject adapters outside ``NORMAL_ROLE_ADAPTERS`` on implementation, repair, and test.
Applies to every profile in those roles, enabled or not, in every mode.
"""
for role in ("implementation", "repair", "test"):
for name in roles[role]:
adapter = profiles[name].adapter
if adapter not in NORMAL_ROLE_ADAPTERS:
raise ConfigError(
f"profile {name!r} in role {role!r} must use adapter "
"cursor, antigravity, or pi"
)
def _validate_normal_role_exactness(
profiles: Mapping[str, Profile],
roles: Mapping[str, tuple[str, ...]],
) -> None:
"""Each normal role needs exactly one enabled profile per normal adapter.
Disabled profiles do not count and may appear alongside the enabled one.
Review is exempt.
"""
for role in ("implementation", "repair", "test"):
enabled: dict[str, list[str]] = {}
for name in roles[role]:
profile = profiles[name]
if profile.enabled:
enabled.setdefault(profile.adapter, []).append(name)
for adapter in sorted(NORMAL_ROLE_ADAPTERS):
names = enabled.get(adapter, [])
if len(names) != 1:
raise ConfigError(
f"role {role!r} must reference exactly one enabled "
f"{adapter} profile; found {names or 'none'}"
)
def _positive_bounded(value: Any, name: str, lo: int, hi: int) -> int:
if type(value) is not int or isinstance(value, bool):
raise ConfigError(f"{name} must be an integer")
if value < lo or value > hi:
raise ConfigError(f"{name} out of bounds: {value}")
return value
def _build_profiles(raw: Any) -> dict[str, Profile]:
if not isinstance(raw, dict) or not raw:
raise ConfigError("profiles must be a non-empty mapping")
out: dict[str, Profile] = {}
for name, body in raw.items():
if not isinstance(body, dict):
raise ConfigError(f"profile {name!r} must be a mapping")
unknown = set(body) - PROFILE_KEYS
if unknown:
raise ConfigError(f"unknown keys in profile {name!r}: {sorted(unknown)}")
missing = PROFILE_KEYS - set(body)
if missing:
raise ConfigError(f"missing keys in profile {name!r}: {sorted(missing)}")
adapter = body["adapter"]
executable = body["executable"]
enabled = body["enabled"]
model_raw = body["model"]
if not isinstance(adapter, str) or not adapter:
raise ConfigError(f"profile {name!r} adapter must be a string")
if not isinstance(executable, str) or not executable:
raise ConfigError(f"profile {name!r} executable must be a string")
if type(enabled) is not bool:
raise ConfigError(f"profile {name!r} enabled must be a boolean")
if model_raw is None or model_raw == "auto":
model: str | None = None
elif isinstance(model_raw, str) and model_raw:
model = model_raw
else:
raise ConfigError(f"profile {name!r} model must be a string or auto")
out[name] = Profile(
name=name,
adapter=adapter,
executable=executable,
model=model,
enabled=enabled,
)
return out
def _build_roles(
raw: Any, profiles: Mapping[str, Profile]
) -> dict[str, tuple[str, ...]]:
if not isinstance(raw, dict):
raise ConfigError("roles must be a mapping")
unknown = set(raw) - set(REQUIRED_ROLES)
if unknown:
raise ConfigError(f"unknown roles: {sorted(unknown)}")
missing = set(REQUIRED_ROLES) - set(raw)
if missing:
raise ConfigError(f"missing roles: {sorted(missing)}")
out: dict[str, tuple[str, ...]] = {}
for role in REQUIRED_ROLES:
value = raw[role]
if not isinstance(value, list) or not value:
raise ConfigError(f"role {role!r} must be a non-empty list")
names: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str) or not item:
raise ConfigError(f"role {role!r} entries must be strings")
if item in seen:
raise ConfigError(f"duplicate profile reference in role {role!r}: {item}")
if item not in profiles:
raise ConfigError(f"unknown profile reference: {item!r}")
seen.add(item)
names.append(item)
out[role] = tuple(names)
return out
def _build_custom_adapters(raw: Any) -> dict[str, CustomAdapter]:
if raw is None:
return {}
if not isinstance(raw, dict):
raise ConfigError("custom_adapters must be a mapping")
out: dict[str, CustomAdapter] = {}
for name, body in raw.items():
if not isinstance(body, dict):
raise ConfigError(f"custom adapter {name!r} must be a mapping")
unknown = set(body) - CUSTOM_ADAPTER_KEYS
if unknown:
raise ConfigError(
f"unknown keys in custom adapter {name!r}: {sorted(unknown)}"
)
missing = CUSTOM_ADAPTER_KEYS - set(body)
if missing:
raise ConfigError(
f"missing keys in custom adapter {name!r}: {sorted(missing)}"
)
base_argv = _argv_tuple(body["base_argv"], f"custom_adapters.{name}.base_argv")
run_everything_argv = _argv_tuple(
body["run_everything_argv"],
f"custom_adapters.{name}.run_everything_argv",
)
workspace_argv = _argv_tuple(
body["workspace_argv"], f"custom_adapters.{name}.workspace_argv"
)
model_argv = _argv_tuple(
body["model_argv"], f"custom_adapters.{name}.model_argv"
)
prompt_transport = body["prompt_transport"]
output_format = body["output_format"]
if prompt_transport not in PROMPT_TRANSPORTS:
raise ConfigError(
f"unsupported prompt_transport for {name!r}: {prompt_transport!r}"
)
if output_format not in OUTPUT_FORMATS:
raise ConfigError(
f"unsupported output_format for {name!r}: {output_format!r}"
)
out[name] = CustomAdapter(
name=name,
base_argv=base_argv,
run_everything_argv=run_everything_argv,
workspace_argv=workspace_argv,
model_argv=model_argv,
prompt_transport=prompt_transport,
output_format=output_format,
)
return out
def _build_integration(raw: Any) -> IntegrationConfig:
if not isinstance(raw, dict):
raise ConfigError("integration must be a mapping")
unknown = set(raw) - INTEGRATION_KEYS
if unknown:
raise ConfigError(f"unknown keys in integration: {sorted(unknown)}")
missing = INTEGRATION_KEYS - set(raw)
if missing:
raise ConfigError(f"missing keys in integration: {sorted(missing)}")
exempt_paths = _exempt_paths_tuple(raw["exempt_paths"])
if set(exempt_paths) != HOST_EXEMPT_PATHS:
raise ConfigError(
"integration.exempt_paths must exactly match the host-managed set "
f"{sorted(HOST_EXEMPT_PATHS)}; got {list(exempt_paths)}"
)
return IntegrationConfig(
exempt_paths=exempt_paths,
transport_command=_entrypoint_command(raw["transport"], "integration.transport"),
admission_command=_entrypoint_command(raw["admission"], "integration.admission"),
cleanup_command=_entrypoint_command(raw["cleanup"], "integration.cleanup"),
)
def _exempt_paths_tuple(value: Any) -> tuple[str, ...]:
if not isinstance(value, list) or not value:
raise ConfigError("integration.exempt_paths must be a non-empty list")
items: list[str] = []
for item in value:
if not isinstance(item, str) or not item:
raise ConfigError("integration.exempt_paths entries must be non-empty strings")
if item.startswith("/") or ".." in PurePosixPath(item).parts:
raise ConfigError(
f"integration.exempt_paths must be relative without '..': {item!r}"
)
if item in items:
raise ConfigError(f"duplicate integration.exempt_paths entry: {item!r}")
items.append(item)
return tuple(items)
def _entrypoint_command(raw: Any, label: str) -> str:
if not isinstance(raw, dict):
raise ConfigError(f"{label} must be a mapping")
unknown = set(raw) - ENTRYPOINT_KEYS
if unknown:
raise ConfigError(f"unknown keys in {label}: {sorted(unknown)}")
missing = ENTRYPOINT_KEYS - set(raw)
if missing:
raise ConfigError(f"missing keys in {label}: {sorted(missing)}")
command = raw["command"]
if not isinstance(command, str) or not command.strip():
raise ConfigError(f"{label}.command must be a non-empty string")
_reject_shell_or_secret_value(f"{label}.command", command)
return command
def _argv_tuple(value: Any, label: str) -> tuple[str, ...]:
if not isinstance(value, list):
raise ConfigError(f"{label} must be a list")
items: list[str] = []
for item in value:
if not isinstance(item, str):
raise ConfigError(f"{label} entries must be strings")
matches = PLACEHOLDER_RE.findall(item)
if matches:
if len(matches) != 1 or item != matches[0]:
raise ConfigError(
f"placeholder must fill whole argv item in {label}: {item!r}"
)
if matches[0] not in ALLOWED_PLACEHOLDERS:
raise ConfigError(f"unknown placeholder {matches[0]!r} in {label}")
else:
_reject_shell_or_secret_value(label, item)
items.append(item)
return tuple(items)
+1 -1
View File
@@ -264,7 +264,7 @@ ${parent_note}
## Agent 实施收据
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/工作区。实施、修复与测试的正常候选仅为 Cursor、Antigravity(agy)和 PI;每个候选对安全可恢复失败最多 3 次全新尝试,然后才换下一候选。三者都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、实施计划或独立 Review。显式 fail-stop(failed-unknown、scope-expansion、blocked、cancelled、无效配置、未对账 Git 状态与安全失败)立即停止。不因推理时长、工具次数、规划方式或首次写文件时间判定失败;尝试只在显式终态失败、进程退出、总超时,或 stdin/stdout/stderr 均无活动的空闲超时时失败。CLI 可在目标、范围与安全边界内自主检查、计划、测试并迭代。实施集成必须是成功 transport 上的 scripts/agent_exec.py apply,或 pre-commit admit 的精确内容来源。host 直接管理的豁免仅为 .task/**、docs/superpowers/specs/** 与 docs/superpowers/plans/**。fallback 列只记录该行是否为 Codex 紧急尝试。安全耗尽不发出 host-fallback-authorized,终态为 unavailable 或 blocked。不授权 host 直接撰写该单元。原始日志保留在源码外收据路径。
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/工作区。实施、修复与测试的正常候选仅为 Cursor、Antigravity(agy)和 PI;每个候选对安全可恢复失败最多 3 次全新尝试,然后才换下一候选。三者都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、实施计划或独立 Review。显式 fail-stop(failed-unknown、scope-expansion、blocked、cancelled、无效配置、未对账 Git 状态与安全失败)立即停止。不因推理时长、工具次数、规划方式或首次写文件时间判定失败;尝试只在显式终态失败、进程退出、总超时,或 stdin/stdout/stderr 均无活动的空闲超时时失败。CLI 可在目标、范围与安全边界内自主检查、计划、测试并迭代。实施集成必须是成功 transport 上的 scripts/agent-exec apply,或 pre-commit admit 的精确内容来源。host 直接管理的豁免仅为 .task/**、docs/superpowers/specs/** 与 docs/superpowers/plans/**。fallback 列只记录该行是否为 Codex 紧急尝试。安全耗尽不发出 host-fallback-authorized,终态为 unavailable 或 blocked。不授权 host 直接撰写该单元。原始日志保留在源码外收据路径。
| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|---|
-68
View File
@@ -1,68 +0,0 @@
# U5 真实 Agent CLI 探针
本夹具描述 host 稍后执行的 opt-in 探针。七个 profile 的真实结果都还是 `NOT_RUN`。这里不记录探针 `PASS`。
## 门禁
默认测试不设置下面两个变量,因此不会启动真实 CLI,也不会联系 provider。
- `RUN_REAL_AGENT_EXEC=1`:显式打开真实探针。其他值(包括 `true`)保持关闭。
- `AGENT_EXEC_REAL_PROFILE`:每次恰好一个 profile。允许的名字是 `cursor`、`antigravity`、`claude`、`codex`、`pi`、`kimi`、`opencode`。空值、空白、逗号或空格列表都会拒绝,并且不会启动 CLI。
一次只跑一个 profile,不要对同一个目标并行开多个探针。
```bash
RUN_REAL_AGENT_EXEC=1 AGENT_EXEC_REAL_PROFILE=cursor \
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest \
tests.test_agent_exec.RealAgentCliProbeTests.test_opt_in_real_profile_probe -v
```
把 `cursor` 换成要测的那一个 profile。建议顺序:`cursor`,然后 `antigravity`(可执行文件 `agy`),`claude`,`pi`,`codex`,`kimi`。`opencode` 单独跑。
## 夹具
探针在临时 Git 仓库里进行,不使用本仓的规范工作区。
- 分支是非默认分支 `feature/u5-real-probe`。
- 配置里只启用被选中的那一个 profile,`model` 为 `auto`(省略模型参数,不把字符串 `auto` 传给 CLI)。
- 单元只允许一个文件:`src/u5-marker.txt`。
- 文件字节必须是 ASCII `U5-REAL-PROBE-MARKER` 再加一个换行 `0x0A`。
## 核对
Host 只在机械检查全部成立时把能力结论记为 `PASS`:
- transport 树和工作区里的标记内容与上面的字节完全一致。
- transport 提交只有一个父提交,且该父提交就是夹具基线 HEAD。
- `changed_paths` 只有 `src/u5-marker.txt`。
- 成功时保留隔离工作区,`transport_created` 为真,`exact_restore` 为假。
- 模型收据只记录实际看得到的状态。原始输出里没有 `model_receipt_status` 时记 `not-observable`,不得编造成 `verified`。观察到 `mismatch` 时结论不是 `PASS`。
- 规范检出的 HEAD、分支、状态和索引保持不变,标记文件不得出现在规范检出里。
## 不可用与回退
缺失或不兼容的 OpenCode 必须记 `unavailable`,不得记 `PASS`。不兼容指本地 help 缺少 adapter 要求的能力标志;这种情况下不得把 CLI 当成已经跑通。
唯一候选耗尽时,控制器的运行级终态可以是 `host-fallback-authorized`。那是回退授权,不是探针通过。夹具不会代替 CLI 去写标记文件。
Provider 配额、认证失败,或进程启动后没有留下标记且规范检出仍然完好,同样记 `unavailable`,不得记 `PASS`。范围外改动、规范检出被改写、标记内容不符,记 `failed`。
## 源码外收据
真实 opt-in 运行会保留临时仓库,并把收据复制到 `/tmp/agent-exec-<uid>/u5-probes/`。原始 CLI 日志留在控制器的私有 run 目录。这些文件不进入 Git。
默认单元测试使用隔离 PATH 上的假 CLI,只验证上述核对逻辑。
## 仍为 NOT_RUN
| profile | 探针 | 模型收据 | transport | 变更路径 | 清理/保留 | 规范检出 | 能力结论 |
|---|---|---|---|---|---|---|---|
| cursor | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN |
| antigravity | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN |
| claude | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN |
| codex | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN |
| pi | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN |
| kimi | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN |
| opencode | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN |
Host 回退演练、完整治理回归、独立 Review、精确 HEAD CI 与合入也不在本夹具的已执行范围内。
File diff suppressed because it is too large Load Diff
-515
View File
@@ -1,515 +0,0 @@
"""Adapter argv, probe, and custom-expansion contract for agent-exec."""
from __future__ import annotations
import os
import re
import stat
import tempfile
import unittest
from pathlib import Path
from scripts.agent_exec_adapters import (
MAX_UNIT_PACKET_BYTES,
AdapterProbe,
Invocation,
build_invocation,
probe_adapter,
)
from scripts.agent_exec_config import (
CustomAdapter,
ExecutionConfig,
ExecutionPolicy,
Profile,
)
UNIT_PACKET_MARKER = "<unit-packet>"
def _policy() -> ExecutionPolicy:
return ExecutionPolicy(
fresh_process_per_unit=True,
permission="run-everything",
fallback_to_host="all-unavailable",
max_parallel=1,
timeout_seconds=60,
summary_bytes=1024,
raw_output_bytes=4096,
)
def _config(
profiles: dict[str, Profile],
custom_adapters: dict[str, CustomAdapter] | None = None,
) -> ExecutionConfig:
names = tuple(profiles)
return ExecutionConfig(
schema="agent-execution/v1",
execution=_policy(),
roles={
"implementation": names,
"repair": names[:1],
"test": names[:1],
"review": names[:1],
},
profiles=profiles,
custom_adapters=custom_adapters or {},
)
def _write_fake_cli(
directory: Path,
name: str,
*,
help_text: str,
version: str = "0.0.1-test",
) -> Path:
path = directory / name
# Shebang must start at column 0; keep help body out of textwrap.dedent.
path.write_text(
"#!/bin/sh\n"
'if [ "$1" = "--version" ] || [ "$1" = "-v" ] || [ "$1" = "-V" ]; then\n'
f' echo "{version}"\n'
" exit 0\n"
"fi\n"
"cat <<'EOF'\n"
f"{help_text.rstrip()}\n"
"EOF\n",
encoding="utf-8",
)
path.chmod(path.stat().st_mode | stat.S_IEXEC)
return path
def _assert_bounded_prompt(test: unittest.TestCase, prompt: str, packet_body: str) -> None:
test.assertIn(packet_body, prompt)
test.assertNotEqual(prompt, packet_body)
lowered = prompt.lower()
test.assertRegex(lowered, r"implement exactly|bounded")
test.assertRegex(lowered, r"workspace")
test.assertRegex(lowered, r"uncommitted")
test.assertRegex(lowered, r"scope")
test.assertRegex(lowered, r"structured|final result")
CURSOR_HELP = """
Usage: agent [options] [prompt...]
-p, --print
--model <model>
-f, --force
--yolo
--sandbox <mode> choices: enabled, disabled
--trust
--workspace <path>
--output-format <format> text | json | stream-json
"""
AGY_HELP = """
Usage of agy:
--print
--dangerously-skip-permissions
--mode accept-edits, auto, plan
--model
--output-format text, json, stream-json
"""
CLAUDE_HELP = """
Usage: claude [options] [prompt]
-p, --print
--permission-mode <mode> bypassPermissions, manual, dontAsk
--model <model>
--output-format <format> text, json, stream-json
"""
CODEX_HELP = """
Usage: codex exec [OPTIONS] [PROMPT]
exec
-s, --sandbox <SANDBOX_MODE> read-only, workspace-write, danger-full-access
-c, --config <key=value>
-m, --model <MODEL>
-C, --cd <DIR>
--json
-o, --output-last-message <FILE>
"""
PI_HELP = """
Usage: pi [options]
--print, -p
--approve, -a
--model <pattern>
--mode <mode> text, json, or rpc
"""
KIMI_HELP = """
Usage: kimi [options]
-p, --prompt <prompt>
--auto
-m, --model <model>
--output-format <format> text, stream-json
"""
class AgentExecAdapterTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-adapters-")
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.bindir = self.root / "bin"
self.bindir.mkdir()
self.workspace = self.root / "workspace"
self.workspace.mkdir()
self.unit_packet = self.root / "unit.md"
self.packet_body = "# unit\nsecret-token-should-not-leak-in-display\n"
self.unit_packet.write_text(self.packet_body, encoding="utf-8")
self.result_path = self.root / "result.json"
self._old_path = os.environ.get("PATH", "")
os.environ["PATH"] = f"{self.bindir}{os.pathsep}{self._old_path}"
self.addCleanup(self._restore_path)
def _restore_path(self) -> None:
os.environ["PATH"] = self._old_path
def test_cursor_run_everything_and_auto_model_argv(self) -> None:
_write_fake_cli(self.bindir, "cursor-agent", help_text=CURSOR_HELP)
profile = Profile(
name="cursor",
adapter="cursor",
executable="cursor-agent",
model=None,
enabled=True,
)
cfg = _config({"cursor": profile})
probe = probe_adapter(profile, cfg)
self.assertTrue(probe.available, probe.reason)
inv = build_invocation(
profile, cfg, self.workspace, self.unit_packet, self.result_path
)
self.assertIsInstance(inv, Invocation)
self.assertEqual(inv.cwd, self.workspace)
self.assertEqual(
inv.argv[:-1],
(
"cursor-agent",
"-p",
"--force",
"--sandbox",
"enabled",
"--trust",
"--workspace",
str(self.workspace),
"--output-format",
"stream-json",
),
)
_assert_bounded_prompt(self, inv.argv[-1], self.packet_body)
self.assertNotIn(str(self.unit_packet), inv.argv)
self.assertNotIn("--model", inv.argv)
self.assertIsNone(inv.stdin)
self.assertEqual(inv.sanitized_display[-1], UNIT_PACKET_MARKER)
self.assertNotIn(self.packet_body, " ".join(inv.sanitized_display))
self.assertNotIn(inv.argv[-1], inv.sanitized_display)
def test_antigravity_run_everything_argv(self) -> None:
_write_fake_cli(self.bindir, "agy", help_text=AGY_HELP)
profile = Profile(
name="antigravity",
adapter="antigravity",
executable="agy",
model=None,
enabled=True,
)
cfg = _config({"antigravity": profile})
self.assertTrue(probe_adapter(profile, cfg).available)
inv = build_invocation(
profile, cfg, self.workspace, self.unit_packet, self.result_path
)
self.assertEqual(
inv.argv[:-2],
(
"agy",
"--dangerously-skip-permissions",
"--mode",
"auto",
"--output-format",
"stream-json",
),
)
self.assertEqual(inv.argv[-2], "--print")
_assert_bounded_prompt(self, inv.argv[-1], self.packet_body)
self.assertNotIn("--model", inv.argv)
self.assertIsNone(inv.stdin)
self.assertEqual(inv.cwd, self.workspace)
self.assertEqual(inv.sanitized_display[-1], UNIT_PACKET_MARKER)
self.assertEqual(inv.sanitized_display[-2], "--print")
self.assertNotIn(self.packet_body, " ".join(inv.sanitized_display))
def test_claude_run_everything_argv(self) -> None:
_write_fake_cli(self.bindir, "claude", help_text=CLAUDE_HELP)
profile = Profile(
name="claude",
adapter="claude",
executable="claude",
model=None,
enabled=True,
)
cfg = _config({"claude": profile})
self.assertTrue(probe_adapter(profile, cfg).available)
inv = build_invocation(
profile, cfg, self.workspace, self.unit_packet, self.result_path
)
self.assertEqual(
inv.argv[:-1],
(
"claude",
"-p",
"--permission-mode",
"bypassPermissions",
"--output-format",
"stream-json",
),
)
_assert_bounded_prompt(self, inv.argv[-1], self.packet_body)
self.assertEqual(inv.sanitized_display[-1], UNIT_PACKET_MARKER)
def test_codex_uses_noninteractive_workspace_sandbox(self) -> None:
_write_fake_cli(self.bindir, "codex", help_text=CODEX_HELP)
profile = Profile(
name="codex",
adapter="codex",
executable="codex",
model=None,
enabled=True,
)
cfg = _config({"codex": profile})
self.assertTrue(probe_adapter(profile, cfg).available)
inv = build_invocation(
profile, cfg, self.workspace, self.unit_packet, self.result_path
)
self.assertEqual(
inv.argv[:-1],
(
"codex",
"exec",
"--sandbox",
"workspace-write",
"-c",
"approval_policy=never",
"--cd",
str(self.workspace),
"--json",
"--output-last-message",
str(self.result_path),
),
)
_assert_bounded_prompt(self, inv.argv[-1], self.packet_body)
self.assertNotIn("--dangerously-bypass-approvals-and-sandbox", inv.argv)
self.assertEqual(inv.sanitized_display[-1], UNIT_PACKET_MARKER)
def test_pi_and_kimi_auto_model_omit_model_flag(self) -> None:
_write_fake_cli(self.bindir, "pi", help_text=PI_HELP)
_write_fake_cli(self.bindir, "kimi", help_text=KIMI_HELP)
pi = Profile(
name="pi", adapter="pi", executable="pi", model=None, enabled=True
)
kimi = Profile(
name="kimi", adapter="kimi", executable="kimi", model=None, enabled=True
)
cfg = _config({"pi": pi, "kimi": kimi})
pi_inv = build_invocation(
pi, cfg, self.workspace, self.unit_packet, self.result_path
)
kimi_inv = build_invocation(
kimi, cfg, self.workspace, self.unit_packet, self.result_path
)
self.assertEqual(
pi_inv.argv[:-1],
(
"pi",
"-p",
"--approve",
"--mode",
"json",
),
)
_assert_bounded_prompt(self, pi_inv.argv[-1], self.packet_body)
self.assertNotIn("--model", pi_inv.argv)
self.assertEqual(
kimi_inv.argv[:-1],
(
"kimi",
"--auto",
"--output-format",
"stream-json",
"--prompt",
),
)
_assert_bounded_prompt(self, kimi_inv.argv[-1], self.packet_body)
self.assertNotIn("--model", kimi_inv.argv)
self.assertNotIn("-m", kimi_inv.argv)
self.assertEqual(kimi_inv.sanitized_display[-1], UNIT_PACKET_MARKER)
self.assertNotIn(self.packet_body, " ".join(kimi_inv.sanitized_display))
self.assertNotIn(kimi_inv.argv[-1], kimi_inv.sanitized_display)
def test_missing_opencode_is_unavailable(self) -> None:
profile = Profile(
name="opencode",
adapter="opencode",
executable="opencode",
model=None,
enabled=True,
)
cfg = _config({"opencode": profile})
# Ensure no opencode on PATH from bindir; real PATH may also lack it.
probe = probe_adapter(profile, cfg)
self.assertIsInstance(probe, AdapterProbe)
self.assertFalse(probe.available)
self.assertIsNotNone(probe.reason)
def test_fixed_model_uses_only_adapter_model_flag(self) -> None:
_write_fake_cli(self.bindir, "cursor-agent", help_text=CURSOR_HELP)
profile = Profile(
name="cursor",
adapter="cursor",
executable="cursor-agent",
model="gpt-5",
enabled=True,
)
cfg = _config({"cursor": profile})
inv = build_invocation(
profile, cfg, self.workspace, self.unit_packet, self.result_path
)
self.assertIn("--model", inv.argv)
model_idx = inv.argv.index("--model")
self.assertEqual(inv.argv[model_idx + 1], "gpt-5")
self.assertEqual(inv.argv.count("--model"), 1)
self.assertNotIn("auto", inv.argv)
_assert_bounded_prompt(self, inv.argv[-1], self.packet_body)
def test_custom_adapter_builds_direct_argv_without_shell(self) -> None:
_write_fake_cli(
self.bindir,
"mycli",
help_text="Usage: mycli --yes --cwd --model --output-format",
)
profile = Profile(
name="mycustom",
adapter="custom",
executable="mycli",
model="fixed-model",
enabled=True,
)
custom = CustomAdapter(
name="mycustom",
base_argv=("run", "${unit_packet}"),
run_everything_argv=("--yes",),
workspace_argv=("--cwd", "${workspace}"),
model_argv=("--model", "${model}"),
prompt_transport="stdin",
output_format="json",
)
cfg = _config({"mycustom": profile}, {"mycustom": custom})
inv = build_invocation(
profile, cfg, self.workspace, self.unit_packet, self.result_path
)
self.assertEqual(
inv.argv,
(
"mycli",
"run",
str(self.unit_packet),
"--yes",
"--cwd",
str(self.workspace),
"--model",
"fixed-model",
"--output-format",
"json",
),
)
self.assertEqual(inv.stdin, self.unit_packet.read_bytes())
self.assertNotIn("&&", " ".join(inv.argv))
self.assertNotIn("|", " ".join(inv.argv))
# Path placeholder is fine; body must not appear in display.
self.assertNotIn(self.packet_body.strip(), " ".join(inv.sanitized_display))
# auto model must omit the whole model_argv pair
auto_profile = Profile(
name="mycustom",
adapter="custom",
executable="mycli",
model=None,
enabled=True,
)
auto_inv = build_invocation(
auto_profile, cfg, self.workspace, self.unit_packet, self.result_path
)
self.assertNotIn("--model", auto_inv.argv)
self.assertNotIn("auto", auto_inv.argv)
# Positional custom transport may embed the bounded prompt, but display
# must use the stable marker.
positional = CustomAdapter(
name="mycustom",
base_argv=("run",),
run_everything_argv=("--yes",),
workspace_argv=("--cwd", "${workspace}"),
model_argv=("--model", "${model}"),
prompt_transport="positional",
output_format="json",
)
pos_cfg = _config({"mycustom": profile}, {"mycustom": positional})
pos_inv = build_invocation(
profile, pos_cfg, self.workspace, self.unit_packet, self.result_path
)
_assert_bounded_prompt(self, pos_inv.argv[-1], self.packet_body)
self.assertEqual(pos_inv.sanitized_display[-1], UNIT_PACKET_MARKER)
self.assertNotIn(pos_inv.argv[-1], pos_inv.sanitized_display)
def test_unverified_run_everything_capability_is_unavailable(self) -> None:
_write_fake_cli(
self.bindir,
"cursor-agent",
help_text="Usage: agent\n -p, --print\n --model <model>\n",
)
profile = Profile(
name="cursor",
adapter="cursor",
executable="cursor-agent",
model=None,
enabled=True,
)
cfg = _config({"cursor": profile})
probe = probe_adapter(profile, cfg)
self.assertFalse(probe.available)
self.assertIsNotNone(probe.reason)
self.assertRegex(probe.reason or "", r"unavailable|missing|capability|flag", re.I)
def test_rejects_oversized_and_invalid_utf8_unit_packet(self) -> None:
_write_fake_cli(self.bindir, "cursor-agent", help_text=CURSOR_HELP)
profile = Profile(
name="cursor",
adapter="cursor",
executable="cursor-agent",
model=None,
enabled=True,
)
cfg = _config({"cursor": profile})
oversized = self.root / "oversized.md"
oversized.write_bytes(b"x" * (MAX_UNIT_PACKET_BYTES + 1))
with self.assertRaises(ValueError) as oversized_ctx:
build_invocation(
profile, cfg, self.workspace, oversized, self.result_path
)
self.assertRegex(str(oversized_ctx.exception).lower(), r"size|large|bound|limit")
invalid = self.root / "invalid.md"
invalid.write_bytes(b"\xff\xfe not utf-8")
with self.assertRaises(ValueError) as utf8_ctx:
build_invocation(
profile, cfg, self.workspace, invalid, self.result_path
)
self.assertRegex(str(utf8_ctx.exception).lower(), r"utf-8|utf8|encoding|decode")
if __name__ == "__main__":
unittest.main()
-697
View File
@@ -1,697 +0,0 @@
"""Provenance admission: apply a success transport, or admit exact staged paths."""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import subprocess
import tempfile
import unittest
import uuid
from pathlib import Path
from scripts import agent_exec
SOURCE = Path(__file__).resolve().parents[1]
def _git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", str(repo), *args],
check=check,
capture_output=True,
text=True,
)
class AgentExecAdmissionTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-admit-")
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.repo = self.root / "repo"
self.repo.mkdir()
_git(self.repo, "init", "-b", "main")
_git(self.repo, "config", "user.email", "agent-exec@example.com")
_git(self.repo, "config", "user.name", "Agent Exec")
(self.repo / "README.md").write_text("fixture\n", encoding="utf-8")
_git(self.repo, "add", "README.md")
_git(self.repo, "commit", "-m", "init")
_git(self.repo, "checkout", "-b", "feature/admission-test")
self.head = _git(self.repo, "rev-parse", "HEAD").stdout.strip()
def _cli(self, *args: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["PYTHONDONTWRITEBYTECODE"] = "1"
env["PYTHONPATH"] = str(SOURCE)
return subprocess.run(
["python3", "-m", "scripts.agent_exec", *args],
cwd=str(self.root),
capture_output=True,
text=True,
env=env,
)
def _state(self) -> dict[str, str]:
return {
"head": _git(self.repo, "rev-parse", "HEAD").stdout.strip(),
"branch": _git(self.repo, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip(),
"status": _git(
self.repo, "status", "--porcelain", "--untracked-files=all"
).stdout,
"index": _git(self.repo, "ls-files", "--stage").stdout,
"readme": (self.repo / "README.md").read_text(encoding="utf-8"),
}
def _transport_commit(
self,
files: dict[str, str],
*,
parent: str | None = None,
ref: str | None = None,
) -> tuple[str, str]:
parent = parent or _git(self.repo, "rev-parse", "HEAD").stdout.strip()
ref = ref or f"refs/agent-exec/{uuid.uuid4().hex}/attempt-1"
index = self.root / f"index-{uuid.uuid4().hex}"
env = os.environ.copy()
env["GIT_INDEX_FILE"] = str(index)
subprocess.run(
["git", "-C", str(self.repo), "read-tree", parent],
check=True,
env=env,
capture_output=True,
text=True,
)
for rel, content in files.items():
blob = subprocess.run(
["git", "-C", str(self.repo), "hash-object", "-w", "--stdin"],
input=content,
text=True,
check=True,
capture_output=True,
).stdout.strip()
subprocess.run(
[
"git",
"-C",
str(self.repo),
"update-index",
"--add",
"--cacheinfo",
f"100644,{blob},{rel}",
],
check=True,
env=env,
capture_output=True,
text=True,
)
tree = subprocess.run(
["git", "-C", str(self.repo), "write-tree"],
check=True,
env=env,
capture_output=True,
text=True,
).stdout.strip()
commit = subprocess.run(
[
"git",
"-C",
str(self.repo),
"-c",
"user.name=agent-exec-transport",
"-c",
"user.email=agent-exec-transport@localhost.invalid",
"commit-tree",
tree,
"-p",
parent,
"-m",
"agent-exec transport",
],
check=True,
capture_output=True,
text=True,
).stdout.strip()
_git(self.repo, "update-ref", ref, commit)
index.unlink(missing_ok=True)
return commit, ref
def _receipt(
self,
*,
parent: str,
ref: str | None,
paths: list[str],
status: str = "success",
repo: str | None = None,
reason: str | None = None,
transport_parent: str | None = None,
omit_transport: bool = False,
run_id: str | None = None,
commit: str | None = None,
) -> Path:
data: dict[str, object] = {
"schema": agent_exec.RECEIPT_SCHEMA,
"repo": repo if repo is not None else str(self.repo.resolve()),
"terminal_status": status,
"reason": reason,
"base": {
"head": parent,
"tree": _git(self.repo, "rev-parse", f"{parent}^{{tree}}").stdout.strip(),
"branch": "feature/admission-test",
},
"changed_paths": paths,
}
if run_id is not None:
data["run_id"] = run_id
if not omit_transport:
transport: dict[str, object] = {
"ref": ref,
"parent": parent if transport_parent is None else transport_parent,
"attempt_id": "attempt-1",
}
if commit is not None:
transport["commit"] = commit
data["transport"] = transport
path = self.root / f"receipt-{uuid.uuid4().hex}.json"
path.write_text(json.dumps(data), encoding="utf-8")
return path
def _common_dir(self) -> Path:
return Path(
_git(
self.repo,
"rev-parse",
"--path-format=absolute",
"--git-common-dir",
).stdout.strip()
)
def _history_path(self) -> Path:
return self._common_dir() / "agent-exec" / "execution-history.jsonl"
def _attestation_key(self) -> bytes:
dest = self._common_dir() / "agent-exec"
dest.mkdir(mode=0o700, exist_ok=True)
os.chmod(dest, 0o700)
path = dest / "attestation.key"
if path.is_symlink():
raise RuntimeError("test attestation key must not be a symlink")
if path.exists():
key = path.read_bytes()
if len(key) != 32:
raise RuntimeError("test attestation key must be 32 bytes")
return key
key = os.urandom(32)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
try:
os.write(fd, key)
os.fchmod(fd, 0o600)
finally:
os.close(fd)
return key
@staticmethod
def _sign_history_record(record: dict[str, object], key: bytes) -> dict[str, object]:
body = {field: value for field, value in record.items() if field != "attestation"}
payload = json.dumps(
body, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
signed = dict(body)
signed["attestation"] = hmac.new(key, payload, hashlib.sha256).hexdigest()
return signed
def _history(
self,
*,
parent: str,
ref: str,
paths: list[str],
status: str = "success",
repo: str | None = None,
commit: str | None = None,
run_id: str | None = None,
sign: bool | None = None,
) -> dict[str, object]:
if commit is None:
commit = _git(self.repo, "rev-parse", f"{ref}^{{commit}}").stdout.strip()
identity = run_id or uuid.uuid4().hex
record: dict[str, object] = {
"schema": agent_exec.HISTORY_SCHEMA,
"terminal_status": status,
"record_id": identity,
"run_id": identity,
"repo": repo if repo is not None else str(self.repo.resolve()),
"changed_paths": paths,
"transport": {"ref": ref, "parent": parent, "commit": commit},
}
if sign is None:
sign = status == "success"
if sign:
record = self._sign_history_record(record, self._attestation_key())
dest = self._history_path().parent
dest.mkdir(mode=0o700, exist_ok=True)
os.chmod(dest, 0o700)
line = json.dumps(
record, ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
with self._history_path().open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
return record
def _reset_branch(self) -> None:
_git(self.repo, "reset", "--hard", self.head)
_git(self.repo, "clean", "-fd")
def _stage(self, rel: str, content: str) -> None:
path = self.repo / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
_git(self.repo, "add", "--", rel)
def test_apply_fast_forwards_clean_matching_transport(self) -> None:
commit, ref = self._transport_commit({"src/marker.txt": "shipped\n"})
run_id = uuid.uuid4().hex
signed = self._history(
parent=self.head,
ref=ref,
paths=["src/marker.txt"],
commit=commit,
run_id=run_id,
)
receipt = self._receipt(
parent=self.head,
ref=ref,
paths=["src/marker.txt"],
run_id=run_id,
commit=commit,
)
self.assertEqual(signed["transport"]["commit"], commit)
self.assertIn("attestation", signed)
proc = self._cli(
"apply", "--repo", str(self.repo), "--receipt", str(receipt)
)
self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout)
self.assertIn("apply: fast-forwarded", proc.stdout)
self.assertEqual(_git(self.repo, "rev-parse", "HEAD").stdout.strip(), commit)
self.assertEqual(
_git(self.repo, "rev-parse", "feature/admission-test").stdout.strip(),
commit,
)
self.assertEqual((self.repo / "src/marker.txt").read_text(encoding="utf-8"), "shipped\n")
self.assertEqual(_git(self.repo, "status", "--porcelain").stdout, "")
parents = _git(self.repo, "rev-list", "--parents", "-n", "1", "HEAD").stdout.split()
self.assertEqual(parents, [commit, self.head])
def test_apply_rejects_without_changing_state(self) -> None:
_commit, ref = self._transport_commit({"src/marker.txt": "shipped\n"})
valid = dict(parent=self.head, ref=ref, paths=["src/marker.txt"])
def reject(label: str, receipt: Path) -> None:
before = self._state()
proc = self._cli(
"apply", "--repo", str(self.repo), "--receipt", str(receipt)
)
self.assertEqual(
proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout
)
self.assertIn(label, proc.stderr)
self.assertNotIn("apply: fast-forwarded", proc.stdout)
self.assertEqual(self._state(), before)
with self.subTest(label="stale HEAD"):
(self.repo / "advance.txt").write_text("advance\n", encoding="utf-8")
_git(self.repo, "add", "advance.txt")
_git(self.repo, "commit", "-m", "advance")
reject("stale HEAD", self._receipt(**valid))
_git(self.repo, "reset", "--hard", self.head)
with self.subTest(label="dirty worktree"):
(self.repo / "README.md").write_text("dirty\n", encoding="utf-8")
reject("dirty worktree", self._receipt(**valid))
_git(self.repo, "checkout", "--", "README.md")
with self.subTest(label="non-success receipt"):
reject(
"non-success receipt",
self._receipt(**valid, status="failed-unknown"),
)
with self.subTest(label="missing transport ref"):
reject(
"missing transport",
self._receipt(
parent=self.head,
ref="refs/agent-exec/missing/attempt",
paths=["src/marker.txt"],
),
)
with self.subTest(label="omitted transport"):
reject(
"missing transport",
self._receipt(
parent=self.head,
ref=None,
paths=["src/marker.txt"],
omit_transport=True,
),
)
with self.subTest(label="mismatched transport"):
side = _git(
self.repo,
"commit-tree",
_git(self.repo, "rev-parse", "HEAD^{tree}").stdout.strip(),
"-p",
self.head,
"-m",
"side",
).stdout.strip()
_mismatch_commit, mismatch_ref = self._transport_commit(
{"src/marker.txt": "other\n"}, parent=side
)
reject(
"mismatched transport",
self._receipt(
parent=self.head, ref=mismatch_ref, paths=["src/marker.txt"]
),
)
with self.subTest(label="receipt repo mismatch"):
reject(
"receipt repo mismatch",
self._receipt(
**valid, repo=str((self.root / "other-repo").resolve())
),
)
def test_apply_rejects_fabricated_unsigned_or_tampered_history(self) -> None:
commit, ref = self._transport_commit({"src/marker.txt": "shipped\n"})
run_id = uuid.uuid4().hex
paths = ["src/marker.txt"]
def reject(label: str) -> None:
self._reset_branch()
before = self._state()
receipt = self._receipt(
parent=self.head,
ref=ref,
paths=paths,
run_id=run_id,
commit=commit,
)
proc = self._cli(
"apply", "--repo", str(self.repo), "--receipt", str(receipt)
)
combined = proc.stdout + proc.stderr
self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, combined)
self.assertIn("apply: no local integrity record", proc.stderr)
self.assertNotIn("apply: fast-forwarded", proc.stdout)
self.assertEqual(self._state(), before, label)
with self.subTest(label="fabricated receipt"):
reject("fabricated receipt")
with self.subTest(label="unsigned history"):
self._history(
parent=self.head,
ref=ref,
paths=paths,
commit=commit,
run_id=run_id,
sign=False,
)
reject("unsigned history")
with self.subTest(label="tampered history"):
self._reset_branch()
signed = self._history(
parent=self.head,
ref=ref,
paths=paths,
commit=commit,
run_id=run_id,
)
history_path = self._history_path()
lines = history_path.read_text(encoding="utf-8").splitlines()
tampered = json.loads(lines[-1])
self.assertEqual(tampered["attestation"], signed["attestation"])
tampered["record_id"] = "tampered-record"
lines[-1] = json.dumps(
tampered, ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
history_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
reject("tampered history")
def test_apply_redacts_secret_shaped_receipt_text(self) -> None:
_commit, ref = self._transport_commit({"src/marker.txt": "shipped\n"})
secret = "sk-admissionsecret"
receipt = self._receipt(
parent=self.head,
ref=ref,
paths=["src/marker.txt"],
status="failed-unknown",
reason=secret,
)
before = self._state()
proc = self._cli("apply", "--repo", str(self.repo), "--receipt", str(receipt))
combined = proc.stdout + proc.stderr
self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, combined)
self.assertIn("non-success receipt", proc.stderr)
self.assertNotIn(secret, combined)
self.assertIn("***", combined)
self.assertEqual(self._state(), before)
def test_admit_rejects_staged_paths_without_provenance(self) -> None:
self._stage("src/extra.txt", "unproven\n")
before = self._state()
proc = self._cli("admit", "--repo", str(self.repo))
self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout)
self.assertIn("admit: no local integrity record", proc.stderr)
self.assertEqual(self._state(), before)
def test_admit_accepts_exact_transport_paths(self) -> None:
_commit, ref = self._transport_commit(
{"src/marker.txt": "shipped\n", "src/other.txt": "also\n"}
)
self._history(
parent=self.head,
ref=ref,
paths=["src/marker.txt", "src/other.txt"],
)
self._stage("src/marker.txt", "shipped\n")
self._stage("src/other.txt", "also\n")
self._stage(".task/feature-admission.md", "host card\n")
before = self._state()
proc = self._cli("admit", "--repo", str(self.repo))
self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout)
self.assertIn("admit: ok", proc.stdout)
self.assertEqual(self._state(), before)
self._git_reset_index()
self._stage("src/marker.txt", "shipped\n")
partial = self._cli("admit", "--repo", str(self.repo))
self.assertEqual(partial.returncode, agent_exec.EXIT_BLOCKED, partial.stderr)
self.assertIn("admit: no local integrity record", partial.stderr)
def test_admit_rejects_tampered_record_and_moved_ref(self) -> None:
commit, ref = self._transport_commit({"src/marker.txt": "shipped\n"})
signed = self._history(parent=self.head, ref=ref, paths=["src/marker.txt"], commit=commit)
history_path = self._history_path()
lines = history_path.read_text(encoding="utf-8").splitlines()
tampered = json.loads(lines[-1])
self.assertEqual(tampered["attestation"], signed["attestation"])
tampered["record_id"] = "tampered-record"
lines[-1] = json.dumps(
tampered, ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
history_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
self._stage("src/marker.txt", "shipped\n")
before = self._state()
proc = self._cli("admit", "--repo", str(self.repo))
combined = proc.stdout + proc.stderr
self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, combined)
self.assertIn("admit: no local integrity record", proc.stderr)
self.assertNotIn("admit: ok", proc.stdout)
self.assertEqual(self._state(), before)
self._reset_branch()
tree = _git(self.repo, "rev-parse", f"{commit}^{{tree}}").stdout.strip()
moved = _git(
self.repo,
"commit-tree",
tree,
"-p",
self.head,
"-m",
"moved ref",
).stdout.strip()
self.assertNotEqual(moved, commit)
restored = self._sign_history_record(
{
"schema": agent_exec.HISTORY_SCHEMA,
"terminal_status": "success",
"record_id": signed["record_id"],
"run_id": signed["run_id"],
"repo": str(self.repo.resolve()),
"changed_paths": ["src/marker.txt"],
"transport": {"commit": commit, "parent": self.head, "ref": ref},
},
self._attestation_key(),
)
history_path.write_text(
json.dumps(
restored, ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
+ "\n",
encoding="utf-8",
)
_git(self.repo, "update-ref", ref, moved)
self._stage("src/marker.txt", "shipped\n")
moved_before = self._state()
moved_proc = self._cli("admit", "--repo", str(self.repo))
moved_text = moved_proc.stdout + moved_proc.stderr
self.assertEqual(moved_proc.returncode, agent_exec.EXIT_BLOCKED, moved_text)
self.assertIn("admit: no local integrity record", moved_proc.stderr)
self.assertNotIn("admit: ok", moved_proc.stdout)
self.assertEqual(self._state(), moved_before)
self.assertEqual(_git(self.repo, "rev-parse", ref).stdout.strip(), moved)
def test_admit_rejects_different_content_at_transport_path(self) -> None:
_commit, ref = self._transport_commit({"src/marker.txt": "shipped\n"})
self._history(parent=self.head, ref=ref, paths=["src/marker.txt"])
self._stage("src/marker.txt", "rewritten\n")
before = self._state()
proc = self._cli("admit", "--repo", str(self.repo))
self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout)
self.assertIn("admit: no local integrity record", proc.stderr)
self.assertEqual(self._state(), before)
def test_rename_and_copy_records_keep_non_exempt_source(self) -> None:
"""Copy/rename records must not drop a non-exempt source behind an exempt dest."""
paths = agent_exec._name_status_paths(
"R100\tsrc/secret.txt\t.task/from-rename.md\n"
"C100\tlib/outside.txt\tdocs/superpowers/plans/from-copy.md\n"
)
self.assertEqual(
agent_exec._non_exempt(paths),
{"src/secret.txt", "lib/outside.txt"},
)
def test_admit_rejects_rename_of_non_exempt_source_into_exempt_path(self) -> None:
(self.repo / "src").mkdir()
(self.repo / "src" / "secret.txt").write_text("secret\n", encoding="utf-8")
_git(self.repo, "add", "src/secret.txt")
_git(self.repo, "commit", "-m", "add secret")
(self.repo / ".task").mkdir()
_git(self.repo, "mv", "src/secret.txt", ".task/hidden.md")
before = self._state()
proc = self._cli("admit", "--repo", str(self.repo))
combined = proc.stdout + proc.stderr
self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, combined)
self.assertIn("src/secret.txt", proc.stderr)
self.assertNotIn("admit: ok", proc.stdout)
self.assertEqual(self._state(), before)
def test_admit_accepts_host_managed_exemptions(self) -> None:
self._stage(".task/feature-admission.md", "card\n")
self._stage("docs/superpowers/specs/admission.md", "spec\n")
self._stage("docs/superpowers/plans/admission.md", "plan\n")
before = self._state()
proc = self._cli("admit", "--repo", str(self.repo))
self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout)
self.assertIn("admit: ok", proc.stdout)
self.assertEqual(self._state(), before)
self._stage("docs/superpowers/specs-extra/not-exempt.md", "nope\n")
rejected = self._cli("admit", "--repo", str(self.repo))
self.assertEqual(rejected.returncode, agent_exec.EXIT_BLOCKED, rejected.stderr)
self.assertIn("admit: no local integrity record", rejected.stderr)
def test_whitespace_paths_are_classified_exactly(self) -> None:
non_exempt = (
" .task/card.md",
"\t.task/card.md",
" docs/superpowers/specs/a.md",
"docs/superpowers/specs /a.md",
"docs/superpowers/plans /a.md",
".task /card.md",
)
for path in non_exempt:
with self.subTest(path=path):
self.assertFalse(agent_exec._host_managed(path))
self.assertEqual(agent_exec._non_exempt([path]), {path})
for path in (
".task/card.md ",
"docs/superpowers/specs/a.md ",
"docs/superpowers/plans/ a.md",
):
with self.subTest(path=path):
self.assertTrue(agent_exec._host_managed(path))
self.assertEqual(
agent_exec._non_exempt(["src/a.txt ", " src/b.txt"]),
{"src/a.txt ", " src/b.txt"},
)
self.assertEqual(
agent_exec._name_status_paths("A\t .task/card.md\nA\tsrc/a.txt \n"),
[" .task/card.md", "src/a.txt "],
)
def test_admit_rejects_whitespace_lookalikes_of_exempt_paths(self) -> None:
for path in (
" .task/card.md",
"docs/superpowers/specs /a.md",
"docs/superpowers/plans /a.md",
):
with self.subTest(path=path):
self._reset_branch()
self._stage(path, "lookalike\n")
before = self._state()
proc = self._cli("admit", "--repo", str(self.repo))
combined = proc.stdout + proc.stderr
self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, combined)
self.assertIn(f"admit: no local integrity record for {path}", proc.stderr)
self.assertNotIn("admit: ok", proc.stdout)
self.assertEqual(self._state(), before)
def test_admit_covers_whitespace_path_only_with_exact_transport(self) -> None:
exact = "src/marker.txt "
_commit, ref = self._transport_commit({exact: "shipped\n"})
self._history(parent=self.head, ref=ref, paths=[exact])
self._stage(exact, "shipped\n")
before = self._state()
proc = self._cli("admit", "--repo", str(self.repo))
self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout)
self.assertIn("admit: ok", proc.stdout)
self.assertEqual(self._state(), before)
self._reset_branch()
self._history_path().unlink()
_commit, trimmed_ref = self._transport_commit({"src/marker.txt": "shipped\n"})
self._history(parent=self.head, ref=trimmed_ref, paths=["src/marker.txt"])
self._stage(exact, "shipped\n")
rejected = self._cli("admit", "--repo", str(self.repo))
self.assertEqual(rejected.returncode, agent_exec.EXIT_BLOCKED, rejected.stderr)
self.assertIn("admit: no local integrity record", rejected.stderr)
def test_make_agent_exec_apply_requires_receipt(self) -> None:
env = os.environ.copy()
env.pop("RECEIPT", None)
proc = subprocess.run(
["make", "agent-exec-apply"],
cwd=str(SOURCE),
capture_output=True,
text=True,
env=env,
)
combined = proc.stdout + proc.stderr
self.assertNotEqual(proc.returncode, 0, combined)
self.assertIn("RECEIPT", combined)
self.assertNotIn("apply: fast-forwarded", combined)
def _git_reset_index(self) -> None:
_git(self.repo, "reset", "--hard", self.head)
_git(self.repo, "clean", "-fd")
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -841,7 +841,7 @@ class TaskLifecycleTests(unittest.TestCase):
self.assertIn("不因推理时长、工具次数、规划方式或首次写文件时间判定失败", agent_receipts)
self.assertIn("stdin/stdout/stderr 均无活动的空闲超时", agent_receipts)
self.assertIn("自主检查、计划、测试并迭代", agent_receipts)
self.assertIn("scripts/agent_exec.py apply", agent_receipts)
self.assertIn("scripts/agent-exec apply", agent_receipts)
self.assertIn("pre-commit admit", agent_receipts)
self.assertIn(".task/**", agent_receipts)
self.assertIn("docs/superpowers/specs/**", agent_receipts)