diff --git a/AGENTS.md b/AGENTS.md index b1858643..ae3422fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/**` 与 diff --git a/policy/adoption.md b/policy/adoption.md index 0161f932..acfea99a 100644 --- a/policy/adoption.md +++ b/policy/adoption.md @@ -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 指针已就位) | diff --git a/policy/delivery.md b/policy/delivery.md index 6300861e..67bde148 100644 --- a/policy/delivery.md +++ b/policy/delivery.md @@ -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、可信身份或防恶意主体的保护。本地记录缺失或无效 diff --git a/policy/governance.md b/policy/governance.md index a1186973..30985805 100644 --- a/policy/governance.md +++ b/policy/governance.md @@ -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/**` diff --git a/policy/tool-adaptation-matrix.md b/policy/tool-adaptation-matrix.md index 296a4135..b5efa38f 100644 --- a/policy/tool-adaptation-matrix.md +++ b/policy/tool-adaptation-matrix.md @@ -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` 固定对象、权限、覆盖拒绝、原样导入与幂等 | diff --git a/scripts/agent_exec.py b/scripts/agent_exec.py deleted file mode 100644 index d2aca91e..00000000 --- a/scripts/agent_exec.py +++ /dev/null @@ -1,2739 +0,0 @@ -"""Recoverable agent-exec controller: validate, run, apply transports, admit staged content. - -Local history MACs and the pre-commit ``admit`` check are local integrity and -tamper evidence only (SELF). They are not GATE, not a trusted identity, and do -not protect against the same OS user. Delivery trust comes from exact-HEAD CI -or an independent REPLAY. -""" -from __future__ import annotations - -import argparse -import contextlib -import errno -import fcntl -import hashlib -import hmac -import json -import os -import re -import select -import shutil -import signal -import stat -import subprocess -import sys -import time -import uuid -from collections.abc import Iterator -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -OUTPUT_TAIL_BYTES = 65536 - -_REPO_ROOT = Path(__file__).resolve().parents[1] -if str(_REPO_ROOT) not in sys.path: - sys.path.insert(0, str(_REPO_ROOT)) - -from scripts.agent_exec_adapters import ( - build_invocation, - decode_terminal_record, - probe_adapter, -) -from scripts.agent_exec_config import ( - DEFAULT_IDLE_TIMEOUT_SECONDS, - SECRET_VALUE_RE, - ConfigError, - ExecutionConfig, - Profile, - load_config, - validate_config, -) - -RECEIPT_SCHEMA = "agent-exec-receipt/v1" -HISTORY_SCHEMA = "agent-exec-history/v1" - -# Distinct stable exit codes. -EXIT_SUCCESS = 0 -EXIT_INVALID_CONFIG = 2 -EXIT_UNAVAILABLE_EXHAUSTION = 3 -EXIT_BLOCKED = 4 -EXIT_SCOPE_EXPANSION = 5 -EXIT_CANCELLED = 6 -EXIT_HOST_FALLBACK = 10 - -_EMERGENCY_ROLES = frozenset({"implementation", "repair", "test"}) -_SAFE_EXHAUST_STATUSES = frozenset({"unavailable", "failed-clean"}) - -# Transport commits must not depend on machine-global Git identity. -TRANSPORT_AUTHOR_NAME = "agent-exec-transport" -TRANSPORT_AUTHOR_EMAIL = "agent-exec-transport@localhost.invalid" - -DEFAULT_BRANCHES = frozenset({"main", "master"}) -SECRET_PATH_MARKERS = ( - ".secrets", - "credentials", - "id_rsa", - "id_ed25519", - ".env", - "ACCESS_TOKEN", -) -ENV_ALLOW_PREFIXES = ("LC_",) -ENV_ALLOW_EXACT = frozenset( - { - "PATH", - "HOME", - "USER", - "LOGNAME", - "LANG", - "LANGUAGE", - "TMPDIR", - "TMP", - "TEMP", - "TERM", - "TZ", - "XDG_RUNTIME_DIR", - "XDG_CACHE_HOME", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "REQUESTS_CA_BUNDLE", - "CURL_CA_BUNDLE", - "HTTP_PROXY", - "HTTPS_PROXY", - "NO_PROXY", - "http_proxy", - "https_proxy", - "no_proxy", - "ALL_PROXY", - "all_proxy", - # Provider discovery names only — values never enter receipts. - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", - "CURSOR_API_KEY", - "AGENT_EXEC_TEST_SECRET", - } -) - -UNIT_REQUIRED = ( - "unit_id", - "goal", - "allowed_paths", - "verification", - "constraints", - "network", - "external_cost", - "timeout_seconds", -) - - -class UnitPacketError(ValueError): - """Raised when a unit packet fails contract checks.""" - - -@dataclass(frozen=True) -class UnitPacket: - unit_id: str - goal: str - allowed_paths: tuple[str, ...] - verification: tuple[Any, ...] - constraints: tuple[Any, ...] - network: bool - external_cost: bool - timeout_seconds: int - raw: dict[str, Any] - digest: str - - -@dataclass -class AttemptRecord: - attempt_id: str - profile: str - adapter: str - terminal_status: str - reason: str | None = None - worktree: str | None = None - pid: int | None = None - exit_code: int | None = None - timed_out: bool = False - idle_timed_out: bool = False - signal: int | None = None - changed_paths: list[str] = field(default_factory=list) - inventory_paths: list[str] = field(default_factory=list) - raw_log: str | None = None - summary: str = "" - cleanup: dict[str, Any] = field(default_factory=dict) - workspace_preserved: bool = False - transport_ref: str | None = None - observed_version: str | None = None - attempt_ordinal: int = 1 - emergency_fallback: bool = False - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="agent_exec") - sub = parser.add_subparsers(dest="command", required=True) - - p_val = sub.add_parser("validate", help="Validate AGENT-EXECUTION.md (no provider calls)") - p_val.add_argument("--repo", required=True) - - p_run = sub.add_parser("run", help="Run one bounded unit through configured profiles") - p_run.add_argument("--repo", required=True) - p_run.add_argument("--role", required=True) - p_run.add_argument("--unit", required=True) - p_run.add_argument("--receipt", required=True) - - p_apply = sub.add_parser( - "apply", - help="Fast-forward the current branch to a success transport", - ) - p_apply.add_argument("--repo", required=True) - p_apply.add_argument("--receipt", required=True) - - p_admit = sub.add_parser( - "admit", - help=( - "Locally check staged paths against a success transport or host " - "exemption (local integrity evidence, not GATE)" - ), - ) - p_admit.add_argument("--repo", required=True) - - args = parser.parse_args(argv) - if args.command == "validate": - return cmd_validate(Path(args.repo)) - if args.command == "run": - return cmd_run( - Path(args.repo), - role=args.role, - unit_path=Path(args.unit), - receipt_path=Path(args.receipt), - ) - if args.command == "apply": - return cmd_apply(Path(args.repo), Path(args.receipt)) - if args.command == "admit": - return cmd_admit(Path(args.repo)) - return EXIT_INVALID_CONFIG - - -def cmd_validate(repo: Path) -> int: - try: - cfg = load_config(repo) - validate_config(repo) - except ConfigError as exc: - print(f"invalid-config: {exc}", file=sys.stderr) - return EXIT_INVALID_CONFIG - # Local capability probes only — never contact a provider. - for name, profile in cfg.profiles.items(): - if not profile.enabled: - continue - probe_adapter(profile, cfg) - print("validate: ok") - return EXIT_SUCCESS - - -def _apply_attempt_route( - attempt: AttemptRecord, - *, - attempt_id: str, - parent: str, - terminal: str, - reason: str | None, - changed_paths: list[str], - transport: dict[str, Any] | None, -) -> tuple[str, str, str | None, list[str], dict[str, Any] | None]: - status = attempt.terminal_status - if status == "success": - return ( - "stop", - "success", - None, - list(attempt.changed_paths), - { - "ref": attempt.transport_ref, - "parent": parent, - "attempt_id": attempt_id, - }, - ) - if status in _SAFE_EXHAUST_STATUSES: - return ("continue", "unavailable", attempt.reason, changed_paths, transport) - if status == "scope-expansion": - return ( - "stop", - "scope-expansion", - attempt.reason, - list(attempt.changed_paths), - transport, - ) - if status in {"failed-unknown", "blocked"}: - return ("stop", status, attempt.reason, changed_paths, transport) - if status == "cancelled": - return ("stop", "cancelled", attempt.reason, changed_paths, transport) - return ("next-profile", terminal, reason, changed_paths, transport) - - -def cmd_run( - repo: Path, - *, - role: str, - unit_path: Path, - receipt_path: Path, -) -> int: - started_monotonic = time.monotonic() - run_id = uuid.uuid4().hex - attempts: list[AttemptRecord] = [] - secret_values: set[str] = set() - _collect_secret_values(secret_values) - - try: - cfg = load_config(repo) - except ConfigError as exc: - receipt = _base_receipt( - run_id=run_id, - repo=repo, - role=role, - unit_id=None, - config=None, - terminal_status="invalid-config", - reason=str(exc), - attempts=[], - secret_values=secret_values, - ) - return _finish_run( - repo=repo, - receipt_path=receipt_path, - receipt=receipt, - secret_values=secret_values, - summary_bytes=256, - exit_code=EXIT_INVALID_CONFIG, - started_monotonic=started_monotonic, - ) - - try: - base = _require_canonical_ready(repo) - unit = load_unit_packet(unit_path, cfg) - profiles = _select_profiles(cfg, role) - except (UnitPacketError, ConfigError, RuntimeError, ValueError) as exc: - receipt = _base_receipt( - run_id=run_id, - repo=repo, - role=role, - unit_id=None, - config=cfg, - terminal_status="invalid-config", - reason=str(exc), - attempts=[], - secret_values=secret_values, - ) - return _finish_run( - repo=repo, - receipt_path=receipt_path, - receipt=receipt, - secret_values=secret_values, - summary_bytes=cfg.execution.summary_bytes, - exit_code=EXIT_INVALID_CONFIG, - started_monotonic=started_monotonic, - ) - - try: - _validate_allowed_path_symlinks(repo, unit.allowed_paths) - except UnitPacketError as exc: - # Symlink escape is a safety failure: stop routing, never authorize fallback. - receipt = _base_receipt( - run_id=run_id, - repo=repo, - role=role, - unit_id=unit.unit_id, - config=cfg, - terminal_status="failed-unknown", - reason=str(exc), - attempts=[], - secret_values=secret_values, - ) - return _finish_run( - repo=repo, - receipt_path=receipt_path, - receipt=receipt, - secret_values=secret_values, - summary_bytes=cfg.execution.summary_bytes, - exit_code=EXIT_BLOCKED, - started_monotonic=started_monotonic, - ) - - run_dir = _create_private_run_dir(run_id) - config_digest = _file_digest(repo / "AGENT-EXECUTION.md") - - terminal = "unavailable" - reason: str | None = None - transport: dict[str, Any] | None = None - changed_paths: list[str] = [] - host_scope: dict[str, Any] | None = None - host_fallback_authorizations = 0 - stop = False - - max_attempts = cfg.execution.max_attempts_per_profile - for profile_name in profiles: - if stop: - break - profile = cfg.profiles[profile_name] - for attempt_ordinal in range(1, max_attempts + 1): - attempt_id = uuid.uuid4().hex[:12] - attempt = _run_attempt( - repo=repo, - cfg=cfg, - profile=profile, - unit=unit, - unit_path=unit_path, - base=base, - run_id=run_id, - attempt_id=attempt_id, - run_dir=run_dir, - secret_values=secret_values, - ) - attempt.attempt_ordinal = attempt_ordinal - attempt.emergency_fallback = False - attempts.append(attempt) - action, terminal, reason, changed_paths, transport = _apply_attempt_route( - attempt, - attempt_id=attempt_id, - parent=base["head"], - terminal=terminal, - reason=reason, - changed_paths=changed_paths, - transport=transport, - ) - if action == "continue": - continue - stop = action == "stop" - break - - exhausted_ok = ( - not stop - and attempts - and all(a.terminal_status in _SAFE_EXHAUST_STATUSES for a in attempts) - and len(attempts) == len(profiles) * max_attempts - ) - emergency_name = cfg.execution.emergency_profile - if exhausted_ok and emergency_name and role in _EMERGENCY_ROLES: - emergency_profile = cfg.profiles.get(emergency_name) - if emergency_profile is None: - terminal = "blocked" - reason = f"emergency profile {emergency_name!r} is not configured" - else: - attempt_id = uuid.uuid4().hex[:12] - attempt = _run_attempt( - repo=repo, - cfg=cfg, - profile=emergency_profile, - unit=unit, - unit_path=unit_path, - base=base, - run_id=run_id, - attempt_id=attempt_id, - run_dir=run_dir, - secret_values=secret_values, - ) - attempt.attempt_ordinal = 1 - attempt.emergency_fallback = True - attempts.append(attempt) - action, terminal, reason, changed_paths, transport = _apply_attempt_route( - attempt, - attempt_id=attempt_id, - parent=base["head"], - terminal=terminal, - reason=reason, - changed_paths=changed_paths, - transport=transport, - ) - if action == "continue": - terminal = "blocked" - reason = attempt.reason - - # No host write authority after exhaustion. Emergency safe-failure is - # `blocked`; exhaustion without an emergency candidate stays `unavailable`. - summary = _bounded_summary( - _build_run_summary(terminal, attempts, reason), - cfg.execution.summary_bytes, - secret_values, - ) - receipt = _base_receipt( - run_id=run_id, - repo=repo, - role=role, - unit_id=unit.unit_id, - config=cfg, - terminal_status=terminal, - reason=reason, - attempts=attempts, - secret_values=secret_values, - ) - receipt.update( - { - "unit_digest": unit.digest, - "config_digest": config_digest, - "base": {"head": base["head"], "tree": base["tree"], "branch": base["branch"]}, - "summary": summary, - "changed_paths": changed_paths, - "transport": transport, - "host_scope": host_scope, - "host_fallback_authorizations": host_fallback_authorizations, - "run_dir": str(run_dir), - "private_root_mode": oct(run_dir.parent.stat().st_mode & 0o777), - } - ) - return _finish_run( - repo=repo, - receipt_path=receipt_path, - receipt=receipt, - secret_values=secret_values, - summary_bytes=cfg.execution.summary_bytes, - exit_code=_exit_for_terminal(terminal), - started_monotonic=started_monotonic, - ) - - -def load_unit_packet(path: Path, cfg: ExecutionConfig) -> UnitPacket: - try: - raw_text = path.read_text(encoding="utf-8") - except OSError as exc: - raise UnitPacketError(f"unable to read unit packet: {exc}") from exc - try: - raw = json.loads(raw_text) - except json.JSONDecodeError as exc: - raise UnitPacketError(f"unit packet is not JSON: {exc}") from exc - if not isinstance(raw, dict): - raise UnitPacketError("unit packet must be an object") - missing = [k for k in UNIT_REQUIRED if k not in raw] - if missing: - raise UnitPacketError(f"missing unit packet fields: {missing}") - - unit_id = raw["unit_id"] - goal = raw["goal"] - if not isinstance(unit_id, str) or not unit_id.strip(): - raise UnitPacketError("unit_id must be a non-empty string") - if not isinstance(goal, str) or not goal.strip(): - raise UnitPacketError("goal must be a non-empty string") - - allowed = raw["allowed_paths"] - if not isinstance(allowed, list) or not allowed: - raise UnitPacketError("allowed_paths must be a non-empty list") - paths: list[str] = [] - for item in allowed: - if not isinstance(item, str) or not item.strip(): - raise UnitPacketError("allowed_paths entries must be non-empty strings") - _validate_allowed_path(item) - paths.append(item) - - for key in ("verification", "constraints"): - if not isinstance(raw[key], list): - raise UnitPacketError(f"{key} must be a list") - for key in ("network", "external_cost"): - if type(raw[key]) is not bool: - raise UnitPacketError(f"{key} must be a boolean") - - timeout = raw["timeout_seconds"] - if type(timeout) is not int or isinstance(timeout, bool) or timeout < 1: - raise UnitPacketError("timeout_seconds must be a positive integer") - if timeout > cfg.execution.timeout_seconds: - raise UnitPacketError( - f"timeout_seconds {timeout} exceeds project policy " - f"{cfg.execution.timeout_seconds}" - ) - - digest = hashlib.sha256(raw_text.encode("utf-8")).hexdigest() - return UnitPacket( - unit_id=unit_id.strip(), - goal=goal.strip(), - allowed_paths=tuple(paths), - verification=tuple(raw["verification"]), - constraints=tuple(raw["constraints"]), - network=bool(raw["network"]), - external_cost=bool(raw["external_cost"]), - timeout_seconds=timeout, - raw=raw, - digest=digest, - ) - - -def _validate_allowed_path(path: str) -> None: - if path.startswith("/") or path.startswith("~"): - raise UnitPacketError(f"absolute allowed path rejected: {path!r}") - parts = Path(path).parts - if ".." in parts: - raise UnitPacketError(f"parent traversal rejected: {path!r}") - if ".git" in parts or path == ".git" or path.startswith(".git/"): - raise UnitPacketError(f".git path rejected: {path!r}") - lowered = path.lower() - for marker in SECRET_PATH_MARKERS: - if marker.lower() in lowered: - raise UnitPacketError(f"secret-looking path rejected: {path!r}") - - -def _select_profiles(cfg: ExecutionConfig, role: str) -> list[str]: - if role not in cfg.roles: - raise ConfigError(f"unknown role: {role!r}") - names: list[str] = [] - for name in cfg.roles[role]: - profile = cfg.profiles[name] - if not profile.enabled: - continue - names.append(name) - if not names: - raise ConfigError(f"role {role!r} has no enabled profiles") - return names - - -def _require_canonical_ready(repo: Path) -> dict[str, str]: - repo = repo.resolve() - if not (repo / ".git").exists(): - raise RuntimeError(f"not a git repository: {repo}") - status = _git(repo, "status", "--porcelain") - if status.stdout.strip(): - raise RuntimeError("canonical checkout is dirty") - branch = _git(repo, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() - if branch in DEFAULT_BRANCHES: - raise RuntimeError(f"canonical checkout must not be on default branch {branch}") - if branch == "HEAD": - # Detached HEAD is acceptable only as a fixed base; still ok for tests that - # check out a branch. Prefer named non-default branches. - pass - head = _git(repo, "rev-parse", "HEAD").stdout.strip() - tree = _git(repo, "rev-parse", "HEAD^{tree}").stdout.strip() - return {"head": head, "tree": tree, "branch": branch} - - -def _create_private_run_dir(run_id: str) -> Path: - root = Path(f"/tmp/agent-exec-{os.getuid()}") - if root.exists(): - if root.is_symlink(): - raise RuntimeError(f"private root must not be a symlink: {root}") - if not root.is_dir(): - raise RuntimeError(f"private root is not a directory: {root}") - st = root.stat() - if st.st_uid != os.getuid(): - raise RuntimeError(f"private root owned by another user: {root}") - os.chmod(root, 0o700) - else: - root.mkdir(mode=0o700) - os.chmod(root, 0o700) - run_dir = root / run_id - run_dir.mkdir(mode=0o700) - os.chmod(run_dir, 0o700) - if run_dir.is_symlink(): - raise RuntimeError(f"run dir must not be a symlink: {run_dir}") - return run_dir - - -def _create_private_attempt_clone(repo: Path, dest: Path, base_head: str) -> None: - """Create a private local shared clone with isolated Git administration. - - Objects are reused read-only via ``git clone --shared`` alternates. The clone - owns its own ``.git`` config, refs, index, HEAD, and worktree. Never contacts - a remote. - """ - if dest.exists(): - shutil.rmtree(dest) - proc = subprocess.run( - [ - "git", - "clone", - "--shared", - "--no-checkout", - "--", - str(repo.resolve()), - str(dest), - ], - check=False, - capture_output=True, - text=True, - ) - if proc.returncode != 0: - raise subprocess.CalledProcessError( - proc.returncode, proc.args, proc.stdout, proc.stderr - ) - # Drop the auto-configured origin so the attempt cannot reach a remote URL. - _git(dest, "remote", "remove", "origin", check=False) - _git(dest, "checkout", "--detach", base_head) - - -def _run_attempt( - *, - repo: Path, - cfg: ExecutionConfig, - profile: Profile, - unit: UnitPacket, - unit_path: Path, - base: dict[str, str], - run_id: str, - attempt_id: str, - run_dir: Path, - secret_values: set[str], -) -> AttemptRecord: - probe = probe_adapter(profile, cfg) - if not probe.available: - return AttemptRecord( - attempt_id=attempt_id, - profile=profile.name, - adapter=profile.adapter, - terminal_status="unavailable", - reason=probe.reason or "adapter unavailable", - observed_version=probe.observed_version, - cleanup={"exact_restore": True, "skipped": True}, - ) - - worktree = run_dir / f"wt-{attempt_id}" - try: - _create_private_attempt_clone(repo, worktree, base["head"]) - except subprocess.CalledProcessError as exc: - err = (exc.stderr or str(exc)).strip() - return AttemptRecord( - attempt_id=attempt_id, - profile=profile.name, - adapter=profile.adapter, - terminal_status="unavailable", - reason=f"private clone create failed: {err}", - cleanup={"exact_restore": True, "skipped": True}, - observed_version=probe.observed_version, - ) - except OSError as exc: - return AttemptRecord( - attempt_id=attempt_id, - profile=profile.name, - adapter=profile.adapter, - terminal_status="unavailable", - reason=f"private clone create failed: {exc}", - cleanup={"exact_restore": True, "skipped": True}, - observed_version=probe.observed_version, - ) - - result_path = run_dir / f"result-{attempt_id}.json" - packet_copy = run_dir / f"unit-{attempt_id}.json" - shutil.copy2(unit_path, packet_copy) - raw_log = run_dir / f"raw-{attempt_id}.log" - - try: - invocation = build_invocation( - profile, cfg, worktree, packet_copy, result_path - ) - except ValueError as exc: - _safe_remove_worktree(repo, worktree) - return AttemptRecord( - attempt_id=attempt_id, - profile=profile.name, - adapter=profile.adapter, - terminal_status="unavailable", - reason=str(exc), - worktree=str(worktree), - cleanup={"exact_restore": True}, - observed_version=probe.observed_version, - ) - - try: - _validate_allowed_path_symlinks(worktree, unit.allowed_paths) - except UnitPacketError as exc: - _safe_remove_worktree(repo, worktree) - return AttemptRecord( - attempt_id=attempt_id, - profile=profile.name, - adapter=profile.adapter, - terminal_status="failed-unknown", - reason=str(exc), - worktree=str(worktree), - cleanup={"exact_restore": True, "reason": "symlink escape before launch"}, - observed_version=probe.observed_version, - ) - - admin_before = _git_admin_snapshot(worktree, repo) - pointer_before = _worktree_pointer_snapshot(worktree, require_git=True) - canonical_before = _canonical_checkout_snapshot(repo) - timeout = min(unit.timeout_seconds, cfg.execution.timeout_seconds) - idle_timeout = min(cfg.execution.idle_timeout_seconds, timeout) - proc_meta = _supervise( - invocation.argv, - cwd=invocation.cwd, - stdin=invocation.stdin, - timeout=timeout, - idle_timeout=idle_timeout, - raw_log=raw_log, - raw_cap=cfg.execution.raw_output_bytes, - secret_values=secret_values, - ) - - changed: list[str] = [] - inventory: dict[str, Any] = {"paths": [], "details": []} - try: - pointer_after = _worktree_pointer_snapshot(worktree, require_git=True) - admin_after = _git_admin_snapshot(worktree, repo) - canonical_after = _canonical_checkout_snapshot(repo) - inventory = _inventory_changes(worktree) - changed = list(inventory["paths"]) - except Exception as exc: - return AttemptRecord( - attempt_id=attempt_id, - profile=profile.name, - adapter=profile.adapter, - terminal_status="failed-unknown", - reason=f"post-launch git inspection failed: {exc}", - worktree=str(worktree), - pid=proc_meta["pid"], - exit_code=proc_meta["exit_code"], - timed_out=proc_meta["timed_out"], - idle_timed_out=bool(proc_meta.get("idle_timed_out")), - signal=proc_meta["signal"], - changed_paths=[], - inventory_paths=[], - raw_log=str(raw_log), - summary=_bounded_summary( - proc_meta.get("summary_source") or str(exc), - cfg.execution.summary_bytes, - secret_values, - ), - workspace_preserved=True, - cleanup={ - "exact_restore": False, - "reason": "post-launch snapshot or inventory failure", - "error": str(exc), - "stdout_tail_bytes": len(proc_meta.get("stdout_tail") or ""), - "stderr_tail_bytes": len(proc_meta.get("stderr_tail") or ""), - }, - observed_version=probe.observed_version, - ) - - admin_delta = _git_admin_delta(admin_before, admin_after) - pointer_delta = _snapshot_delta(pointer_before, pointer_after) - canonical_delta = _snapshot_delta(canonical_before, canonical_after) - - worker_result = _load_worker_result(profile.adapter, result_path, proc_meta) - summary = _bounded_summary( - proc_meta.get("summary_source") or (worker_result or {}).get("summary") or "", - cfg.execution.summary_bytes, - secret_values, - ) - - def _preserved( - status: str, - reason: str, - *, - exact_restore: bool = False, - cleanup_extra: dict[str, Any] | None = None, - ) -> AttemptRecord: - cleanup: dict[str, Any] = {"exact_restore": exact_restore} - if cleanup_extra: - cleanup.update(cleanup_extra) - cleanup.setdefault( - "stdout_tail_bytes", len(proc_meta.get("stdout_tail") or "") - ) - cleanup.setdefault( - "stderr_tail_bytes", len(proc_meta.get("stderr_tail") or "") - ) - return AttemptRecord( - attempt_id=attempt_id, - profile=profile.name, - adapter=profile.adapter, - terminal_status=status, - reason=reason, - worktree=str(worktree), - pid=proc_meta["pid"], - exit_code=proc_meta["exit_code"], - timed_out=proc_meta["timed_out"], - idle_timed_out=bool(proc_meta.get("idle_timed_out")), - signal=proc_meta["signal"], - changed_paths=list(changed), - inventory_paths=list(changed), - raw_log=str(raw_log), - summary=summary, - workspace_preserved=not exact_restore, - cleanup=cleanup, - observed_version=probe.observed_version, - ) - - if pointer_delta: - return _preserved( - "failed-unknown", - f"worktree .git pointer/admin identity changed: {', '.join(pointer_delta)}", - cleanup_extra={ - "reason": "worktree git pointer mismatch", - "pointer_delta": pointer_delta, - }, - ) - - # Git administration snapshot dominates worker prose. - if admin_delta: - return _preserved( - "failed-unknown", - f"unexplained git administration change: {', '.join(admin_delta)}", - cleanup_extra={"reason": "git admin snapshot mismatch", "admin_delta": admin_delta}, - ) - - if canonical_delta: - return _preserved( - "failed-unknown", - f"canonical checkout mutated: {', '.join(canonical_delta)}", - cleanup_extra={ - "reason": "canonical checkout snapshot mismatch", - "canonical_delta": canonical_delta, - }, - ) - - escaped = [p for p in changed if _path_symlink_escapes(worktree, p)] - if escaped: - return _preserved( - "failed-unknown", - f"changed path escapes worktree via symlink: {escaped}", - cleanup_extra={"reason": "symlink escape on changed paths"}, - ) - - out_of_scope = [p for p in changed if not _path_allowed(p, unit.allowed_paths)] - worker_requests_expand = bool( - worker_result - and worker_result.get("terminal_status") in {"scope_expansion", "scope-expansion"} - ) - if out_of_scope or worker_requests_expand: - return _preserved( - "scope-expansion", - ( - f"paths outside allowed_paths: {out_of_scope}" - if out_of_scope - else "worker requested scope expansion" - ), - cleanup_extra={"preserved": True}, - ) - - if proc_meta.get("idle_timed_out") and not proc_meta["timed_out"]: - # Idle expiry is not a proven-clean failure and must not continue routing. - return _preserved( - "failed-unknown", - "idle timeout; process group terminated", - cleanup_extra={ - "reason": "idle timeout", - "idle_timed_out": True, - "timed_out": False, - }, - ) - - if proc_meta["timed_out"] or proc_meta["signal"] is not None: - # Timeout/disconnect cannot prove absence of external side effects. - return _preserved( - "failed-unknown", - ( - "timed out; process group terminated" - if proc_meta["timed_out"] - else f"abnormal termination signal={proc_meta['signal']}" - ), - cleanup_extra={"reason": "unproven external side effects"}, - ) - - clean_exit = proc_meta["exit_code"] == 0 - # Worker fail-stop terminals dominate success and no-change classification. - # A zero exit or an in-scope diff must not launder blocked/cancelled/unknown. - worker_stop = _worker_fail_stop_status(worker_result) - if worker_stop: - return _preserved( - worker_stop, - f"worker terminal_status {worker_stop}", - cleanup_extra={"reason": "worker fail-stop terminal"}, - ) - - completed = bool( - worker_result and worker_result.get("terminal_status") == "completed" - ) - if clean_exit and changed and not completed: - return _preserved( - "failed-unknown", - "worker exited 0 with changes but no valid completed terminal record", - cleanup_extra={"reason": "missing or invalid terminal record"}, - ) - - # Success requires exit 0 + completed terminal record + clean admin + in-scope diff + transport. - if clean_exit and changed and not out_of_scope: - # Recheck path bounds immediately before host-owned transport. - try: - _validate_allowed_path_symlinks(worktree, tuple(changed)) - except UnitPacketError as exc: - return _preserved("failed-unknown", str(exc)) - try: - transport_ref = _create_transport_commit( - repo=repo, - worktree=worktree, - base_head=base["head"], - run_id=run_id, - attempt_id=attempt_id, - inventory=inventory, - ) - except Exception as exc: - # Staging / commit-tree / update-ref failure after a clean worker exit - # must stay recoverable: retained failed-unknown, no routing, no fallback. - return _preserved( - "failed-unknown", - f"transport construction failed: {exc}", - cleanup_extra={ - "exact_restore": False, - "transport_created": False, - "reason": "transport construction failure", - "error": str(exc), - }, - ) - return AttemptRecord( - attempt_id=attempt_id, - profile=profile.name, - adapter=profile.adapter, - terminal_status="success", - reason=None, - worktree=str(worktree), - pid=proc_meta["pid"], - exit_code=proc_meta["exit_code"], - changed_paths=changed, - inventory_paths=list(changed), - raw_log=str(raw_log), - summary=summary, - transport_ref=transport_ref, - workspace_preserved=True, - cleanup={ - "exact_restore": False, - "transport_created": True, - "stdout_tail_bytes": len(proc_meta.get("stdout_tail") or ""), - "stderr_tail_bytes": len(proc_meta.get("stderr_tail") or ""), - }, - observed_version=probe.observed_version, - ) - - # Launched attempts have no enforced network or external-cost isolation, and - # the unit's declared network/external_cost booleans are not proof. A - # restored private clone therefore cannot make a launched failure clean. - if clean_exit: - reason = "worker exited 0 without in-scope changes" - else: - reason = f"worker failed with exit {proc_meta['exit_code']}" - return _preserved( - "failed-unknown", - f"{reason}; external side effects are not mechanically reconciled", - cleanup_extra={ - "reason": "external side effects not mechanically reconciled", - "declared_network": unit.network, - "declared_external_cost": unit.external_cost, - }, - ) - -def _supervise( - argv: tuple[str, ...], - *, - cwd: Path, - stdin: bytes | None, - timeout: int, - raw_log: Path, - raw_cap: int, - secret_values: set[str], - idle_timeout: int | None = None, -) -> dict[str, Any]: - env = _filtered_env() - # Capture for redaction only — never serialize into receipts. - for key, value in list(env.items()): - if SECRET_VALUE_RE.match(value.strip()) or "SECRET" in key.upper() or "TOKEN" in key.upper() or "KEY" in key.upper(): - if value: - secret_values.add(value) - - timed_out = False - idle_timed_out = False - exit_code: int | None = None - sig: int | None = None - pid: int | None = None - stdout_tail = _StdoutTail(OUTPUT_TAIL_BYTES) - stderr_tail = _ByteTail(OUTPUT_TAIL_BYTES) - # Bounded prefix: raw_cap + exact-secret lookahead. Redact the complete - # prefix once, write at most raw_cap, then discard further drained bytes. - secret_lookahead = max((len(s) for s in secret_values), default=0) - secret_lookahead = min(max(secret_lookahead, 64), 8192) - prefix_limit = max(raw_cap, 0) + secret_lookahead - raw_prefix = bytearray() - raw_written = 0 - prefix_sealed = False - - def _preexec() -> None: - os.setsid() - - try: - proc = subprocess.Popen( - list(argv), - cwd=str(cwd), - stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=env, - preexec_fn=_preexec, - bufsize=0, - ) - except OSError as exc: - raw_log.write_bytes(_cap_bytes(str(exc).encode("utf-8", "replace"), raw_cap)) - return { - "pid": None, - "exit_code": 127, - "timed_out": False, - "idle_timed_out": False, - "signal": None, - "stdout_tail": "", - "stderr_tail": "", - "summary_source": str(exc), - } - - assert proc.stdout is not None and proc.stderr is not None - pid = proc.pid - - def _set_nonblocking(fh: Any) -> None: - fd = fh.fileno() - flags = fcntl.fcntl(fd, fcntl.F_GETFL) - fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) - - _set_nonblocking(proc.stdout) - _set_nonblocking(proc.stderr) - stdin_fh = proc.stdin - stdin_view: memoryview | None = None - stdin_offset = 0 - if stdin is not None and stdin_fh is not None: - _set_nonblocking(stdin_fh) - stdin_view = memoryview(stdin) - elif stdin_fh is not None: - try: - stdin_fh.close() - except OSError: - pass - stdin_fh = None - - streams = {proc.stdout: stdout_tail, proc.stderr: stderr_tail} - bounded_timeout = max(int(timeout), 1) - if idle_timeout is None: - idle_limit = min(DEFAULT_IDLE_TIMEOUT_SECONDS, bounded_timeout) - else: - idle_limit = min(max(int(idle_timeout), 1), bounded_timeout) - started = time.monotonic() - deadline = started + bounded_timeout - idle_deadline = started + idle_limit - grace_deadline: float | None = None - - with raw_log.open("wb") as raw_fh: - - def _note_progress() -> None: - """Reset idle only after a successful stdin write or stdout/stderr read.""" - nonlocal idle_deadline - if grace_deadline is not None: - return - idle_deadline = time.monotonic() + idle_limit - - def _seal_raw_prefix() -> None: - nonlocal raw_written, prefix_sealed, raw_prefix - if prefix_sealed: - return - prefix_sealed = True - if not raw_prefix: - return - redacted = _redact_bytes(bytes(raw_prefix), secret_values) - raw_prefix.clear() - if raw_cap > 0: - take = redacted[:raw_cap] - if take: - raw_fh.write(take) - raw_written = len(take) - raw_fh.flush() - - def _emit_raw(chunk: bytes) -> None: - nonlocal raw_prefix - if not chunk: - return - if prefix_sealed: - # Cap reached earlier; keep draining but discard excess. - return - remaining = prefix_limit - len(raw_prefix) - if remaining <= 0: - _seal_raw_prefix() - return - if len(chunk) <= remaining: - raw_prefix.extend(chunk) - if len(raw_prefix) >= prefix_limit: - _seal_raw_prefix() - return - raw_prefix.extend(chunk[:remaining]) - _seal_raw_prefix() - - def _drain_fd(fh: Any, tail: _ByteTail) -> bool: - """Read one chunk. True = keep watching; False = EOF/error (drop stream).""" - try: - chunk = os.read(fh.fileno(), 65536) - except BlockingIOError: - return True - except OSError: - return False - if not chunk: - return False - tail.extend(chunk) - _emit_raw(chunk) - _note_progress() - return True - - def _drain_fd_available(fh: Any, tail: _ByteTail) -> None: - """Drain until EOF, error, or would-block (final post-exit sweep).""" - while True: - try: - chunk = os.read(fh.fileno(), 65536) - except BlockingIOError: - return - except OSError: - return - if not chunk: - return - tail.extend(chunk) - _emit_raw(chunk) - - def _write_stdin() -> bool: - """Write a nonblocking stdin slice. Return False when stdin is done/closed.""" - nonlocal stdin_fh, stdin_offset - if stdin_fh is None or stdin_view is None: - return False - if stdin_offset >= len(stdin_view): - try: - stdin_fh.close() - except OSError: - pass - stdin_fh = None - return False - try: - written = os.write( - stdin_fh.fileno(), - stdin_view[stdin_offset : stdin_offset + 65536], - ) - except BrokenPipeError: - try: - stdin_fh.close() - except OSError: - pass - stdin_fh = None - return False - except BlockingIOError: - return True - except OSError: - try: - stdin_fh.close() - except OSError: - pass - stdin_fh = None - return False - stdin_offset += written - if written > 0: - _note_progress() - if stdin_offset >= len(stdin_view): - try: - stdin_fh.close() - except OSError: - pass - stdin_fh = None - return False - return True - - while streams or proc.poll() is None or stdin_fh is not None: - now = time.monotonic() - if grace_deadline is None and (now >= deadline or now >= idle_deadline): - # Total timeout stays the hard bound when both deadlines are due. - if now >= deadline: - timed_out = True - else: - idle_timed_out = True - _kill_process_group(proc.pid) - grace_deadline = now + 5 - if stdin_fh is not None: - try: - stdin_fh.close() - except OSError: - pass - stdin_fh = None - if grace_deadline is not None and now >= grace_deadline: - break - wait = 0.2 - if grace_deadline is None: - wait = max( - 0.0, min(wait, deadline - now, idle_deadline - now) - ) - else: - wait = max(0.0, min(wait, grace_deadline - now)) - readers = list(streams.keys()) - writers: list[Any] = [] - if stdin_fh is not None and stdin_view is not None and stdin_offset < len(stdin_view): - writers.append(stdin_fh) - if not readers and not writers: - # Process still alive but pipes already closed; wait for exit/timeout. - time.sleep(min(wait, 0.05)) - continue - readable, writable, _ = select.select(readers, writers, [], wait) - for fh in writable: - if fh is stdin_fh: - _write_stdin() - for fh in readable: - alive = _drain_fd(fh, streams[fh]) - if not alive: - streams.pop(fh, None) - try: - fh.close() - except OSError: - pass - if proc.poll() is not None and not readable and stdin_fh is None: - # Final drain after exit when select reports idle pipes. - for fh in list(streams.keys()): - _drain_fd_available(fh, streams[fh]) - streams.pop(fh, None) - try: - fh.close() - except OSError: - pass - break - - _seal_raw_prefix() - - if proc.poll() is None: - if not idle_timed_out: - timed_out = True - _kill_process_group(proc.pid) - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - pass - exit_code = proc.returncode - - if exit_code is not None and exit_code < 0: - sig = -exit_code - exit_code = 128 + sig - - stdout_text = stdout_tail.text() - stderr_text = stderr_tail.text() - summary_source = _redact_text(stdout_text[-2000:], secret_values) - return { - "pid": pid, - "exit_code": exit_code, - "timed_out": timed_out, - "idle_timed_out": idle_timed_out, - "signal": sig, - "stdout_tail": stdout_text, - "stderr_tail": stderr_text, - "terminal_lines": stdout_tail.terminal_lines(), - "summary_source": summary_source, - } - - -class _ByteTail: - """Bounded in-memory byte tail used while draining child output.""" - - def __init__(self, limit: int) -> None: - self.limit = max(1, limit) - self._data = bytearray() - - def extend(self, chunk: bytes) -> None: - if not chunk: - return - self._data.extend(chunk) - if len(self._data) > self.limit: - self._data = self._data[-self.limit :] - - def text(self) -> str: - return bytes(self._data).decode("utf-8", "replace") - - -TERMINAL_LINE_MAX_BYTES = 1 << 20 -TERMINAL_LINE_KEEP = 32 -_TERMINAL_ENVELOPE_RE = re.compile( - rb'"(?:type|event)"\s*:\s*"(?:result|message_end)"' -) -_TERMINAL_HINT_RE = re.compile(rb'terminal_status|"is_error"') - - -class _StdoutTail(_ByteTail): - """Byte tail that also keeps recent complete lines that may be terminal envelopes. - - A final envelope can sit behind a later event larger than the byte tail - (for example PI ``agent_end``), so candidate lines are kept separately. - An oversized envelope line is recorded as ``None`` so decoding fails closed. - """ - - def __init__(self, limit: int) -> None: - super().__init__(limit) - self._line = bytearray() - self._overflow_head: bytes | None = None - self._lines: list[bytes | None] = [] - - def extend(self, chunk: bytes) -> None: - super().extend(chunk) - start = 0 - while start <= len(chunk): - nl = chunk.find(b"\n", start) - piece = chunk[start:] if nl == -1 else chunk[start:nl] - self._append_piece(piece) - if nl == -1: - return - self._end_line() - start = nl + 1 - - def _append_piece(self, piece: bytes) -> None: - if self._overflow_head is not None: - return - if len(self._line) + len(piece) > TERMINAL_LINE_MAX_BYTES: - self._overflow_head = bytes(self._line[:256] + piece[:256])[:256] - self._line.clear() - return - self._line.extend(piece) - - def _end_line(self) -> None: - if self._overflow_head is not None: - if _TERMINAL_ENVELOPE_RE.search(self._overflow_head): - self._keep(None) - self._overflow_head = None - return - line = bytes(self._line) - self._line.clear() - if _TERMINAL_ENVELOPE_RE.search(line) or _TERMINAL_HINT_RE.search(line): - self._keep(line) - - def _keep(self, line: bytes | None) -> None: - self._lines.append(line) - if len(self._lines) > TERMINAL_LINE_KEEP: - del self._lines[0] - - def terminal_lines(self) -> list[str | None]: - if self._line or self._overflow_head is not None: - self._end_line() - return [ - None if line is None else line.decode("utf-8", "replace") - for line in self._lines - ] - - -def _kill_process_group(pid: int) -> None: - try: - os.killpg(pid, signal.SIGKILL) - except ProcessLookupError: - pass - except PermissionError: - try: - os.kill(pid, signal.SIGKILL) - except ProcessLookupError: - pass - - -def _filtered_env() -> dict[str, str]: - out: dict[str, str] = {} - for key, value in os.environ.items(): - if key in ENV_ALLOW_EXACT or key.startswith(ENV_ALLOW_PREFIXES): - out[key] = value - # Ensure PATH/HOME exist for executables. - if "PATH" not in out and "PATH" in os.environ: - out["PATH"] = os.environ["PATH"] - if "HOME" not in out and "HOME" in os.environ: - out["HOME"] = os.environ["HOME"] - return out - - -_WORKER_FAIL_STOP = frozenset({"blocked", "cancelled", "failed-unknown"}) - - -def _worker_fail_stop_status(worker_result: dict[str, Any] | None) -> str | None: - """Return a worker terminal that must fail-stop before success or no-change.""" - if not isinstance(worker_result, dict): - return None - raw = worker_result.get("terminal_status") - if not isinstance(raw, str): - return None - status = raw.strip().replace("_", "-") - if status in _WORKER_FAIL_STOP: - return status - return None - - -def _parse_name_status(text: str) -> list[tuple[str, tuple[str, ...]]]: - """Parse git name-status text into ``(status, paths)`` records. - - Rename (``R``) and copy (``C``) records keep both the source and the - destination. Keeping only the destination would hide deletion of an - out-of-scope or non-exempt source that was moved into an allowed path. - """ - records: list[tuple[str, tuple[str, ...]]] = [] - for line in text.splitlines(): - if not line.strip(): - continue - parts = line.split("\t") - status = parts[0] - if status[:1] in {"R", "C"} and len(parts) >= 3: - records.append((status, (parts[1], parts[2]))) - elif len(parts) >= 2: - records.append((status, (parts[1],))) - return records - - -def _inventory_changes(worktree: Path) -> dict[str, Any]: - """Inventory tracked, staged, untracked, deleted, renamed, mode, and binary changes. - - Staged index entries are collected explicitly so they cannot be silently dropped. - """ - paths: list[str] = [] - details: list[dict[str, Any]] = [] - - def _consume_name_status(text: str, *, prefix: str) -> None: - for status, rec_paths in _parse_name_status(text): - paths.extend(rec_paths) - kind = status[:1] - if kind in {"R", "C"} and len(rec_paths) == 2: - label = "renamed" if kind == "R" else "copied" - details.append( - { - "status": f"{prefix}{label}", - "from": rec_paths[0], - "path": rec_paths[1], - } - ) - else: - details.append({"status": f"{prefix}{kind}", "path": rec_paths[0]}) - - # Explicit staged inventory (index vs HEAD) — fail-visible, never silent. - staged = _git( - worktree, - "diff", - "--cached", - "--name-status", - "--find-renames", - check=False, - ) - if staged.returncode != 0: - raise RuntimeError(f"staged inventory failed: {staged.stderr.strip()}") - _consume_name_status(staged.stdout, prefix="staged:") - - # Working tree vs HEAD (covers unstaged tracked modifications / deletions). - named = _git( - worktree, - "diff", - "--name-status", - "--find-renames", - "HEAD", - check=False, - ) - if named.returncode != 0: - raise RuntimeError(f"worktree inventory failed: {named.stderr.strip()}") - _consume_name_status(named.stdout, prefix="") - - # Untracked (including binary); ignore ignored files. - untracked = _git( - worktree, - "ls-files", - "--others", - "--exclude-standard", - check=False, - ) - if untracked.returncode != 0: - raise RuntimeError(f"untracked inventory failed: {untracked.stderr.strip()}") - for line in untracked.stdout.splitlines(): - path = line.strip() - if not path: - continue - if path.startswith(".git/") or "/.git/" in path: - continue - paths.append(path) - details.append({"status": "untracked", "path": path}) - - # Executable mode bits among tracked diffs. - mode = _git(worktree, "diff", "--summary", "HEAD", check=False) - if mode.returncode != 0: - raise RuntimeError(f"mode inventory failed: {mode.stderr.strip()}") - for line in mode.stdout.splitlines(): - if "mode change" in line or "change from" in line: - details.append({"status": "mode", "raw": line.strip()}) - - # Deduplicate while preserving order. - seen: set[str] = set() - ordered: list[str] = [] - for p in paths: - if p not in seen: - seen.add(p) - ordered.append(p) - return {"paths": ordered, "details": details} - -def _path_allowed(path: str, allowed: tuple[str, ...]) -> bool: - norm = path.replace("\\", "/").lstrip("./") - if ".." in Path(norm).parts: - return False - for rule in allowed: - rule_n = rule.replace("\\", "/").lstrip("./") - if norm == rule_n or norm.startswith(rule_n.rstrip("/") + "/"): - return True - # Exact file rule: also allow if rule is a prefix directory. - if rule_n.endswith("/") and norm.startswith(rule_n): - return True - return False - - -def _git_admin_snapshot(worktree: Path, repo: Path) -> dict[str, str]: - """Snapshot the private attempt clone's Git administration state. - - Canonical checkout identity is snapshotted separately. ``repo`` is unused now - that each attempt owns private config/refs; kept for call-site stability. - """ - del repo - head = _git(worktree, "rev-parse", "HEAD").stdout.strip() - index_tree = _git(worktree, "write-tree").stdout.strip() - staged_proc = _git( - worktree, - "diff", - "--cached", - "--name-status", - "--find-renames", - check=False, - ) - if staged_proc.returncode != 0: - raise RuntimeError(f"admin staged snapshot failed: {staged_proc.stderr.strip()}") - staged = staged_proc.stdout - local_config_proc = _git(worktree, "config", "--local", "--list", check=False) - if local_config_proc.returncode != 0: - raise RuntimeError( - f"admin local-config snapshot failed: {local_config_proc.stderr.strip()}" - ) - local_config = "\n".join( - sorted(line for line in local_config_proc.stdout.splitlines() if line) - ) - refs_proc = _git( - worktree, - "for-each-ref", - "--format=%(refname) %(objectname)", - check=False, - ) - if refs_proc.returncode != 0: - raise RuntimeError(f"admin refs snapshot failed: {refs_proc.stderr.strip()}") - refs = "\n".join(sorted(line for line in refs_proc.stdout.splitlines() if line)) - abs_git = _git(worktree, "rev-parse", "--absolute-git-dir").stdout.strip() - common_dir = _git( - worktree, "rev-parse", "--path-format=absolute", "--git-common-dir" - ).stdout.strip() - return { - "head": head, - "index_tree": index_tree, - "staged": staged, - "local_config": local_config, - "refs": refs, - "abs_git": abs_git, - "common_dir": common_dir, - } - - -def _git_admin_delta(before: dict[str, str], after: dict[str, str]) -> list[str]: - return _snapshot_delta(before, after) - - -def _snapshot_delta(before: dict[str, str], after: dict[str, str]) -> list[str]: - changed: list[str] = [] - for key in sorted(set(before) | set(after)): - if before.get(key) != after.get(key): - changed.append(key) - return changed - - -def _canonical_checkout_snapshot(repo: Path) -> dict[str, str]: - """Snapshot the canonical checkout identity (not the attempt workspace). - - Protects HEAD, current branch, index, tracked/untracked status, and - git/common-dir identity. Unrelated sibling branch refs, branch config, and - worktree-list registration are intentionally excluded — worker Git is - isolated in a private clone, so those host-side delivery signals are not - worker mutation. - """ - repo = repo.resolve() - head = _git(repo, "rev-parse", "HEAD").stdout.strip() - branch = _git(repo, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() - index_tree = _git(repo, "write-tree").stdout.strip() - status = _git(repo, "status", "--porcelain=v2", "-uall").stdout - git_dir = _git(repo, "rev-parse", "--absolute-git-dir").stdout.strip() - common = _git( - repo, "rev-parse", "--path-format=absolute", "--git-common-dir" - ).stdout.strip() - return { - "head": head, - "branch": branch, - "index_tree": index_tree, - "status": status, - "git_dir": git_dir, - "common_dir": common, - } - - -def _worktree_pointer_snapshot( - worktree: Path, *, require_git: bool = True -) -> dict[str, str]: - """Snapshot the private attempt `.git` directory / admin identity.""" - git_path = worktree / ".git" - if git_path.is_symlink(): - kind = "symlink" - try: - content = os.readlink(git_path) - except OSError as exc: - raise RuntimeError(f"unable to read worktree .git symlink: {exc}") from exc - elif git_path.is_file(): - kind = "file" - try: - content = git_path.read_text(encoding="utf-8") - except OSError as exc: - raise RuntimeError(f"unable to read worktree .git file: {exc}") from exc - elif git_path.is_dir(): - kind = "dir" - content = "dir" - else: - kind = "missing" - content = "" - snap = {"kind": kind, "content": content, "abs_git": "", "common_dir": ""} - if require_git: - snap["abs_git"] = _git( - worktree, "rev-parse", "--absolute-git-dir" - ).stdout.strip() - snap["common_dir"] = _git( - worktree, "rev-parse", "--path-format=absolute", "--git-common-dir" - ).stdout.strip() - return snap - - -def _is_within_root(path: Path, root: Path) -> bool: - try: - path.resolve().relative_to(root.resolve()) - return True - except (ValueError, OSError): - return False - - -def _path_symlink_escapes(root: Path, rel: str) -> bool: - """True when any existing ancestor/target of rel resolves outside root.""" - root = root.resolve() - cur = root - for part in Path(rel).parts: - if part in ("", "."): - continue - if part == "..": - return True - nxt = cur / part - if nxt.is_symlink() or nxt.exists(): - try: - resolved = nxt.resolve() - except OSError: - return True - if not _is_within_root(resolved, root): - return True - cur = resolved if nxt.is_symlink() else nxt - else: - # Remaining path does not exist yet; parent must already be inside. - return not _is_within_root(cur, root) - return False - - -def _validate_allowed_path_symlinks(root: Path, paths: tuple[str, ...]) -> None: - for rel in paths: - if _path_symlink_escapes(root, rel): - raise UnitPacketError( - f"allowed path escapes worktree via symlink: {rel!r}" - ) - - -def _create_transport_commit( - *, - repo: Path, - worktree: Path, - base_head: str, - run_id: str, - attempt_id: str, - inventory: dict[str, Any], -) -> str: - """Create a base-parented transport commit in the private clone and copy it back. - - Staging and ``commit-tree`` run only inside the attempt clone. Exact - commit/tree/blob objects are fetched into the canonical repository, which - receives solely the host-owned ``refs/agent-exec//`` ref. - """ - del inventory # Inventory already validated; staging uses the full private tree. - # Stage all tracked/untracked changes in the private clone only. - _git(worktree, "add", "-A", "--", ".") - # Include deletions. - _git(worktree, "add", "-u", check=False) - tree = _git(worktree, "write-tree").stdout.strip() - msg = f"agent-exec transport {run_id}/{attempt_id}" - commit = _git( - worktree, - "-c", - f"user.name={TRANSPORT_AUTHOR_NAME}", - "-c", - f"user.email={TRANSPORT_AUTHOR_EMAIL}", - "commit-tree", - tree, - "-p", - base_head, - "-m", - msg, - ).stdout.strip() - ref = f"refs/agent-exec/{run_id}/{attempt_id}" - # Park the commit on a private ref so a local fetch can name it. - _git(worktree, "update-ref", ref, commit) - # Copy objects into the canonical store and create only the host-owned ref. - fetch = _git( - repo, - "fetch", - "--no-tags", - "--no-write-fetch-head", - str(worktree.resolve()), - f"+{ref}:{ref}", - check=False, - ) - if fetch.returncode != 0: - raise RuntimeError( - f"transport object transfer failed: {fetch.stderr.strip() or fetch.stdout.strip()}" - ) - resolved = _git(repo, "rev-parse", ref).stdout.strip() - if resolved != commit: - raise RuntimeError( - f"transport ref resolved to {resolved}, expected {commit}" - ) - parents = ( - _git(repo, "rev-list", "--parents", "-n", "1", commit) - .stdout.strip() - .split() - ) - if len(parents) != 2 or parents[0] != commit or parents[1] != base_head: - raise RuntimeError( - f"transport commit parent check failed: {' '.join(parents)}" - ) - return ref - - -def _safe_remove_worktree(repo: Path, worktree: Path) -> None: - """Remove a private attempt clone directory (not a linked worktree).""" - del repo # Private clones are not registered linked worktrees. - if worktree.exists(): - shutil.rmtree(worktree, ignore_errors=True) - - -def _load_worker_result( - adapter: str, result_path: Path, proc_meta: dict[str, Any] -) -> dict[str, Any] | None: - """Return the worker's valid terminal record, or None when absent/invalid.""" - result_text: str | None = None - if result_path.is_file(): - try: - result_text = result_path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - result_text = None - return decode_terminal_record( - adapter, - event_lines=proc_meta.get("terminal_lines") or [], - stdout_tail=proc_meta.get("stdout_tail") or "", - result_text=result_text, - ) - - -def _base_receipt( - *, - run_id: str, - repo: Path, - role: str, - unit_id: str | None, - config: ExecutionConfig | None, - terminal_status: str, - reason: str | None, - attempts: list[AttemptRecord], - secret_values: set[str], -) -> dict[str, Any]: - attempt_dicts = [] - for a in attempts: - attempt_dicts.append( - { - "attempt_id": a.attempt_id, - "profile": a.profile, - "adapter": a.adapter, - "attempt_ordinal": a.attempt_ordinal, - "emergency_fallback": a.emergency_fallback, - "terminal_status": a.terminal_status, - "reason": a.reason, - "worktree": a.worktree, - "pid": a.pid, - "exit_code": a.exit_code, - "timed_out": a.timed_out, - "idle_timed_out": a.idle_timed_out, - "signal": a.signal, - "changed_paths": a.changed_paths, - "inventory_paths": a.inventory_paths or a.changed_paths, - "raw_log": a.raw_log, - "summary": a.summary, - "cleanup": a.cleanup, - "workspace_preserved": a.workspace_preserved, - "transport_ref": a.transport_ref, - "observed_version": a.observed_version, - } - ) - return { - "schema": RECEIPT_SCHEMA, - "run_id": run_id, - "repo": str(repo), - "role": role, - "unit_id": unit_id, - "terminal_status": terminal_status, - "reason": reason, - "attempts": attempt_dicts, - "config_summary": _config_summary(config), - "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - } - - -def _config_summary(config: ExecutionConfig | None) -> dict[str, Any] | None: - if config is None: - return None - return { - "schema": config.schema, - "permission": config.execution.permission, - "fallback_to_host": config.execution.fallback_to_host, - "timeout_seconds": config.execution.timeout_seconds, - "idle_timeout_seconds": config.execution.idle_timeout_seconds, - "summary_bytes": config.execution.summary_bytes, - "raw_output_bytes": config.execution.raw_output_bytes, - "roles": {k: list(v) for k, v in config.roles.items()}, - "profiles": { - name: { - "adapter": p.adapter, - "executable": p.executable, - "model": p.model, - "enabled": p.enabled, - } - for name, p in config.profiles.items() - }, - } - - -def _finish_run( - *, - repo: Path, - receipt_path: Path, - receipt: dict[str, Any], - secret_values: set[str], - summary_bytes: int, - exit_code: int, - started_monotonic: float, -) -> int: - """Persist one history line and the normal receipt without changing outcome.""" - receipt["elapsed_seconds"] = round( - max(0.0, time.monotonic() - started_monotonic), 3 - ) - history_path: Path | None = None - try: - history_path, integrity_mac = _append_execution_history( - repo, receipt, secret_values - ) - recorded: dict[str, Any] = { - "path": str(history_path), - "status": "recorded", - "record_id": receipt.get("run_id"), - "run_id": receipt.get("run_id"), - } - if integrity_mac: - recorded["local_integrity_mac"] = integrity_mac - receipt["history"] = recorded - except Exception as exc: - receipt["history"] = { - "path": str(history_path) if history_path is not None else None, - "status": "failed", - "reason": _redact_text(str(exc), secret_values), - } - _write_receipt( - receipt_path, - receipt, - secret_values=secret_values, - summary_bytes=summary_bytes, - ) - return exit_code - - -def _append_execution_history( - repo: Path, - receipt: dict[str, Any], - secret_values: set[str], -) -> tuple[Path, str | None]: - repo = repo.resolve() - history_dir = _git_common_dir(repo) / "agent-exec" - if history_dir.is_symlink(): - raise RuntimeError("agent-exec history directory must not be a symlink") - history_dir.mkdir(mode=0o700, parents=True, exist_ok=True) - if history_dir.is_symlink() or not history_dir.is_dir(): - raise RuntimeError("agent-exec history directory must not be a symlink") - os.chmod(history_dir, 0o700) - history_path = history_dir / "execution-history.jsonl" - if history_path.is_symlink(): - raise RuntimeError("agent-exec history file must not be a symlink") - integrity_mac: str | None = None - if receipt.get("terminal_status") == "success": - _bind_success_transport_commit(repo, receipt) - key = _ensure_local_integrity_key(repo) - record = _execution_history_record(repo, receipt) - line, integrity_mac = _integrity_history_line(record, key, secret_values) - else: - record = _execution_history_record(repo, receipt) - line = _redact_text(_canonical_json(record), secret_values) + "\n" - fd = os.open( - history_path, - os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW | os.O_CLOEXEC, - 0o600, - ) - try: - os.fchmod(fd, 0o600) - with os.fdopen(fd, "a", encoding="utf-8", closefd=False) as handle: - fcntl.flock(fd, fcntl.LOCK_EX) - try: - handle.write(line) - handle.flush() - os.fsync(fd) - finally: - fcntl.flock(fd, fcntl.LOCK_UN) - finally: - os.close(fd) - return history_path, integrity_mac - - -def _execution_history_record(repo: Path, receipt: dict[str, Any]) -> dict[str, Any]: - config = receipt.get("config_summary") - profiles = config.get("profiles", {}) if isinstance(config, dict) else {} - attempt_records: list[dict[str, Any]] = [] - for attempt in receipt.get("attempts", []): - if not isinstance(attempt, dict): - continue - profile_name = attempt.get("profile") - profile = profiles.get(profile_name, {}) if isinstance(profiles, dict) else {} - requested = profile.get("model") if isinstance(profile, dict) else None - attempt_records.append( - { - "attempt_id": attempt.get("attempt_id"), - "profile": profile_name, - "adapter": attempt.get("adapter"), - "attempt_ordinal": attempt.get("attempt_ordinal"), - "emergency_fallback": bool(attempt.get("emergency_fallback")), - "observed_version": attempt.get("observed_version"), - "model_requested": requested or "auto", - "model_observed": "not-observable", - "fresh_session": True, - "session_id": "not-observable", - "terminal_status": attempt.get("terminal_status"), - "exit_code": attempt.get("exit_code"), - "timed_out": bool(attempt.get("timed_out")), - "idle_timed_out": bool(attempt.get("idle_timed_out")), - "changed_paths": list(attempt.get("changed_paths") or []), - "transport_ref": attempt.get("transport_ref"), - } - ) - transport = receipt.get("transport") - safe_transport = None - if isinstance(transport, dict): - safe_transport = { - "ref": transport.get("ref"), - "parent": transport.get("parent"), - } - commit = transport.get("commit") - if isinstance(commit, str) and commit: - safe_transport["commit"] = commit - terminal = receipt.get("terminal_status") - return { - "schema": HISTORY_SCHEMA, - "recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "record_id": receipt.get("run_id"), - "run_id": receipt.get("run_id"), - "repo": str(repo.resolve()), - "unit_id": receipt.get("unit_id"), - "role": receipt.get("role"), - "stage": receipt.get("role"), - "config_digest": receipt.get("config_digest"), - "elapsed_seconds": receipt.get("elapsed_seconds"), - "terminal_status": terminal, - "reason": receipt.get("reason"), - "failure_category": None if terminal == "success" else terminal, - "changed_paths": list(receipt.get("changed_paths") or []), - "transport": safe_transport, - "host_fallback_authorizations": receipt.get( - "host_fallback_authorizations", 0 - ), - "attempts": attempt_records, - "verification_result": "NOT_RUN", - "review_result": "NOT_RUN", - "final_result": "NOT_RUN", - "tokens": "not-observable", - "cost": "not-observable", - } - - -def _write_receipt( - path: Path, - receipt: dict[str, Any], - *, - secret_values: set[str], - summary_bytes: int, -) -> None: - if "summary" in receipt and isinstance(receipt["summary"], str): - receipt["summary"] = _bounded_summary( - receipt["summary"], summary_bytes, secret_values - ) - text = json.dumps(receipt, indent=2, sort_keys=True) + "\n" - text = _redact_text(text, secret_values) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - -def _build_run_summary( - terminal: str, attempts: list[AttemptRecord], reason: str | None -) -> str: - parts = [f"terminal={terminal}"] - if reason: - parts.append(f"reason={reason}") - parts.append(f"attempts={len(attempts)}") - for a in attempts: - parts.append(f"{a.profile}:{a.terminal_status}") - return "; ".join(parts) - - -def _exit_for_terminal(terminal: str) -> int: - return { - "success": EXIT_SUCCESS, - "host-fallback-authorized": EXIT_HOST_FALLBACK, - "invalid-config": EXIT_INVALID_CONFIG, - "unavailable": EXIT_UNAVAILABLE_EXHAUSTION, - "failed-clean": EXIT_UNAVAILABLE_EXHAUSTION, - "failed-unknown": EXIT_BLOCKED, - "blocked": EXIT_BLOCKED, - "scope-expansion": EXIT_SCOPE_EXPANSION, - "cancelled": EXIT_CANCELLED, - }.get(terminal, EXIT_BLOCKED) - - -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, - ) - - -def _file_digest(path: Path) -> str: - try: - return hashlib.sha256(path.read_bytes()).hexdigest() - except OSError: - return "" - - -def _collect_secret_values(out: set[str]) -> None: - for key, value in os.environ.items(): - if not value: - continue - if SECRET_VALUE_RE.match(value.strip()): - out.add(value) - if re.search(r"(SECRET|TOKEN|PASSWORD|API_KEY|CREDENTIAL)", key, re.I): - out.add(value) - - -def _redact_text(text: str, secrets: set[str]) -> str: - out = text - for secret in sorted(secrets, key=len, reverse=True): - if secret and secret in out: - out = out.replace(secret, "***") - out = SECRET_VALUE_RE.sub("***", out) - return out - - -def _redact_bytes(data: bytes, secrets: set[str]) -> bytes: - try: - text = data.decode("utf-8", "replace") - except Exception: - return data[: len(data)] - return _redact_text(text, secrets).encode("utf-8", "replace") - - -def _bounded_summary(text: str, limit: int, secrets: set[str]) -> str: - text = _redact_text(text or "", secrets) - encoded = text.encode("utf-8") - if len(encoded) <= limit: - return text - return encoded[:limit].decode("utf-8", "ignore") - - -def _cap_bytes(data: bytes, limit: int) -> bytes: - if len(data) <= limit: - return data - return data[:limit] - - -# Host may commit task cards and superpowers docs without a worker transport. -_HOST_MANAGED_PREFIXES = ( - ".task/", - "docs/superpowers/specs/", - "docs/superpowers/plans/", -) -_EMBEDDED_SECRET_RE = re.compile(r"(?i)(?:bearer\s+\S+|sk-[A-Za-z0-9_\-]{8,})") - - -def _reject(message: str, secrets: set[str], code: int = EXIT_BLOCKED) -> int: - print(_redact_text(message, secrets), file=sys.stderr) - return code - - -def _secret_set(*texts: str) -> set[str]: - found: set[str] = set() - _collect_secret_values(found) - for text in texts: - found.update(_EMBEDDED_SECRET_RE.findall(text)) - return found - - -def _normalize_repo_path(path: str) -> str: - """Normalize a repo-relative path without eating dotted names such as ``.task``. - - Leading/trailing whitespace is part of a valid Git path and is preserved. - """ - norm = path.replace("\\", "/") - while norm.startswith("./"): - norm = norm[2:] - return norm - - -def _host_managed(path: str) -> bool: - norm = _normalize_repo_path(path) - if not norm or ".." in Path(norm).parts: - return False - return any(norm.startswith(prefix) for prefix in _HOST_MANAGED_PREFIXES) - - -def _non_exempt(paths: list[str]) -> set[str]: - return {_normalize_repo_path(path) for path in paths if not _host_managed(path)} - - -def _same_repo(repo: Path, raw: object) -> bool: - if not isinstance(raw, str) or not raw.strip(): - return False - candidate = Path(raw) - if not candidate.is_absolute(): - candidate = repo / candidate - try: - return candidate.resolve() == repo.resolve() - except OSError: - return False - - -def _resolve_commit(repo: Path, rev: str) -> str | None: - if not rev or rev.startswith("-") or "\n" in rev or "\x00" in rev: - return None - proc = _git(repo, "rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}", check=False) - if proc.returncode != 0: - return None - resolved = proc.stdout.strip() - return resolved or None - - -def _commit_parents(repo: Path, commit: str) -> list[str] | None: - proc = _git(repo, "rev-list", "--parents", "-n", "1", commit, check=False) - if proc.returncode != 0: - return None - parts = proc.stdout.split() - if not parts or parts[0] != commit: - return None - return parts[1:] - - -def _name_status_paths(text: str) -> list[str]: - paths: list[str] = [] - for _status, rec_paths in _parse_name_status(text): - paths.extend(rec_paths) - return paths - - -def _diff_paths(repo: Path, parent: str, commit: str) -> list[str]: - proc = _git( - repo, - "diff", - "--name-status", - "--find-renames", - parent, - commit, - check=False, - ) - if proc.returncode != 0: - raise RuntimeError(proc.stderr.strip() or "transport diff failed") - return _name_status_paths(proc.stdout) - - -def _staged_paths(repo: Path) -> list[str]: - proc = _git( - repo, - "diff", - "--cached", - "--name-status", - "--find-renames", - check=False, - ) - if proc.returncode != 0: - raise RuntimeError(proc.stderr.strip() or "staged diff failed") - return _name_status_paths(proc.stdout) - - -def _index_matches_commit(repo: Path, commit: str, paths: set[str]) -> bool: - """True when each named index path has the same content as commit.""" - if not paths: - return True - proc = _git( - repo, - "diff", - "--cached", - "--quiet", - commit, - "--", - *sorted(paths), - check=False, - ) - return proc.returncode == 0 - - -def _valid_transport_ref(ref: object) -> str | None: - if not isinstance(ref, str) or not ref.startswith("refs/agent-exec/"): - return None - if ".." in ref.split("/") or ref.endswith("/"): - return None - return ref - - -# Legacy on-disk names keep existing local history readable. The MAC is local -# integrity/tamper evidence only: any process running as the same OS user can -# read the key and forge records, so it is never GATE or a trusted identity. -_LOCAL_INTEGRITY_KEY_NAME = "attestation.key" -_LOCAL_INTEGRITY_FIELD = "attestation" -_LOCAL_INTEGRITY_KEY_BYTES = 32 - - -def _git_common_dir(repo: Path) -> Path: - repo = repo.resolve() - common = _git( - repo, - "rev-parse", - "--path-format=absolute", - "--git-common-dir", - check=False, - ) - if common.returncode != 0 or not common.stdout.strip(): - raise RuntimeError( - "unable to resolve git common dir: " - + (common.stderr.strip() or common.stdout.strip() or "empty") - ) - common_dir = Path(common.stdout.strip()) - if not common_dir.is_absolute(): - common_dir = (repo / common_dir).resolve() - return common_dir - - -def _canonical_json(data: dict[str, Any]) -> str: - return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")) - - -def _local_integrity_mac(key: bytes, record: dict[str, Any]) -> str: - body = {field: value for field, value in record.items() if field != _LOCAL_INTEGRITY_FIELD} - return hmac.new(key, _canonical_json(body).encode("utf-8"), hashlib.sha256).hexdigest() - - -def _local_integrity_ok(key: bytes, record: dict[str, Any]) -> bool: - presented = record.get(_LOCAL_INTEGRITY_FIELD) - if not isinstance(presented, str) or len(presented) != 64: - return False - return hmac.compare_digest(_local_integrity_mac(key, record), presented) - - -def _integrity_history_line( - record: dict[str, Any], key: bytes, secret_values: set[str] -) -> tuple[str, str]: - text = _redact_text(_canonical_json(record), secret_values) - body = json.loads(text) - if not isinstance(body, dict): - raise RuntimeError("history record redaction produced non-object JSON") - mac = _local_integrity_mac(key, body) - body[_LOCAL_INTEGRITY_FIELD] = mac - return _canonical_json(body) + "\n", mac - - -def _bind_success_transport_commit(repo: Path, receipt: dict[str, Any]) -> None: - transport = receipt.get("transport") - if not isinstance(transport, dict): - raise RuntimeError("success history requires transport") - ref = transport.get("ref") - parent = transport.get("parent") - if not isinstance(ref, str) or not isinstance(parent, str): - raise RuntimeError("success history requires transport ref and parent") - commit = _resolve_commit(repo, ref) - resolved_parent = _resolve_commit(repo, parent) - if commit is None or resolved_parent is None: - raise RuntimeError("success transport ref did not resolve") - if _commit_parents(repo, commit) != [resolved_parent]: - raise RuntimeError("success transport parent does not match the commit") - transport["commit"] = commit - transport["parent"] = resolved_parent - - -def _symlink_oserror(exc: OSError, message: str) -> OSError | RuntimeError: - if exc.errno == errno.ELOOP: - return RuntimeError(message) - return exc - - -@contextlib.contextmanager -def _locked_agent_exec_dir(repo: Path, *, create: bool) -> Iterator[int | None]: - """Lock the real agent-exec directory. Missing dirs yield None unless creating. - - Callers share this lock so the first key write cannot race another reader. - """ - history_dir = _git_common_dir(repo) / "agent-exec" - if history_dir.is_symlink(): - raise RuntimeError("agent-exec history directory must not be a symlink") - if not create and not history_dir.exists(): - yield None - return - if create: - history_dir.mkdir(mode=0o700, parents=True, exist_ok=True) - if history_dir.is_symlink() or not history_dir.is_dir(): - raise RuntimeError("agent-exec history directory must not be a symlink") - os.chmod(history_dir, 0o700) - elif not history_dir.is_dir(): - raise RuntimeError("agent-exec history directory must not be a symlink") - try: - fd = os.open( - history_dir, - os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, - ) - except OSError as exc: - raise _symlink_oserror( - exc, "agent-exec history directory must not be a symlink" - ) from exc - try: - info = os.fstat(fd) - if not stat.S_ISDIR(info.st_mode): - raise RuntimeError("agent-exec history directory must not be a symlink") - if stat.S_IMODE(info.st_mode) != 0o700: - raise RuntimeError("agent-exec history directory mode must be 0700") - fcntl.flock(fd, fcntl.LOCK_EX) - try: - yield fd - finally: - fcntl.flock(fd, fcntl.LOCK_UN) - finally: - os.close(fd) - - -def _read_key_at(dir_fd: int) -> bytes: - fd = os.open( - _LOCAL_INTEGRITY_KEY_NAME, - os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, - dir_fd=dir_fd, - ) - try: - current = os.fstat(fd) - if not stat.S_ISREG(current.st_mode): - raise RuntimeError("local integrity key must be a regular file") - if stat.S_IMODE(current.st_mode) != 0o600: - raise RuntimeError("local integrity key mode must be 0600") - if current.st_size != _LOCAL_INTEGRITY_KEY_BYTES: - raise RuntimeError("local integrity key must be 32 bytes") - os.lseek(fd, 0, os.SEEK_SET) - key = os.read(fd, _LOCAL_INTEGRITY_KEY_BYTES) - if len(key) != _LOCAL_INTEGRITY_KEY_BYTES: - raise RuntimeError("local integrity key must be 32 bytes") - return key - finally: - os.close(fd) - - -def _key_is_symlink(dir_fd: int) -> bool: - try: - info = os.stat(_LOCAL_INTEGRITY_KEY_NAME, dir_fd=dir_fd, follow_symlinks=False) - except FileNotFoundError: - return False - return stat.S_ISLNK(info.st_mode) - - -def _read_local_integrity_key(repo: Path) -> bytes | None: - """Read the local integrity key. Never creates one and never follows links.""" - with _locked_agent_exec_dir(repo, create=False) as dir_fd: - if dir_fd is None: - return None - if _key_is_symlink(dir_fd): - raise RuntimeError("local integrity key must not be a symlink") - try: - return _read_key_at(dir_fd) - except FileNotFoundError: - return None - except OSError as exc: - raise _symlink_oserror(exc, "local integrity key must not be a symlink") from exc - - -def _ensure_local_integrity_key(repo: Path) -> bytes: - """Create or read the 32-byte key under the Git common dir. - - The MAC lets later apply/admit checks detect accidental or casual edits of - local history. It is not an identity: the same OS user can read the key. - """ - with _locked_agent_exec_dir(repo, create=True) as dir_fd: - if dir_fd is None: - raise RuntimeError("agent-exec history directory is unavailable") - if _key_is_symlink(dir_fd): - raise RuntimeError("local integrity key must not be a symlink") - flags = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC - try: - fd = os.open(_LOCAL_INTEGRITY_KEY_NAME, flags, 0o600, dir_fd=dir_fd) - except OSError as exc: - raise _symlink_oserror(exc, "local integrity key must not be a symlink") from exc - try: - os.fchmod(fd, 0o600) - current = os.fstat(fd) - if not stat.S_ISREG(current.st_mode): - raise RuntimeError("local integrity key must be a regular file") - if current.st_size == 0: - key = os.urandom(_LOCAL_INTEGRITY_KEY_BYTES) - written = os.write(fd, key) - if written != _LOCAL_INTEGRITY_KEY_BYTES: - raise RuntimeError("local integrity key write was short") - os.fsync(fd) - return key - if current.st_size != _LOCAL_INTEGRITY_KEY_BYTES: - raise RuntimeError("local integrity key must be 32 bytes") - os.lseek(fd, 0, os.SEEK_SET) - key = os.read(fd, _LOCAL_INTEGRITY_KEY_BYTES) - if len(key) != _LOCAL_INTEGRITY_KEY_BYTES: - raise RuntimeError("local integrity key must be 32 bytes") - if stat.S_IMODE(os.fstat(fd).st_mode) != 0o600: - raise RuntimeError("local integrity key mode must be 0600") - return key - finally: - os.close(fd) - - -def _history_file(repo: Path) -> Path | None: - history_dir = _git_common_dir(repo) / "agent-exec" - history_path = history_dir / "execution-history.jsonl" - if history_dir.is_symlink() or history_path.is_symlink(): - return None - return history_path - - -def _load_history_records(repo: Path) -> list[dict[str, Any]]: - history_path = _history_file(repo) - if history_path is None or not history_path.is_file(): - return [] - records: list[dict[str, Any]] = [] - for line in history_path.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - try: - data = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(data, dict): - records.append(data) - return records - - -def _transport_covers( - repo: Path, - record: dict[str, Any], - *, - head: str, - required: set[str], - key: bytes | None, -) -> bool: - if key is None or not _local_integrity_ok(key, record): - return False - if record.get("schema") != HISTORY_SCHEMA: - return False - if record.get("terminal_status") != "success": - return False - if not _same_repo(repo, record.get("repo")): - return False - transport = record.get("transport") - if not isinstance(transport, dict): - return False - ref = _valid_transport_ref(transport.get("ref")) - parent = transport.get("parent") - recorded_commit = transport.get("commit") - if ref is None or not isinstance(parent, str) or not isinstance(recorded_commit, str): - return False - if _resolve_commit(repo, ref) != recorded_commit: - return False - if _resolve_commit(repo, parent) != head: - return False - if _commit_parents(repo, recorded_commit) != [head]: - return False - changed = record.get("changed_paths") - if not isinstance(changed, list) or any(not isinstance(path, str) for path in changed): - return False - diff_paths = _diff_paths(repo, head, recorded_commit) - if _non_exempt(changed) != required or _non_exempt(diff_paths) != required: - return False - return _index_matches_commit(repo, recorded_commit, required) - - -def _apply_local_integrity_ok( - repo: Path, - receipt: dict[str, Any], - *, - commit: str, - base_head: str, -) -> bool: - key = _read_local_integrity_key(repo) - if key is None: - return False - run_id = receipt.get("run_id") - transport = receipt.get("transport") - changed = receipt.get("changed_paths") - if not isinstance(run_id, str) or not run_id or not isinstance(transport, dict): - return False - ref = transport.get("ref") - parent = transport.get("parent") - claimed = transport.get("commit") - if not isinstance(ref, str) or parent != base_head or claimed != commit: - return False - if _resolve_commit(repo, ref) != commit: - return False - if _commit_parents(repo, commit) != [base_head]: - return False - if not isinstance(changed, list) or any(not isinstance(path, str) for path in changed): - return False - if _non_exempt(changed) != _non_exempt(_diff_paths(repo, base_head, commit)): - return False - for record in _load_history_records(repo): - if record.get("run_id") != run_id or record.get("terminal_status") != "success": - continue - if not _same_repo(repo, record.get("repo")): - continue - record_paths = record.get("changed_paths") - if record_paths != changed: - continue - rec_transport = record.get("transport") - if not isinstance(rec_transport, dict): - continue - if rec_transport.get("ref") != ref or rec_transport.get("parent") != parent: - continue - if rec_transport.get("commit") != commit: - continue - if _local_integrity_ok(key, record): - return True - return False - - -def cmd_apply(repo: Path, receipt_path: Path) -> int: - """Fast-forward the clean current branch onto one success transport.""" - repo = repo.resolve() - text = "" - try: - text = receipt_path.read_text(encoding="utf-8") - receipt = json.loads(text) - except (OSError, UnicodeError, json.JSONDecodeError) as exc: - return _reject( - f"apply: invalid receipt: {exc}", - _secret_set(text), - EXIT_INVALID_CONFIG, - ) - secrets = _secret_set(text) - if not isinstance(receipt, dict) or receipt.get("schema") != RECEIPT_SCHEMA: - return _reject("apply: invalid receipt", secrets, EXIT_INVALID_CONFIG) - if not _same_repo(repo, receipt.get("repo")): - return _reject("apply: receipt repo mismatch", secrets) - if receipt.get("terminal_status") != "success": - message = "apply: non-success receipt" - reason = receipt.get("reason") - if isinstance(reason, str) and reason: - message = f"{message}: {reason}" - return _reject(message, secrets) - - base = receipt.get("base") - base_head = base.get("head") if isinstance(base, dict) else None - if not isinstance(base_head, str): - return _reject("apply: stale HEAD", secrets) - resolved_base = _resolve_commit(repo, base_head) - head = _resolve_commit(repo, "HEAD") - status = _git( - repo, "status", "--porcelain", "--untracked-files=all", check=False - ) - if status.returncode != 0: - return _reject( - f"apply: dirty worktree: {status.stderr.strip()}", - secrets, - ) - if status.stdout.strip(): - return _reject("apply: dirty worktree", secrets) - branch = _git(repo, "rev-parse", "--abbrev-ref", "HEAD", check=False) - if branch.returncode != 0 or branch.stdout.strip() in {"", "HEAD"}: - return _reject("apply: detached HEAD", secrets) - if head is None or resolved_base is None or head != resolved_base: - return _reject("apply: stale HEAD", secrets) - - transport = receipt.get("transport") - if not isinstance(transport, dict): - return _reject("apply: missing transport", secrets) - ref = _valid_transport_ref(transport.get("ref")) - parent = transport.get("parent") - if ref is None or not isinstance(parent, str) or not parent: - return _reject("apply: missing transport", secrets) - commit = _resolve_commit(repo, ref) - if commit is None: - return _reject("apply: missing transport", secrets) - if ( - _resolve_commit(repo, parent) != resolved_base - or _commit_parents(repo, commit) != [resolved_base] - ): - return _reject("apply: mismatched transport", secrets) - - try: - authorized = _apply_local_integrity_ok( - repo, receipt, commit=commit, base_head=resolved_base - ) - except Exception as exc: - return _reject(f"apply: no local integrity record: {exc}", secrets) - if not authorized: - return _reject("apply: no local integrity record", secrets) - - merge = _git(repo, "merge", "--ff-only", "--quiet", commit, check=False) - if merge.returncode != 0: - return _reject( - "apply: fast-forward failed: " + (merge.stderr.strip() or merge.stdout.strip()), - secrets, - ) - print(f"apply: fast-forwarded {branch.stdout.strip()} {head}..{commit}") - return EXIT_SUCCESS - - -def cmd_admit(repo: Path) -> int: - """Accept staged host-managed paths, or paths exactly covered by one transport. - - A pass is local integrity evidence (SELF), not GATE; invalid or missing - local records still reject. - """ - repo = repo.resolve() - secrets = _secret_set() - try: - staged = _staged_paths(repo) - required = _non_exempt(staged) - if not required: - print("admit: ok") - return EXIT_SUCCESS - head = _resolve_commit(repo, "HEAD") - if head is None: - return _reject("admit: no local integrity record", secrets) - history_text = "" - history_path = _history_file(repo) - if history_path is not None and history_path.is_file(): - history_text = history_path.read_text(encoding="utf-8") - secrets = _secret_set(history_text) - key = _read_local_integrity_key(repo) - for record in _load_history_records(repo): - if _transport_covers( - repo, record, head=head, required=required, key=key - ): - print("admit: ok") - return EXIT_SUCCESS - except Exception as exc: - return _reject(f"admit: no local integrity record: {exc}", secrets) - listed = ", ".join(sorted(required)) - return _reject(f"admit: no local integrity record for {listed}", secrets) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/agent_exec_adapters.py b/scripts/agent_exec_adapters.py deleted file mode 100644 index 26aac68f..00000000 --- a/scripts/agent_exec_adapters.py +++ /dev/null @@ -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 = "" - -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", -] diff --git a/scripts/agent_exec_config.py b/scripts/agent_exec_config.py deleted file mode 100644 index a65b0ded..00000000 --- a/scripts/agent_exec_config.py +++ /dev/null @@ -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) diff --git a/scripts/task.sh b/scripts/task.sh index 6a21012f..ff0d5e6d 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -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 | 源码外收据路径 | |---|---|---|---|---|---|---|---|---|---|---| diff --git a/tests/fixtures/agent-exec/README.md b/tests/fixtures/agent-exec/README.md deleted file mode 100644 index 63386177..00000000 --- a/tests/fixtures/agent-exec/README.md +++ /dev/null @@ -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-/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 与合入也不在本夹具的已执行范围内。 diff --git a/tests/test_agent_exec.py b/tests/test_agent_exec.py deleted file mode 100644 index e0e5f282..00000000 --- a/tests/test_agent_exec.py +++ /dev/null @@ -1,3734 +0,0 @@ -"""End-to-end execution controller contract for agent-exec (U3). - -U5 adds an opt-in real CLI probe. Default discovery does not contact providers. -Set RUN_REAL_AGENT_EXEC=1 and AGENT_EXEC_REAL_PROFILE to exactly one profile. -""" -from __future__ import annotations - -import hashlib -import hmac -import json -import os -import re -import shlex -import shutil -import stat -import subprocess -import tempfile -import textwrap -import threading -import time -import unittest -import uuid -from pathlib import Path -from unittest import mock - -from scripts import agent_exec -from scripts.agent_exec_config import NORMAL_ROLE_ADAPTERS - - -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, - ) - - -def _canonical_snapshot(repo: Path) -> dict[str, str]: - head = _git(repo, "rev-parse", "HEAD").stdout.strip() - tree = _git(repo, "rev-parse", "HEAD^{tree}").stdout.strip() - status = _git(repo, "status", "--porcelain=v2", "--branch").stdout - index = _git(repo, "write-tree").stdout.strip() - return {"head": head, "tree": tree, "status": status, "index": index} - - -def _pid_alive(pid: int) -> bool: - """Return true only for a running, non-zombie process.""" - try: - text = Path(f"/proc/{pid}/stat").read_text( - encoding="utf-8", errors="replace" - ) - except FileNotFoundError: - return False - rpar = text.rfind(")") - if rpar != -1 and rpar + 2 < len(text) and text[rpar + 2] == "Z": - return False - return ( - subprocess.run( - ["kill", "-0", str(pid)], check=False, capture_output=True - ).returncode - == 0 - ) - - -_NORMAL_ADAPTER_ORDER = ("cursor", "antigravity", "pi") - - -def _holder_name(adapter: str) -> str: - return f"{adapter}_holder" - - -def _route_normal_roles( - profiles: dict[str, dict[str, object]], - orders: list[list[str]], -) -> dict[str, dict[str, object]]: - """Place fixture profiles behind the cursor/antigravity/pi adapters. - - A ``fake`` profile is a ``_write_fake_worker`` executable; it takes the - next normal adapter not already enabled elsewhere. Any adapter a normal - role still lacks gets an enabled holder whose executable is absent, so it - is a safe ``unavailable`` after the configured profiles. - """ - routed = {name: dict(body) for name, body in profiles.items()} - used = { - body["adapter"] - for body in routed.values() - if body["adapter"] in _NORMAL_ADAPTER_ORDER and body.get("enabled", True) - } - for body in routed.values(): - if body["adapter"] != "fake": - continue - free = [adapter for adapter in _NORMAL_ADAPTER_ORDER if adapter not in used] - if not free: - raise ValueError("fixture has more fake profiles than normal adapters") - body["adapter"] = free[0] - used.add(free[0]) - for order in orders: - present = { - routed[name]["adapter"] - for name in order - if routed[name].get("enabled", True) - } - for adapter in _NORMAL_ADAPTER_ORDER: - if adapter in present: - continue - holder = _holder_name(adapter) - routed.setdefault( - holder, {"adapter": adapter, "executable": f"{adapter}-holder-absent"} - ) - order.append(holder) - return routed - - -def _write_config( - repo: Path, - *, - profiles: dict[str, dict[str, object]], - role_order: list[str], - review_order: list[str] | None = None, - repair_test_order: list[str] | None = None, - timeout_seconds: int = 30, - idle_timeout_seconds: int | None = None, - max_attempts_per_profile: int | None = None, - emergency_profile: str | None = None, - summary_bytes: int = 256, - raw_output_bytes: int = 1024, -) -> None: - implementation = list(role_order) - repair_test = list(repair_test_order if repair_test_order is not None else role_order) - repair = list(repair_test) - profiles = _route_normal_roles(profiles, [implementation, repair, repair_test]) - review_names = review_order if review_order is not None else implementation[:1] - profile_lines: list[str] = [] - custom_lines: list[str] = [] - for name, body in profiles.items(): - profile_lines.append(f" {name}:") - profile_lines.append(f" adapter: {body['adapter']}") - profile_lines.append(f" executable: {body['executable']}") - profile_lines.append(f" model: {body.get('model', 'auto')}") - profile_lines.append(f" enabled: {str(body.get('enabled', True)).lower()}") - if body["adapter"] == "custom": - custom_lines.append(f" {name}:") - custom_lines.append(' base_argv: ["${workspace}", "${unit_packet}", "${result_path}"]') - custom_lines.append(" run_everything_argv: []") - custom_lines.append(" workspace_argv: []") - custom_lines.append(" model_argv: []") - custom_lines.append(" prompt_transport: stdin") - custom_lines.append(" output_format: text") - implementation_line = ", ".join(implementation) - repair_line = ", ".join(repair) - test_line = ", ".join(repair_test) - review_line = ", ".join(review_names) - idle_line = ( - f" idle_timeout_seconds: {idle_timeout_seconds}\n" - if idle_timeout_seconds is not None - else "" - ) - attempts_line = ( - f" max_attempts_per_profile: {max_attempts_per_profile}\n" - if max_attempts_per_profile is not None - else "" - ) - emergency_line = ( - f" emergency_profile: {emergency_profile}\n" - if emergency_profile is not None - else "" - ) - block = textwrap.dedent( - f"""\ - schema: agent-execution/v1 - - execution: - fresh_process_per_unit: true - permission: run-everything - fallback_to_host: all-unavailable - max_parallel: 1 - timeout_seconds: {timeout_seconds} -{idle_line}{attempts_line}{emergency_line} summary_bytes: {summary_bytes} - raw_output_bytes: {raw_output_bytes} - - roles: - implementation: [{implementation_line}] - repair: [{repair_line}] - test: [{test_line}] - review: [{review_line}] - - profiles: - """ - ) - block += "\n".join(profile_lines) + "\n" - if custom_lines: - block += "\ncustom_adapters:\n" + "\n".join(custom_lines) + "\n" - (repo / "AGENT-EXECUTION.md").write_text( - "# Fixture\n\n```agent-execution\n" + block + "```\n", - encoding="utf-8", - ) - _git(repo, "add", "AGENT-EXECUTION.md") - _git(repo, "commit", "-m", "add agent-execution config") - - -def _write_unit( - path: Path, - *, - unit_id: str = "u-test", - goal: str = "Create marker file", - allowed_paths: list[str] | None = None, - timeout_seconds: int = 20, -) -> Path: - packet = { - "unit_id": unit_id, - "goal": goal, - "allowed_paths": allowed_paths or ["src/marker.txt"], - "verification": ["test -f src/marker.txt"], - "constraints": ["leave uncommitted"], - "network": False, - "external_cost": False, - "timeout_seconds": timeout_seconds, - } - path.write_text(json.dumps(packet, indent=2) + "\n", encoding="utf-8") - return path - - -_FAKE_ENVELOPE_PY = textwrap.dedent( - """\ - import json, sys - adapter, path = sys.argv[1], sys.argv[2] - with open(path, encoding="utf-8") as handle: - text = handle.read() - if text: - if adapter == "cursor": - event = {"type": "result", "subtype": "success", "is_error": False, "result": text} - elif adapter == "antigravity": - event = {"event": "result", "result": {"response": text}} - else: - event = { - "type": "message_end", - "message": { - "role": "assistant", - "content": [{"type": "text", "text": text}], - "stopReason": "stop", - }, - } - sys.stdout.write("\\n" + json.dumps(event) + "\\n") - """ -) - - -def _fake_adapter_prelude(scratch: Path) -> str: - """Run a ``$1``=workspace/``$3``=result body behind cursor, agy, or PI argv. - - Text the body writes to ``$3`` becomes that adapter's final envelope on - exit, so terminal decoding still goes through the real adapter path. - """ - return textwrap.dedent( - f"""\ - _fake_adapter="" - for _fake_arg in "$@"; do - case "$_fake_arg" in - --force) _fake_adapter=cursor ;; - --dangerously-skip-permissions) _fake_adapter=antigravity ;; - --approve) _fake_adapter=pi ;; - esac - done - if [ -n "$_fake_adapter" ]; then - _fake_result=$(mktemp {shlex.quote(str(scratch))}/fake-terminal.XXXXXX) || exit 70 - _fake_emit() {{ - _fake_code=$? - python3 -c {shlex.quote(_FAKE_ENVELOPE_PY)} "$_fake_adapter" "$_fake_result" - rm -f "$_fake_result" - exit "$_fake_code" - }} - trap _fake_emit EXIT - set -- "$(pwd -P)" "" "$_fake_result" - fi - """ - ) - - -def _write_fake_worker( - bindir: Path, - name: str, - *, - body: str, - help_text: str | None = None, - native: bool = False, -) -> Path: - """Write a fake CLI. - - By default the body uses the ``$1``/``$3`` convention behind any normal - adapter. ``native=True`` leaves argv and stdout exactly as the adapter - delivers them. - """ - if help_text is None: - help_text = "custom worker\n" if native else _normal_help() - prelude = "" if native else _fake_adapter_prelude(bindir.parent) - path = bindir / name - path.write_text( - "#!/bin/sh\n" - 'if [ "$1" = "--version" ] || [ "$1" = "-v" ] || [ "$1" = "-V" ]; then\n' - ' echo "0.0.1-test"\n' - " exit 0\n" - "fi\n" - 'if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then\n' - f" cat <<'HELP'\n{help_text}HELP\n" - " exit 0\n" - "fi\n" - + prelude - + body, - encoding="utf-8", - ) - path.chmod(path.stat().st_mode | stat.S_IEXEC) - return path - - -def _normal_help() -> str: - from scripts.agent_exec_adapters import _REQUIRED_HELP_MARKERS - - markers = { - marker - for adapter in _NORMAL_ADAPTER_ORDER - for marker in _REQUIRED_HELP_MARKERS[adapter] - } - return "\n".join(sorted(markers)) + "\n" - - -def _codex_help() -> str: - from scripts.agent_exec_adapters import _REQUIRED_HELP_MARKERS - - return "\n".join(_REQUIRED_HELP_MARKERS["codex"]) + "\n" - - -def _pi_help() -> str: - from scripts.agent_exec_adapters import _REQUIRED_HELP_MARKERS - - return "\n".join(_REQUIRED_HELP_MARKERS["pi"]) + "\n" - - -def _antigravity_help() -> str: - from scripts.agent_exec_adapters import _REQUIRED_HELP_MARKERS - - return "\n".join(_REQUIRED_HELP_MARKERS["antigravity"]) + "\n" - - -def _terminal_json(status: str = "completed") -> str: - return json.dumps( - { - "terminal_status": status, - "summary": "done", - "changed_files": ["src/marker.txt"], - "evidence": ["wrote marker"], - "scope_expansion": None, - }, - separators=(",", ":"), - ) - - -def _agy_stream_events(final_text: str, *, is_error: bool = False) -> list[str]: - """Representative Antigravity ``--output-format stream-json`` lines.""" - conv = "b97cb31f-a6bd-40bf-aca8-aaeaa2e1999a" - result: dict[str, object] = { - "conversation_id": conv, - "response": final_text, - "num_turns": 2, - "duration_ms": 10, - } - if is_error: - result["is_error"] = True - result["error"] = "model error" - events: list[dict[str, object]] = [ - {"event": "init", "conversation_id": conv, "init": {"cwd": "/w", "tools": ["run_command"]}}, - { - "event": "step_update", - "step_update": {"conversation_id": conv, "step_index": 0, "state": "DONE", "step_type": "user_input"}, - }, - { - "event": "step_update", - "step_update": {"conversation_id": conv, "step_index": 1, "state": "DONE", "step_type": "agent_response"}, - }, - {"event": "result", "conversation_id": conv, "result": result}, - ] - return [json.dumps(event, separators=(",", ":")) for event in events] - - -def _pi_json_events( - final_text: str, - *, - prompt_text: str = "unit", - stop_reason: str = "stop", - agent_end_padding: int = 0, -) -> list[str]: - """Representative PI ``--mode json`` lines (see pi docs/json.md).""" - user = {"role": "user", "content": prompt_text, "timestamp": 1} - assistant = { - "role": "assistant", - "content": [ - {"type": "thinking", "thinking": "plan"}, - {"type": "text", "text": final_text}, - ], - "stopReason": stop_reason, - } - padding = {"role": "toolResult", "content": [{"type": "text", "text": "x" * agent_end_padding}]} - events: list[dict[str, object]] = [ - {"type": "session", "version": 3, "id": "s", "timestamp": "2026-10-15T00:00:00.000Z", "cwd": "/w"}, - {"type": "agent_start"}, - {"type": "turn_start"}, - {"type": "message_start", "message": user}, - {"type": "message_end", "message": user}, - {"type": "message_start", "message": {"role": "assistant", "content": [], "stopReason": "pending"}}, - { - "type": "message_update", - "usage": {"input": 1, "output": 1}, - "assistantMessageEvent": {"type": "text_delta", "contentIndex": 1, "delta": final_text}, - }, - {"type": "message_end", "message": assistant}, - {"type": "turn_end", "message": assistant, "toolResults": []}, - {"type": "agent_end", "messages": [user, padding, assistant], "willRetry": False}, - {"type": "agent_settled"}, - ] - return [json.dumps(event, separators=(",", ":")) for event in events] - - -class AgentExecControllerTests(unittest.TestCase): - def setUp(self) -> None: - self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-ctrl-") - self.addCleanup(self.tmp.cleanup) - self.root = Path(self.tmp.name) - self.bindir = self.root / "bin" - self.bindir.mkdir() - self.repo = self.root / "repo" - self.repo.mkdir() - self.receipts = self.root / "receipts" - self.receipts.mkdir() - self._old_path = os.environ.get("PATH", "") - os.environ["PATH"] = f"{self.bindir}{os.pathsep}{self._old_path}" - self.addCleanup(self._restore_path) - self._init_repo() - - def _restore_path(self) -> None: - os.environ["PATH"] = self._old_path - - def _init_repo(self) -> None: - _git(self.repo, "init") - _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") - (self.repo / "src").mkdir() - (self.repo / "src" / ".gitkeep").write_text("", encoding="utf-8") - _git(self.repo, "add", "README.md", "src/.gitkeep") - _git(self.repo, "commit", "-m", "init") - _git(self.repo, "checkout", "-b", "feature/agent-exec-test") - self.base_snap = _canonical_snapshot(self.repo) - - def _run_cli(self, *args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: - cmd_env = os.environ.copy() - if env: - cmd_env.update(env) - cmd_env["PYTHONDONTWRITEBYTECODE"] = "1" - workspace = Path(__file__).resolve().parents[1] - existing = cmd_env.get("PYTHONPATH", "") - cmd_env["PYTHONPATH"] = ( - f"{workspace}{os.pathsep}{existing}" if existing else str(workspace) - ) - return subprocess.run( - ["python3", "-m", "scripts.agent_exec", *args], - cwd=str(self.root), - capture_output=True, - text=True, - env=cmd_env, - ) - - def _assert_canonical_untouched(self) -> None: - self.assertEqual(_canonical_snapshot(self.repo), self.base_snap) - - def _configure(self, **kwargs: object) -> None: - _write_config(self.repo, **kwargs) # type: ignore[arg-type] - self.base_snap = _canonical_snapshot(self.repo) - - def test_validate_does_not_contact_provider(self) -> None: - marker = self.root / "provider-contacted" - _write_fake_worker( - self.bindir, - "cursor-agent", - body=( - f'echo contacted > "{marker}"\n' - "exit 1\n" - ), - help_text=textwrap.dedent( - """\ - Usage: agent [options] - -p, --print - --force - --sandbox - --trust - --workspace - --model - --output-format - """ - ), - ) - self._configure( - profiles={ - "cursor": { - "adapter": "cursor", - "executable": "cursor-agent", - "model": "auto", - } - }, - role_order=["cursor"], - ) - proc = self._run_cli("validate", "--repo", str(self.repo)) - self.assertEqual(proc.returncode, 0, proc.stderr + proc.stdout) - self.assertFalse(marker.exists()) - self._assert_canonical_untouched() - - def test_run_uses_fresh_process_and_worktree_per_attempt(self) -> None: - meta = self.root / "meta" - meta.mkdir() - _write_fake_worker( - self.bindir, - "worker-b", - body=textwrap.dedent( - f"""\ - workspace="$1" - result="$3" - echo "$$" >> "{meta}/pids" - echo "$workspace" >> "{meta}/wts" - mkdir -p "$workspace/src" - printf 'ok\\n' > "$workspace/src/marker.txt" - printf '%s\\n' '{{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":["wrote marker"],"scope_expansion":null}}' > "$result" - exit 0 - """ - ), - ) - self._configure( - profiles={"b": {"adapter": "fake", "executable": "worker-b"}}, - role_order=["b"], - ) - loaded = agent_exec.load_config(self.repo) - self.assertEqual(loaded.profiles["b"].adapter, "cursor") - for role in ("implementation", "repair", "test"): - self.assertEqual(loaded.roles[role][0], "b") - adapters = [loaded.profiles[name].adapter for name in loaded.roles[role]] - self.assertEqual(sorted(adapters), sorted(NORMAL_ROLE_ADAPTERS)) - unit = _write_unit(self.root / "unit.json") - for label in ("first", "second"): - receipt = self.receipts / f"fresh-{label}.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual( - proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout - ) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["schema"], "agent-exec-receipt/v1") - self.assertEqual(data["terminal_status"], "success") - pids = (meta / "pids").read_text(encoding="utf-8").split() - wts = (meta / "wts").read_text(encoding="utf-8").split() - self.assertEqual(len(pids), 2) - self.assertNotEqual(pids[0], pids[1]) - self.assertNotEqual(wts[0], wts[1]) - for wt in wts: - self.assertTrue(wt.startswith(f"/tmp/agent-exec-{os.getuid()}/")) - self._assert_canonical_untouched() - - def test_missing_profiles_fall_through_in_declared_order(self) -> None: - log = self.root / "order.log" - log.write_text("", encoding="utf-8") - _write_fake_worker( - self.bindir, - "second", - body=textwrap.dedent( - f"""\ - echo second >> "{log}" - workspace="$1" - result="$3" - mkdir -p "$workspace/src" - printf 'ok\\n' > "$workspace/src/marker.txt" - printf '%s\\n' '{{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}}' > "$result" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "missing": {"adapter": "fake", "executable": "does-not-exist-xyz"}, - "second": {"adapter": "fake", "executable": "second"}, - }, - role_order=["missing", "second"], - ) - unit = _write_unit(self.root / "unit.json") - receipt = self.receipts / "order.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - names = [a["profile"] for a in data["attempts"]] - self.assertEqual(names, ["missing", "second"]) - self.assertEqual(data["attempts"][0]["terminal_status"], "unavailable") - self.assertEqual(log.read_text(encoding="utf-8").strip(), "second") - self._assert_canonical_untouched() - - def test_all_unavailable_exhaustion_does_not_authorize_host(self) -> None: - self._configure( - profiles={ - "m1": {"adapter": "fake", "executable": "gone-one"}, - "m2": {"adapter": "fake", "executable": "gone-two"}, - }, - role_order=["m1", "m2"], - ) - unit = _write_unit(self.root / "unit.json") - receipt = self.receipts / "fallback.json" - before = list(self.repo.rglob("*")) - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual( - proc.returncode, - agent_exec.EXIT_UNAVAILABLE_EXHAUSTION, - proc.stderr + proc.stdout, - ) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "unavailable") - self.assertNotEqual(data["terminal_status"], "host-fallback-authorized") - self.assertEqual( - [a["profile"] for a in data["attempts"]], ["m1", "m2", _holder_name("pi")] - ) - self.assertTrue(all(a["terminal_status"] == "unavailable" for a in data["attempts"])) - self.assertEqual(data.get("host_fallback_authorizations"), 0) - # Controller must not implement the unit itself. - history_root = self.repo / ".git" / "agent-exec" - after = [ - path - for path in self.repo.rglob("*") - if path != history_root and history_root not in path.parents - ] - self.assertEqual(before, after) - self.assertEqual(data["history"]["status"], "recorded") - self.assertTrue((history_root / "execution-history.jsonl").is_file()) - self.assertFalse((self.repo / "src" / "marker.txt").exists()) - self.assertIsNone(data.get("host_scope")) - self._assert_canonical_untouched() - - def test_legacy_exhaustion_without_emergency_does_not_authorize_host(self) -> None: - self._configure( - profiles={ - "m1": {"adapter": "fake", "executable": "gone-one"}, - "m2": {"adapter": "fake", "executable": "gone-two"}, - }, - role_order=["m1", "m2"], - ) - unit = _write_unit(self.root / "unit.json") - receipt = self.receipts / "no-host.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual( - proc.returncode, - agent_exec.EXIT_UNAVAILABLE_EXHAUSTION, - proc.stderr + proc.stdout, - ) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "unavailable") - self.assertNotEqual(data["terminal_status"], "host-fallback-authorized") - self.assertEqual(data.get("host_fallback_authorizations"), 0) - self.assertIsNone(data.get("host_scope")) - self.assertFalse((self.repo / "src" / "marker.txt").exists()) - self._assert_canonical_untouched() - - def test_declared_offline_launched_failure_is_failed_unknown_and_stops(self) -> None: - """network=false/external_cost=false are declarations, not isolation proof.""" - launched = self.root / "declared-offline-next.log" - launched.write_text("", encoding="utf-8") - _write_fake_worker( - self.bindir, - "dirty", - body=textwrap.dedent( - """\ - workspace="$1" - mkdir -p "$workspace/src" - printf 'tmp\\n' > "$workspace/src/marker.txt" - exit 9 - """ - ), - ) - _write_fake_worker( - self.bindir, - "ok", - body=textwrap.dedent( - f"""\ - echo next >> "{launched}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "dirty": {"adapter": "fake", "executable": "dirty"}, - "ok": {"adapter": "fake", "executable": "ok"}, - }, - role_order=["dirty", "ok"], - ) - unit = _write_unit(self.root / "unit.json") - packet = json.loads(unit.read_text(encoding="utf-8")) - self.assertFalse(packet["network"]) - self.assertFalse(packet["external_cost"]) - receipt = self.receipts / "declared-offline.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "failed-unknown") - self.assertEqual(len(data["attempts"]), 1) - attempt = data["attempts"][0] - self.assertEqual(attempt["terminal_status"], "failed-unknown") - self.assertFalse(attempt["cleanup"]["exact_restore"]) - self.assertTrue(attempt["workspace_preserved"]) - self.assertFalse(attempt["cleanup"]["declared_network"]) - self.assertFalse(attempt["cleanup"]["declared_external_cost"]) - self.assertTrue( - (Path(attempt["worktree"]) / "src" / "marker.txt").is_file() - ) - self.assertEqual(launched.read_text(encoding="utf-8"), "") - self.assertEqual(data.get("host_fallback_authorizations", 0), 0) - self._assert_canonical_untouched() - - def test_safe_unavailable_retries_profile_before_next_success(self) -> None: - launches = self.root / "profile-launches.log" - launches.write_text("", encoding="utf-8") - _write_fake_worker( - self.bindir, - "thenok", - body=textwrap.dedent( - f"""\ - printf 'thenok\\n' >> "{launches}" - workspace="$1" - result="$3" - mkdir -p "$workspace/src" - printf 'final\\n' > "$workspace/src/marker.txt" - printf '%s\\n' '{{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}}' > "$result" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "retryme": {"adapter": "fake", "executable": "retryme-missing"}, - "thenok": {"adapter": "fake", "executable": "thenok"}, - }, - role_order=["retryme", "thenok"], - max_attempts_per_profile=3, - ) - loaded = agent_exec.load_config(self.repo) - self.assertEqual(loaded.execution.max_attempts_per_profile, 3) - unit = _write_unit(self.root / "unit.json") - receipt = self.receipts / "retry-ordinal.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) - observed = [ - line for line in launches.read_text(encoding="utf-8").splitlines() if line - ] - self.assertEqual(observed, ["thenok"]) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual( - [ - ( - attempt["profile"], - attempt.get("attempt_ordinal"), - attempt["terminal_status"], - ) - for attempt in data["attempts"] - ], - [ - ("retryme", 1, "unavailable"), - ("retryme", 2, "unavailable"), - ("retryme", 3, "unavailable"), - ("thenok", 1, "success"), - ], - ) - self.assertTrue( - all(attempt["cleanup"]["exact_restore"] for attempt in data["attempts"][:3]) - ) - self._assert_canonical_untouched() - - def test_emergency_profile_runs_once_after_regular_safe_exhaustion(self) -> None: - launches = self.root / "emergency-launches.log" - launches.write_text("", encoding="utf-8") - # Installed but missing required capability flags: each attempt is a - # safe pre-launch `unavailable`, which is what may route onward. - normal = ( - ("cursor", "cursor-regular", "incompatible\n"), - ("antigravity", "agy-regular", "incompatible\n"), - ("pi", "pi-regular", "incompatible\n"), - ) - for adapter, executable, help_text in normal: - _write_fake_worker( - self.bindir, - executable, - help_text=help_text, - native=True, - body=textwrap.dedent( - f"""\ - printf '{adapter}\\n' >> "{launches}" - exit 9 - """ - ), - ) - _write_fake_worker( - self.bindir, - "codex", - help_text=_codex_help(), - native=True, - body=textwrap.dedent( - f"""\ - if [ "$1" = "exec" ] && [ "$2" = "--help" ]; then - exec "$0" --help - fi - printf 'codex\\n' >> "{launches}" - result="" - prev="" - for arg in "$@"; do - if [ "$prev" = "--output-last-message" ]; then - result="$arg" - fi - prev="$arg" - done - if [ -z "$result" ]; then - echo "missing --output-last-message" >&2 - exit 2 - fi - mkdir -p src - printf 'final\\n' > src/marker.txt - printf '%s\\n' '{{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}}' > "$result" - exit 0 - """ - ), - ) - order = [adapter for adapter, _, _ in normal] - profiles: dict[str, dict[str, object]] = { - adapter: {"adapter": adapter, "executable": executable, "model": "auto"} - for adapter, executable, _ in normal - } - profiles["codex"] = {"adapter": "codex", "executable": "codex", "model": "auto"} - self._configure( - profiles=profiles, - role_order=order, - repair_test_order=order, - max_attempts_per_profile=3, - emergency_profile="codex", - ) - loaded = agent_exec.load_config(self.repo) - self.assertEqual(loaded.execution.max_attempts_per_profile, 3) - self.assertEqual(loaded.execution.emergency_profile, "codex") - self.assertIn("codex", loaded.profiles) - for role in ("implementation", "repair", "test"): - self.assertEqual(loaded.roles[role], tuple(order)) - for adapter in order: - self.assertEqual(loaded.profiles[adapter].adapter, adapter) - self.assertEqual(loaded.profiles["codex"].adapter, "codex") - for role_names in loaded.roles.values(): - self.assertNotIn("codex", role_names) - unit = _write_unit(self.root / "unit.json") - receipt = self.receipts / "emergency-fallback.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - data = json.loads(receipt.read_text(encoding="utf-8")) - recorded = [ - ( - attempt["profile"], - attempt.get("attempt_ordinal"), - attempt["terminal_status"], - attempt["emergency_fallback"], - ) - for attempt in data["attempts"] - ] - self.assertEqual( - recorded, - [ - (adapter, ordinal, "unavailable", False) - for adapter in order - for ordinal in (1, 2, 3) - ] - + [("codex", 1, "success", True)], - ) - self.assertTrue(recorded[-1][3]) - self.assertFalse(any(flag for _, _, _, flag in recorded[:-1])) - self.assertEqual(data.get("host_fallback_authorizations"), 0) - observed = [ - line for line in launches.read_text(encoding="utf-8").splitlines() if line - ] - self.assertEqual(observed, ["codex"]) - self.assertEqual(data["attempts"][-1]["summary"], "done") - self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) - self._assert_canonical_untouched() - - def test_failed_unknown_blocks_fallback(self) -> None: - launched = self.root / "launched.log" - launched.write_text("", encoding="utf-8") - _write_fake_worker( - self.bindir, - "unknown", - body=textwrap.dedent( - """\ - workspace="$1" - mkdir -p "$workspace/src" - printf 'x\\n' > "$workspace/src/marker.txt" - # Abnormal termination / disconnect: cannot prove absence of side effects. - kill -KILL $$ - """ - ), - ) - _write_fake_worker( - self.bindir, - "next", - body=textwrap.dedent( - f"""\ - echo next >> "{launched}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "unknown": {"adapter": "fake", "executable": "unknown"}, - "next": {"adapter": "fake", "executable": "next"}, - }, - role_order=["unknown", "next"], - ) - unit = _write_unit(self.root / "unit.json") - receipt = self.receipts / "unknown.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "failed-unknown") - self.assertEqual(len(data["attempts"]), 1) - self.assertEqual(launched.read_text(encoding="utf-8"), "") - self.assertNotEqual(data["terminal_status"], "host-fallback-authorized") - self._assert_canonical_untouched() - - def test_scope_expansion_preserves_workspace_and_blocks(self) -> None: - launched = self.root / "scope-next.log" - launched.write_text("", encoding="utf-8") - _write_fake_worker( - self.bindir, - "scope", - body=textwrap.dedent( - """\ - workspace="$1" - result="$3" - printf 'bad\\n' > "$workspace/README.md" - printf '%s\\n' '{"terminal_status":"completed","summary":"expanded","changed_files":["README.md"],"evidence":[],"scope_expansion":null}' > "$result" - exit 0 - """ - ), - ) - _write_fake_worker( - self.bindir, - "next", - body=textwrap.dedent( - f"""\ - echo next >> "{launched}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "scope": {"adapter": "fake", "executable": "scope"}, - "next": {"adapter": "fake", "executable": "next"}, - }, - role_order=["scope", "next"], - ) - unit = _write_unit(self.root / "unit.json") - receipt = self.receipts / "scope.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual( - proc.returncode, agent_exec.EXIT_SCOPE_EXPANSION, proc.stderr + proc.stdout - ) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "scope-expansion") - self.assertEqual(len(data["attempts"]), 1) - self.assertEqual(launched.read_text(encoding="utf-8"), "") - attempt = data["attempts"][0] - self.assertTrue(Path(attempt["worktree"]).exists()) - self.assertTrue(attempt.get("workspace_preserved")) - self._assert_canonical_untouched() - - def test_worker_commit_or_head_move_is_rejected(self) -> None: - """Worker HEAD/commit, staged index, and config/ref mutations are fail-stop.""" - cases = { - "commit": textwrap.dedent( - """\ - workspace="$1" - mkdir -p "$workspace/src" - printf 'c\\n' > "$workspace/src/marker.txt" - git -C "$workspace" add src/marker.txt - git -C "$workspace" -c user.email=t@e -c user.name=t commit -m 'worker commit' - exit 0 - """ - ), - "staged": textwrap.dedent( - """\ - workspace="$1" - mkdir -p "$workspace/src" - printf 'staged\\n' > "$workspace/src/marker.txt" - git -C "$workspace" add src/marker.txt - exit 0 - """ - ), - "config_ref": textwrap.dedent( - """\ - workspace="$1" - git -C "$workspace" config --local agentexec.probe 1 - git -C "$workspace" update-ref refs/agent-exec-probe/tmp HEAD - exit 0 - """ - ), - } - for label, body in cases.items(): - with self.subTest(case=label): - launched = self.root / f"commit-next-{label}.log" - launched.write_text("", encoding="utf-8") - worker = f"committer-{label}" - _write_fake_worker(self.bindir, worker, body=body) - _write_fake_worker( - self.bindir, - f"next-{label}", - body=textwrap.dedent( - f"""\ - echo next >> "{launched}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "committer": {"adapter": "fake", "executable": worker}, - "next": { - "adapter": "fake", - "executable": f"next-{label}", - }, - }, - role_order=["committer", "next"], - ) - self.base_snap = _canonical_snapshot(self.repo) - unit = _write_unit(self.root / f"unit-{label}.json") - receipt = self.receipts / f"commit-{label}.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual( - proc.returncode, - agent_exec.EXIT_BLOCKED, - proc.stderr + proc.stdout, - ) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "failed-unknown") - self.assertEqual(len(data["attempts"]), 1) - self.assertEqual(launched.read_text(encoding="utf-8"), "") - self.assertTrue(data["attempts"][0].get("workspace_preserved")) - self._assert_canonical_untouched() - - def test_untracked_and_binary_files_enter_transport_inventory(self) -> None: - _write_fake_worker( - self.bindir, - "binwriter", - body=textwrap.dedent( - """\ - workspace="$1" - result="$3" - mkdir -p "$workspace/src" - printf 'text\\n' > "$workspace/src/marker.txt" - printf 'bin\\0data' > "$workspace/src/blob.bin" - printf '%s\\n' '{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt","src/blob.bin"],"evidence":[],"scope_expansion":null}' > "$result" - exit 0 - """ - ), - ) - self._configure( - profiles={"binwriter": {"adapter": "fake", "executable": "binwriter"}}, - role_order=["binwriter"], - ) - unit = _write_unit( - self.root / "unit.json", - allowed_paths=["src/marker.txt", "src/blob.bin"], - ) - receipt = self.receipts / "binary.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - changed = set(data["changed_paths"]) - self.assertIn("src/marker.txt", changed) - self.assertIn("src/blob.bin", changed) - transport = data["transport"] - self.assertTrue(transport["ref"].startswith("refs/agent-exec/")) - self._assert_canonical_untouched() - - def test_timeout_kills_process_group_and_records_receipt(self) -> None: - child_pid_file = self.root / "child.pid" - _write_fake_worker( - self.bindir, - "sleeper", - body=textwrap.dedent( - f"""\ - # Start a child in the same process group - (sleep 120 & echo $! > "{child_pid_file}"; wait) - """ - ), - ) - self._configure( - profiles={"sleeper": {"adapter": "fake", "executable": "sleeper"}}, - role_order=["sleeper"], - timeout_seconds=2, - ) - unit = _write_unit(self.root / "unit.json", timeout_seconds=2) - receipt = self.receipts / "timeout.json" - t0 = time.time() - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - elapsed = time.time() - t0 - self.assertLess(elapsed, 15) - self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "failed-unknown") - self.assertTrue(data["attempts"][0].get("timed_out")) - self.assertFalse(data["attempts"][0].get("idle_timed_out")) - self.assertIn("timed out", data["reason"]) - self.assertNotIn("idle timeout", data["reason"]) - if child_pid_file.exists(): - child = int(child_pid_file.read_text(encoding="utf-8").strip()) - # A zombie is terminated even if a container PID 1 has not reaped it. - self.assertFalse(_pid_alive(child)) - self._assert_canonical_untouched() - - def test_idle_timeout_kills_silent_worker_before_total(self) -> None: - child_pid_file = self.root / "idle-child.pid" - launched = self.root / "idlenext.log" - launched.write_text("", encoding="utf-8") - secret = "sk-idletimeoutdeadbeef123456" - _write_fake_worker( - self.bindir, - "silent", - body=textwrap.dedent( - f"""\ - # No stdout/stderr. Child stays in this process group. - (sleep 120 & echo $! > "{child_pid_file}"; wait) - """ - ), - ) - _write_fake_worker( - self.bindir, - "idlenext", - body=textwrap.dedent( - f"""\ - echo next >> "{launched}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "silent": {"adapter": "fake", "executable": "silent"}, - "idlenext": {"adapter": "fake", "executable": "idlenext"}, - }, - role_order=["silent", "idlenext"], - timeout_seconds=40, - idle_timeout_seconds=2, - ) - unit = _write_unit(self.root / "unit.json", timeout_seconds=40) - receipt = self.receipts / "idle.json" - t0 = time.time() - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - env={"AGENT_EXEC_TEST_SECRET": secret}, - ) - elapsed = time.time() - t0 - self.assertLess(elapsed, 20) - self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout) - text = receipt.read_text(encoding="utf-8") - self.assertNotIn(secret, text) - data = json.loads(text) - self.assertEqual(data["terminal_status"], "failed-unknown") - self.assertEqual(data["reason"], "idle timeout; process group terminated") - self.assertEqual(data.get("host_fallback_authorizations", 0), 0) - self.assertNotEqual(data["terminal_status"], "host-fallback-authorized") - self.assertEqual(len(data["attempts"]), 1) - self.assertEqual(launched.read_text(encoding="utf-8"), "") - attempt = data["attempts"][0] - self.assertEqual(attempt["terminal_status"], "failed-unknown") - self.assertTrue(attempt["idle_timed_out"]) - self.assertFalse(attempt["timed_out"]) - self.assertTrue(attempt["workspace_preserved"]) - self.assertTrue(Path(attempt["worktree"]).is_dir()) - cleanup = attempt["cleanup"] - self.assertFalse(cleanup["exact_restore"]) - self.assertEqual(cleanup["reason"], "idle timeout") - self.assertTrue(child_pid_file.is_file()) - child = int(child_pid_file.read_text(encoding="utf-8").strip()) - # A zombie is terminated even if a container PID 1 has not reaped it. - self.assertFalse(_pid_alive(child)) - self._assert_canonical_untouched() - - def test_progress_keeps_worker_alive_past_one_idle_interval(self) -> None: - _write_fake_worker( - self.bindir, - "paced", - body=textwrap.dedent( - """\ - workspace="$1" - result="$3" - python3 - <<'PY' - import sys, time - end = time.monotonic() + 5.0 - while time.monotonic() < end: - sys.stdout.buffer.write(b"tick\\n") - sys.stdout.buffer.flush() - time.sleep(0.5) - PY - mkdir -p "$workspace/src" - printf 'ok\\n' > "$workspace/src/marker.txt" - printf '%s\\n' '{"terminal_status":"completed","summary":"progress","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}' > "$result" - exit 0 - """ - ), - ) - self._configure( - profiles={"paced": {"adapter": "fake", "executable": "paced"}}, - role_order=["paced"], - timeout_seconds=40, - idle_timeout_seconds=2, - ) - unit = _write_unit(self.root / "unit.json", timeout_seconds=40) - receipt = self.receipts / "progress.json" - t0 = time.time() - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - elapsed = time.time() - t0 - self.assertGreaterEqual(elapsed, 5) - self.assertLess(elapsed, 25) - self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "success") - self.assertIn("src/marker.txt", data["changed_paths"]) - attempt = data["attempts"][0] - self.assertFalse(attempt["idle_timed_out"]) - self.assertFalse(attempt["timed_out"]) - raw = Path(attempt["raw_log"]).read_text(encoding="utf-8") - self.assertGreaterEqual(raw.count("tick"), 8) - self._assert_canonical_untouched() - - def test_stdin_and_stderr_progress_resets_idle_deadline(self) -> None: - code = "\n".join( - [ - "import sys, time", - "end = time.monotonic() + 3.0", - "while time.monotonic() < end:", - " data = sys.stdin.buffer.read(4096)", - " sys.stderr.buffer.write(b'e\\n')", - " sys.stderr.buffer.flush()", - " time.sleep(0.25)", - " if not data:", - " continue", - ] - ) - raw_log = self.root / "paced-raw.log" - t0 = time.monotonic() - meta = agent_exec._supervise( - ("python3", "-c", code), - cwd=self.root, - stdin=b"x" * 200_000, - timeout=8, - idle_timeout=1, - raw_log=raw_log, - raw_cap=1024, - secret_values=set(), - ) - elapsed = time.monotonic() - t0 - self.assertEqual(meta["exit_code"], 0, meta) - self.assertFalse(meta["idle_timed_out"]) - self.assertFalse(meta["timed_out"]) - self.assertGreaterEqual(elapsed, 2.5) - self.assertLess(elapsed, 8) - - def test_summary_and_raw_log_caps_are_enforced(self) -> None: - _write_fake_worker( - self.bindir, - "noisy", - body=textwrap.dedent( - """\ - workspace="$1" - result="$3" - python3 - <<'PY' - import sys - chunk = b"A" * (512 * 1024) - sys.stdout.buffer.write(chunk) - sys.stdout.buffer.flush() - sys.stderr.buffer.write(b"B" * (512 * 1024)) - sys.stderr.buffer.flush() - PY - mkdir -p "$workspace/src" - printf 'ok\\n' > "$workspace/src/marker.txt" - printf '%s\\n' '{"terminal_status":"completed","summary":"'"$(python3 -c 'print("S"*2000)')"'","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}' > "$result" - exit 0 - """ - ), - ) - self._configure( - profiles={"noisy": {"adapter": "fake", "executable": "noisy"}}, - role_order=["noisy"], - summary_bytes=64, - raw_output_bytes=128, - ) - unit = _write_unit(self.root / "unit.json") - receipt = self.receipts / "caps.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - summary = data["summary"] - self.assertLessEqual(len(summary.encode("utf-8")), 64) - attempt = data["attempts"][0] - raw_path = Path(attempt["raw_log"]) - self.assertTrue(raw_path.is_file()) - self.assertLessEqual(raw_path.stat().st_size, 128) - cleanup = attempt.get("cleanup") or {} - self.assertLessEqual( - int(cleanup.get("stdout_tail_bytes", agent_exec.OUTPUT_TAIL_BYTES)), - agent_exec.OUTPUT_TAIL_BYTES, - ) - self.assertLessEqual( - int(cleanup.get("stderr_tail_bytes", agent_exec.OUTPUT_TAIL_BYTES)), - agent_exec.OUTPUT_TAIL_BYTES, - ) - self._assert_canonical_untouched() - - def test_receipt_redacts_environment_secrets(self) -> None: - secret = "sk-testdeadbeefdeadbeef123456" - _write_fake_worker( - self.bindir, - "leaky", - body=textwrap.dedent( - """\ - workspace="$1" - result="$3" - echo "token=$AGENT_EXEC_TEST_SECRET" - mkdir -p "$workspace/src" - printf 'ok\\n' > "$workspace/src/marker.txt" - printf '%s\\n' '{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}' > "$result" - exit 0 - """ - ), - ) - self._configure( - profiles={"leaky": {"adapter": "fake", "executable": "leaky"}}, - role_order=["leaky"], - ) - unit = _write_unit(self.root / "unit.json") - receipt = self.receipts / "redact.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - env={"AGENT_EXEC_TEST_SECRET": secret}, - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) - text = receipt.read_text(encoding="utf-8") - self.assertNotIn(secret, text) - data = json.loads(text) - self.assertIn("***", json.dumps(data.get("attempts", [{}])[0].get("summary", "***"))) - self._assert_canonical_untouched() - - def test_success_creates_base_parented_transport_without_touching_canonical_head( - self, - ) -> None: - _write_fake_worker( - self.bindir, - "ok", - body=textwrap.dedent( - """\ - workspace="$1" - result="$3" - mkdir -p "$workspace/src" - printf 'shipped\\n' > "$workspace/src/marker.txt" - printf '%s\\n' '{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}' > "$result" - exit 0 - """ - ), - ) - self._configure( - profiles={"ok": {"adapter": "fake", "executable": "ok"}}, - role_order=["ok"], - ) - unit = _write_unit(self.root / "unit.json") - receipt = self.receipts / "transport.json" - before = _canonical_snapshot(self.repo) - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) - after = _canonical_snapshot(self.repo) - self.assertEqual(before, after) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "success") - transport = data["transport"] - ref = transport["ref"] - self.assertRegex(ref, r"^refs/agent-exec/[^/]+/[^/]+$") - parent = _git(self.repo, "rev-parse", f"{ref}^").stdout.strip() - self.assertEqual(parent, before["head"]) - commit = _git(self.repo, "rev-parse", ref).stdout.strip() - self.assertNotEqual(commit, before["head"]) - # Canonical branch HEAD unchanged - branch_head = _git(self.repo, "rev-parse", "HEAD").stdout.strip() - self.assertEqual(branch_head, before["head"]) - attempt = data["attempts"][0] - self.assertTrue(attempt.get("workspace_preserved")) - cleanup = attempt.get("cleanup") or {} - self.assertFalse(cleanup.get("exact_restore")) - self.assertTrue(cleanup.get("transport_created")) - - def test_canonical_checkout_mutation_is_failed_unknown(self) -> None: - """Worker mutating canonical files/index while keeping private clone clean is blocked.""" - launched = self.root / "canon-next.log" - launched.write_text("", encoding="utf-8") - # Resolve the canonical checkout via the shared-clone object alternates. - _write_fake_worker( - self.bindir, - "canonmut", - body=textwrap.dedent( - """\ - workspace="$1" - alternates="$workspace/.git/objects/info/alternates" - objdir=$(head -n1 "$alternates") - canonical=$(dirname "$(dirname "$objdir")") - printf 'canonical-mutated\\n' >> "$canonical/README.md" - git -C "$canonical" add README.md - # Private clone left clean on purpose. - exit 0 - """ - ), - ) - _write_fake_worker( - self.bindir, - "canonnext", - body=textwrap.dedent( - f"""\ - echo next >> "{launched}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "canonmut": {"adapter": "fake", "executable": "canonmut"}, - "canonnext": {"adapter": "fake", "executable": "canonnext"}, - }, - role_order=["canonmut", "canonnext"], - ) - unit = _write_unit(self.root / "unit-canon.json") - receipt = self.receipts / "canon.json" - before = _canonical_snapshot(self.repo) - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "failed-unknown") - self.assertEqual(len(data["attempts"]), 1) - attempt = data["attempts"][0] - self.assertTrue(attempt.get("workspace_preserved")) - self.assertIn("canonical", (attempt.get("reason") or "").lower()) - self.assertEqual(launched.read_text(encoding="utf-8"), "") - self.assertEqual(data.get("host_fallback_authorizations", 0), 0) - # Mutation is retained for reconciliation; canonical is intentionally dirty. - after = _canonical_snapshot(self.repo) - self.assertNotEqual(after["status"], before["status"]) - - def test_corrupt_worktree_git_pointer_yields_failed_unknown_receipt(self) -> None: - """Corrupted private-clone .git directory must produce a stable blocked receipt.""" - _write_fake_worker( - self.bindir, - "corruptgit", - body=textwrap.dedent( - """\ - workspace="$1" - rm -rf "$workspace/.git" - printf 'not-a-valid-gitdir\\n' > "$workspace/.git" - exit 0 - """ - ), - ) - self._configure( - profiles={"corruptgit": {"adapter": "fake", "executable": "corruptgit"}}, - role_order=["corruptgit"], - ) - unit = _write_unit(self.root / "unit-corrupt.json") - receipt = self.receipts / "corrupt.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout) - self.assertTrue(receipt.is_file()) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "failed-unknown") - self.assertEqual(len(data["attempts"]), 1) - attempt = data["attempts"][0] - self.assertEqual(attempt["terminal_status"], "failed-unknown") - self.assertTrue(attempt.get("workspace_preserved")) - self.assertFalse((attempt.get("cleanup") or {}).get("exact_restore")) - reason = (attempt.get("reason") or "").lower() - self.assertTrue( - "inspection" in reason or "pointer" in reason or ".git" in reason, - attempt.get("reason"), - ) - self.assertNotIn("Traceback", proc.stderr) - self._assert_canonical_untouched() - - def test_networked_nonzero_no_file_change_is_failed_unknown(self) -> None: - """network=true nonzero exit with no file changes cannot be failed-clean.""" - launched = self.root / "net-next.log" - launched.write_text("", encoding="utf-8") - _write_fake_worker( - self.bindir, - "netfail", - body=textwrap.dedent( - """\ - # No worktree writes; nonzero exit after possible external contact. - exit 11 - """ - ), - ) - _write_fake_worker( - self.bindir, - "netnext", - body=textwrap.dedent( - f"""\ - echo next >> "{launched}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "netfail": {"adapter": "fake", "executable": "netfail"}, - "netnext": {"adapter": "fake", "executable": "netnext"}, - }, - role_order=["netfail", "netnext"], - ) - unit = _write_unit(self.root / "unit-net.json") - packet = json.loads(unit.read_text(encoding="utf-8")) - packet["network"] = True - packet["external_cost"] = False - unit.write_text(json.dumps(packet, indent=2) + "\n", encoding="utf-8") - receipt = self.receipts / "netfail.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "failed-unknown") - self.assertEqual(len(data["attempts"]), 1) - self.assertEqual(data["attempts"][0]["terminal_status"], "failed-unknown") - self.assertEqual(launched.read_text(encoding="utf-8"), "") - self.assertNotEqual(data["terminal_status"], "host-fallback-authorized") - self.assertEqual(data.get("host_fallback_authorizations", 0), 0) - self._assert_canonical_untouched() - - def test_git_admin_staged_config_ref_mutation_is_failed_unknown(self) -> None: - """Staged index plus local config/ref mutation preserves workspace and stops.""" - launched = self.root / "admin-next.log" - launched.write_text("", encoding="utf-8") - _write_fake_worker( - self.bindir, - "adminmut", - body=textwrap.dedent( - """\ - workspace="$1" - mkdir -p "$workspace/src" - printf 'idx\\n' > "$workspace/src/marker.txt" - git -C "$workspace" add src/marker.txt - git -C "$workspace" config --local agentexec.mutation yes - git -C "$workspace" update-ref refs/temporary/agent-exec-admin HEAD - exit 0 - """ - ), - ) - _write_fake_worker( - self.bindir, - "adminnext", - body=textwrap.dedent( - f"""\ - echo next >> "{launched}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "adminmut": {"adapter": "fake", "executable": "adminmut"}, - "adminnext": {"adapter": "fake", "executable": "adminnext"}, - }, - role_order=["adminmut", "adminnext"], - ) - unit = _write_unit(self.root / "unit-admin.json") - receipt = self.receipts / "admin.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "failed-unknown") - self.assertEqual(len(data["attempts"]), 1) - attempt = data["attempts"][0] - self.assertTrue(attempt.get("workspace_preserved")) - inventory = attempt.get("inventory_paths") or attempt.get("changed_paths") - self.assertIn("src/marker.txt", inventory) - self.assertEqual(launched.read_text(encoding="utf-8"), "") - self.assertEqual(data.get("host_fallback_authorizations", 0), 0) - self._assert_canonical_untouched() - - def test_sibling_canonical_ref_config_mutation_during_private_clone_succeeds( - self, - ) -> None: - """Host sibling branch/ref/config churn must not fail-stop a private clone.""" - ready = self.root / "sibling-ready" - proceed = self.root / "sibling-proceed" - for path in (ready, proceed): - if path.exists(): - path.unlink() - - _write_fake_worker( - self.bindir, - "sibworker", - body=textwrap.dedent( - f"""\ - workspace="$1" - result="$3" - mkdir -p "$workspace/src" - printf 'ok\\n' > "$workspace/src/marker.txt" - printf 'ready\\n' > "{ready}" - # Wait for the host-side sibling mutation before finishing. - i=0 - while [ ! -f "{proceed}" ]; do - i=$((i + 1)) - if [ "$i" -gt 400 ]; then - echo "timed out waiting for host sibling mutation" >&2 - exit 99 - fi - sleep 0.025 - done - printf '%s\\n' '{{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":["wrote marker"],"scope_expansion":null}}' > "$result" - exit 0 - """ - ), - ) - self._configure( - profiles={"sibworker": {"adapter": "fake", "executable": "sibworker"}}, - role_order=["sibworker"], - ) - unit = _write_unit(self.root / "unit-sibling.json") - receipt = self.receipts / "sibling.json" - before = _canonical_snapshot(self.repo) - sibling_ref = "refs/heads/feature/host-sibling-delivery" - sibling_head = before["head"] - - def _host_sibling_mutation() -> None: - deadline = time.time() + 10 - while not ready.exists(): - if time.time() > deadline: - return - time.sleep(0.025) - _git(self.repo, "update-ref", sibling_ref, sibling_head) - _git( - self.repo, - "config", - "--local", - "branch.feature/host-sibling-delivery.remote", - "origin", - ) - _git( - self.repo, - "config", - "--local", - "branch.feature/host-sibling-delivery.merge", - sibling_ref, - ) - proceed.write_text("go\n", encoding="utf-8") - - host_thread = threading.Thread(target=_host_sibling_mutation, daemon=True) - host_thread.start() - try: - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - finally: - host_thread.join(timeout=15) - - self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "success") - attempt = data["attempts"][0] - worktree = Path(attempt["worktree"]) - self.assertTrue(worktree.is_dir()) - self.assertTrue((worktree / ".git").is_dir()) - self.assertTrue(ready.exists()) - self.assertTrue(proceed.exists()) - self.assertEqual( - _git(self.repo, "rev-parse", sibling_ref).stdout.strip(), sibling_head - ) - - porcelain = _git(self.repo, "worktree", "list", "--porcelain").stdout - self.assertNotIn(str(worktree.resolve()), porcelain) - canon_git = _git(self.repo, "rev-parse", "--absolute-git-dir").stdout.strip() - worker_git = _git(worktree, "rev-parse", "--absolute-git-dir").stdout.strip() - self.assertNotEqual(Path(canon_git).resolve(), Path(worker_git).resolve()) - canon_common = _git( - self.repo, "rev-parse", "--path-format=absolute", "--git-common-dir" - ).stdout.strip() - worker_common = _git( - worktree, "rev-parse", "--path-format=absolute", "--git-common-dir" - ).stdout.strip() - self.assertNotEqual(Path(canon_common).resolve(), Path(worker_common).resolve()) - - transport = data["transport"] - ref = transport["ref"] - self.assertRegex(ref, r"^refs/agent-exec/[^/]+/[^/]+$") - parent = _git(self.repo, "rev-parse", f"{ref}^").stdout.strip() - self.assertEqual(parent, before["head"]) - # Canonical HEAD/index identity preserved; sibling delivery side effects are allowed. - after = _canonical_snapshot(self.repo) - self.assertEqual(after["head"], before["head"]) - self.assertEqual(after["index"], before["index"]) - branch_head = _git(self.repo, "rev-parse", "HEAD").stdout.strip() - self.assertEqual(branch_head, before["head"]) - - def test_symlink_allowed_path_escape_rejects_without_external_write(self) -> None: - """Allowed path through symlink escaping the worktree cannot touch outside.""" - outside = self.root / "outside-target" - outside.mkdir() - victim = outside / "victim.txt" - victim.write_text("untouched\n", encoding="utf-8") - link = self.repo / "src" / "escape" - link.symlink_to(outside, target_is_directory=True) - _git(self.repo, "add", "src/escape") - _git(self.repo, "commit", "-m", "add escape symlink") - self.base_snap = _canonical_snapshot(self.repo) - - launched = self.root / "symlink-next.log" - launched.write_text("", encoding="utf-8") - _write_fake_worker( - self.bindir, - "symworker", - body=textwrap.dedent( - """\ - workspace="$1" - # If launch were allowed, attempt to write through the symlink. - printf 'pwned\\n' > "$workspace/src/escape/victim.txt" - exit 0 - """ - ), - ) - _write_fake_worker( - self.bindir, - "symnext", - body=textwrap.dedent( - f"""\ - echo next >> "{launched}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "symworker": {"adapter": "fake", "executable": "symworker"}, - "symnext": {"adapter": "fake", "executable": "symnext"}, - }, - role_order=["symworker", "symnext"], - ) - unit = _write_unit( - self.root / "unit-symlink.json", - allowed_paths=["src/escape/victim.txt"], - ) - receipt = self.receipts / "symlink.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertNotEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) - self.assertNotEqual(proc.returncode, agent_exec.EXIT_HOST_FALLBACK) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertNotEqual(data["terminal_status"], "success") - self.assertNotEqual(data["terminal_status"], "host-fallback-authorized") - self.assertEqual(victim.read_text(encoding="utf-8"), "untouched\n") - self.assertEqual(launched.read_text(encoding="utf-8"), "") - self._assert_canonical_untouched() - - def test_secret_crossing_raw_log_flush_boundary_is_redacted(self) -> None: - """Exact env secrets must not leak when split across raw-log drain/flush edges.""" - secret = "SECRETBOUNDARYTOKEN" - _write_fake_worker( - self.bindir, - "boundary", - body=textwrap.dedent( - f"""\ - workspace="$1" - result="$3" - python3 - <<'PY' - import os, sys - secret = os.environ["AGENT_EXEC_TEST_SECRET"] - sys.stdout.buffer.write(b"A" * 120 + secret.encode() + b"Z" * 60) - sys.stdout.buffer.flush() - PY - mkdir -p "$workspace/src" - printf 'ok\\n' > "$workspace/src/marker.txt" - printf '%s\\n' '{{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}}' > "$result" - exit 0 - """ - ), - ) - self._configure( - profiles={"boundary": {"adapter": "fake", "executable": "boundary"}}, - role_order=["boundary"], - raw_output_bytes=4096, - ) - unit = _write_unit(self.root / "unit-boundary.json") - receipt = self.receipts / "boundary-redact.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - env={"AGENT_EXEC_TEST_SECRET": secret}, - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) - receipt_text = receipt.read_text(encoding="utf-8") - self.assertNotIn(secret, receipt_text) - data = json.loads(receipt_text) - raw_path = Path(data["attempts"][0]["raw_log"]) - raw_bytes = raw_path.read_bytes() - self.assertNotIn(secret.encode("utf-8"), raw_bytes) - self.assertNotIn(secret, raw_bytes.decode("utf-8", "replace")) - self._assert_canonical_untouched() - - def test_large_stdin_with_early_stdout_does_not_deadlock(self) -> None: - """Stdin must be select-driven so early stdout beyond pipe capacity cannot hang.""" - # Stay under adapter MAX_UNIT_PACKET_BYTES (262144) while exceeding pipe capacity. - # Only a review-role custom adapter delivers the packet on stdin. - pad = "P" * (128 * 1024) - _write_fake_worker( - self.bindir, - "pipefill", - native=True, - body=textwrap.dedent( - """\ - workspace="$1" - result="$3" - # Use -c so process stdin (unit packet) remains readable by Python. - python3 -c 'import sys; sys.stdout.buffer.write(b"X"*(256*1024)); sys.stdout.buffer.flush(); sys.stdin.buffer.read()' - mkdir -p "$workspace/src" - printf 'ok\\n' > "$workspace/src/marker.txt" - printf '%s\\n' '{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}' > "$result" - exit 0 - """ - ), - ) - self._configure( - profiles={"pipefill": {"adapter": "custom", "executable": "pipefill"}}, - role_order=[], - review_order=["pipefill"], - timeout_seconds=15, - raw_output_bytes=1024, - ) - loaded = agent_exec.load_config(self.repo) - self.assertEqual(loaded.custom_adapters["pipefill"].prompt_transport, "stdin") - self.assertNotIn("pipefill", loaded.roles["implementation"]) - unit = _write_unit( - self.root / "unit-pipefill.json", - timeout_seconds=15, - ) - # Inflate the stdin-delivered unit packet well beyond pipe capacity. - packet = json.loads(unit.read_text(encoding="utf-8")) - packet["constraints"] = ["leave uncommitted", pad] - unit.write_text(json.dumps(packet) + "\n", encoding="utf-8") - self.assertGreater(unit.stat().st_size, 65536) - self.assertLessEqual(unit.stat().st_size, 262144) - receipt = self.receipts / "pipefill.json" - started = time.monotonic() - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "review", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - elapsed = time.monotonic() - started - self.assertLessEqual(elapsed, 20.0, f"controller hung for {elapsed:.1f}s") - self.assertIn( - proc.returncode, - { - agent_exec.EXIT_SUCCESS, - agent_exec.EXIT_BLOCKED, - }, - proc.stderr + proc.stdout, - ) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertIn( - data["terminal_status"], - {"success", "failed-unknown", "blocked"}, - ) - if data["terminal_status"] == "success": - self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS) - self._assert_canonical_untouched() - - def test_transport_construction_failure_is_failed_unknown(self) -> None: - """Host transport staging/commit-tree/update-ref failure stays recoverable.""" - launched = self.root / "transport-next.log" - launched.write_text("", encoding="utf-8") - _write_fake_worker( - self.bindir, - "txok", - body=textwrap.dedent( - """\ - workspace="$1" - result="$3" - mkdir -p "$workspace/src" - printf 'ok\\n' > "$workspace/src/marker.txt" - printf '%s\\n' '{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}' > "$result" - exit 0 - """ - ), - ) - _write_fake_worker( - self.bindir, - "txnext", - body=textwrap.dedent( - f"""\ - echo next >> "{launched}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "txok": {"adapter": "fake", "executable": "txok"}, - "txnext": {"adapter": "fake", "executable": "txnext"}, - }, - role_order=["txok", "txnext"], - ) - unit = _write_unit(self.root / "unit-txfail.json") - receipt = self.receipts / "txfail.json" - with mock.patch.object( - agent_exec, - "_create_transport_commit", - side_effect=subprocess.CalledProcessError( - 1, ["git", "commit-tree"], stderr="simulated transport failure" - ), - ): - code = agent_exec.cmd_run( - self.repo, - role="implementation", - unit_path=unit, - receipt_path=receipt, - ) - self.assertEqual(code, agent_exec.EXIT_BLOCKED) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "failed-unknown") - self.assertEqual(len(data["attempts"]), 1) - attempt = data["attempts"][0] - self.assertEqual(attempt["terminal_status"], "failed-unknown") - self.assertTrue(attempt.get("workspace_preserved")) - cleanup = attempt.get("cleanup") or {} - self.assertFalse(cleanup.get("transport_created", True)) - self.assertIn("transport construction failed", attempt.get("reason") or data.get("reason") or "") - self.assertEqual(data.get("host_fallback_authorizations", 0), 0) - self.assertEqual(launched.read_text(encoding="utf-8"), "") - self._assert_canonical_untouched() - - def test_transport_commit_does_not_require_configured_git_identity(self) -> None: - base_head = _git(self.repo, "rev-parse", "HEAD").stdout.strip() - _git(self.repo, "config", "--unset-all", "user.name") - _git(self.repo, "config", "--unset-all", "user.email") - worktree = self.root / "no-identity-worktree" - agent_exec._create_private_attempt_clone(self.repo, worktree, base_head) - (worktree / "src" / "marker.txt").write_text("ok\n", encoding="utf-8") - - clean_git_env = { - "PATH": os.environ["PATH"], - "GIT_CONFIG_GLOBAL": "/dev/null", - "GIT_CONFIG_NOSYSTEM": "1", - } - with mock.patch.dict(os.environ, clean_git_env, clear=True): - ref = agent_exec._create_transport_commit( - repo=self.repo, - worktree=worktree, - base_head=base_head, - run_id="no-identity", - attempt_id="attempt-1", - inventory={"paths": ["src/marker.txt"]}, - ) - - identity = _git( - self.repo, "log", "-1", "--format=%an <%ae>|%cn <%ce>", ref - ).stdout.strip() - expected = ( - f"{agent_exec.TRANSPORT_AUTHOR_NAME} " - f"<{agent_exec.TRANSPORT_AUTHOR_EMAIL}>" - ) - self.assertEqual(identity, f"{expected}|{expected}") - - def test_rename_into_allowed_destination_cannot_hide_source(self) -> None: - """Rename parsing must keep an out-of-scope source and its destination.""" - launched = self.root / "rename-next.log" - launched.write_text("", encoding="utf-8") - _write_fake_worker( - self.bindir, - "renamer", - body=textwrap.dedent( - """\ - workspace="$1" - result="$3" - git -C "$workspace" mv README.md src/marker.txt - printf '%s\\n' '{"terminal_status":"completed","summary":"moved","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}' > "$result" - exit 0 - """ - ), - ) - _write_fake_worker( - self.bindir, - "renamenext", - body=textwrap.dedent( - f"""\ - echo next >> "{launched}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "renamer": {"adapter": "fake", "executable": "renamer"}, - "renamenext": {"adapter": "fake", "executable": "renamenext"}, - }, - role_order=["renamer", "renamenext"], - ) - unit = _write_unit(self.root / "unit-rename.json") - receipt = self.receipts / "rename.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertNotEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertNotEqual(data["terminal_status"], "success") - self.assertEqual(len(data["attempts"]), 1) - attempt = data["attempts"][0] - paths = attempt.get("inventory_paths") or [] - self.assertIn("README.md", paths) - self.assertIn("src/marker.txt", paths) - self.assertIn("README.md", attempt.get("changed_paths") or []) - self.assertIn("src/marker.txt", attempt.get("changed_paths") or []) - self.assertFalse(agent_exec._path_allowed("README.md", ("src/marker.txt",))) - self.assertEqual(launched.read_text(encoding="utf-8"), "") - self.assertEqual(data.get("host_fallback_authorizations", 0), 0) - self._assert_canonical_untouched() - - def test_launched_failure_without_diff_is_failed_unknown_and_stops(self) -> None: - """No diff and declared-offline packets still do not prove external cleanliness.""" - cases = { - "clean-exit": "exit 0\n", - "nonzero-exit": "exit 7\n", - } - for label, body in cases.items(): - with self.subTest(case=label): - launched = self.root / f"restore-next-{label}.log" - launched.write_text("", encoding="utf-8") - _write_fake_worker(self.bindir, f"restorer-{label}", body=body) - _write_fake_worker( - self.bindir, - f"restorenext-{label}", - body=textwrap.dedent( - f"""\ - echo next >> "{launched}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "restorer": { - "adapter": "fake", - "executable": f"restorer-{label}", - }, - "restorenext": { - "adapter": "fake", - "executable": f"restorenext-{label}", - }, - }, - role_order=["restorer", "restorenext"], - ) - unit = _write_unit(self.root / f"unit-restore-{label}.json") - receipt = self.receipts / f"restore-{label}.json" - code = agent_exec.cmd_run( - self.repo, - role="implementation", - unit_path=unit, - receipt_path=receipt, - ) - self.assertEqual(code, agent_exec.EXIT_BLOCKED) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "failed-unknown") - self.assertEqual(len(data["attempts"]), 1) - attempt = data["attempts"][0] - self.assertEqual(attempt["terminal_status"], "failed-unknown") - self.assertFalse((attempt.get("cleanup") or {}).get("exact_restore")) - self.assertEqual(launched.read_text(encoding="utf-8"), "") - self.assertEqual(data.get("host_fallback_authorizations", 0), 0) - self._assert_canonical_untouched() - - def test_clean_exit_without_diff_and_network_is_failed_unknown(self) -> None: - """Authorized network with a clean no-diff exit is not failed-clean.""" - launched = self.root / "net-clean-next.log" - launched.write_text("", encoding="utf-8") - _write_fake_worker(self.bindir, "netclean", body="exit 0\n") - _write_fake_worker( - self.bindir, - "netcleannext", - body=textwrap.dedent( - f"""\ - echo next >> "{launched}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "netclean": {"adapter": "fake", "executable": "netclean"}, - "netcleannext": {"adapter": "fake", "executable": "netcleannext"}, - }, - role_order=["netclean", "netcleannext"], - ) - unit = _write_unit(self.root / "unit-net-clean.json") - packet = json.loads(unit.read_text(encoding="utf-8")) - packet["network"] = True - packet["external_cost"] = False - unit.write_text(json.dumps(packet, indent=2) + "\n", encoding="utf-8") - receipt = self.receipts / "net-clean.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "failed-unknown") - self.assertEqual(len(data["attempts"]), 1) - self.assertEqual(data["attempts"][0]["terminal_status"], "failed-unknown") - self.assertEqual(launched.read_text(encoding="utf-8"), "") - self.assertEqual(data.get("host_fallback_authorizations", 0), 0) - self._assert_canonical_untouched() - - def test_worker_fail_stop_terminal_beats_success_and_no_change(self) -> None: - """blocked, cancelled, and failed-unknown stop before success or no-change.""" - cases = ( - ("blocked", agent_exec.EXIT_BLOCKED, True), - ("cancelled", agent_exec.EXIT_CANCELLED, True), - ("failed-unknown", agent_exec.EXIT_BLOCKED, True), - ("cancelled", agent_exec.EXIT_CANCELLED, False), - ) - for status, exit_code, write_file in cases: - label = f"{status}-{'files' if write_file else 'nodiff'}" - with self.subTest(case=label): - launched = self.root / f"failstop-{label}.log" - launched.write_text("", encoding="utf-8") - write = "" - if write_file: - write = ( - 'mkdir -p "$workspace/src"\n' - "printf 'x\\n' > \"$workspace/src/marker.txt\"\n" - ) - _write_fake_worker( - self.bindir, - f"failstop-{label}", - body=textwrap.dedent( - f"""\ - workspace="$1" - result="$3" - {write}printf '%s\\n' '{{"terminal_status":"{status}","summary":"stop","changed_files":[],"evidence":[],"scope_expansion":null}}' > "$result" - exit 0 - """ - ), - ) - _write_fake_worker( - self.bindir, - f"failstop-next-{label}", - body=textwrap.dedent( - f"""\ - echo next >> "{launched}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - "failstop": { - "adapter": "fake", - "executable": f"failstop-{label}", - }, - "failnext": { - "adapter": "fake", - "executable": f"failstop-next-{label}", - }, - }, - role_order=["failstop", "failnext"], - ) - unit = _write_unit(self.root / f"unit-failstop-{label}.json") - receipt = self.receipts / f"failstop-{label}.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual(proc.returncode, exit_code, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], status) - self.assertEqual(len(data["attempts"]), 1) - attempt = data["attempts"][0] - self.assertEqual(attempt["terminal_status"], status) - if write_file: - self.assertIn("src/marker.txt", attempt.get("changed_paths") or []) - self.assertEqual(launched.read_text(encoding="utf-8"), "") - self.assertEqual(data.get("host_fallback_authorizations", 0), 0) - self._assert_canonical_untouched() - - def test_builtin_event_envelopes_gate_success(self) -> None: - """Real Cursor/agy/PI output envelopes decide the terminal; prose fails closed.""" - prose = "All done, the marker is written." - cases = ( - ("cursor", _cursor_help(), _cursor_stream_events("ok\n" + _terminal_json()), "success"), - ("cursor", _cursor_help(), _cursor_stream_events(prose), "failed-unknown"), - ("cursor", _cursor_help(), _cursor_stream_events(_terminal_json("blocked")), "blocked"), - ( - "cursor", - _cursor_help(), - _cursor_stream_events(_terminal_json(), is_error=True), - "failed-unknown", - ), - ("antigravity", _antigravity_help(), _agy_stream_events("ok " + _terminal_json()), "success"), - ("antigravity", _antigravity_help(), _agy_stream_events(prose), "failed-unknown"), - ( - "pi", - _pi_help(), - _pi_json_events( - "ok\n" + _terminal_json() + "\n", - agent_end_padding=2 * agent_exec.TERMINAL_LINE_MAX_BYTES, - ), - "success", - ), - ("pi", _pi_help(), _pi_json_events(prose, prompt_text=_terminal_json()), "failed-unknown"), - ("pi", _pi_help(), [], "failed-unknown"), - ) - expected_exit = { - "success": agent_exec.EXIT_SUCCESS, - "failed-unknown": agent_exec.EXIT_BLOCKED, - "blocked": agent_exec.EXIT_BLOCKED, - } - for index, (adapter, help_text, events, expected) in enumerate(cases): - label = f"{adapter}-{index}-{expected}" - with self.subTest(case=label): - events_path = self.root / f"events-{label}.jsonl" - events_path.write_text( - "".join(line + "\n" for line in events), encoding="utf-8" - ) - executable = f"envelope-{label}" - _write_fake_worker( - self.bindir, - executable, - help_text=help_text, - native=True, - body=textwrap.dedent( - f"""\ - mkdir -p src - printf 'ok\\n' > src/marker.txt - cat "{events_path}" - exit 0 - """ - ), - ) - self._configure( - profiles={ - adapter: {"adapter": adapter, "executable": executable} - }, - role_order=[adapter], - ) - unit = _write_unit(self.root / f"unit-{label}.json") - receipt = self.receipts / f"envelope-{label}.json" - code = agent_exec.cmd_run( - self.repo, - role="implementation", - unit_path=unit, - receipt_path=receipt, - ) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], expected, data.get("reason")) - self.assertEqual(code, expected_exit[expected]) - self.assertEqual(len(data["attempts"]), 1) - attempt = data["attempts"][0] - self.assertEqual(attempt["exit_code"], 0) - self.assertEqual(attempt["changed_paths"], ["src/marker.txt"]) - if expected == "success": - self.assertTrue(attempt["transport_ref"]) - else: - self.assertIsNone(attempt.get("transport_ref")) - self.assertTrue(attempt["workspace_preserved"]) - if expected == "failed-unknown": - self.assertIn("no valid completed terminal record", attempt["reason"]) - self._assert_canonical_untouched() - - -class TerminalRecordDecodingTests(unittest.TestCase): - """Adapter-specific terminal decoding over representative real envelopes.""" - - def _decode( - self, - adapter: str, - lines: list[str | None], - *, - stdout_tail: str | None = None, - result_text: str | None = None, - ) -> dict[str, object] | None: - from scripts.agent_exec_adapters import decode_terminal_record - - tail = stdout_tail - if tail is None: - tail = "".join(line + "\n" for line in lines if line is not None) - return decode_terminal_record( - adapter, event_lines=lines, stdout_tail=tail, result_text=result_text - ) - - def test_cursor_stream_json_result_is_decoded(self) -> None: - lines = _cursor_stream_events("Done.\n" + _terminal_json() + "\n \n") - record = self._decode("cursor", lines) - assert record is not None - self.assertEqual(record["terminal_status"], "completed") - self.assertEqual(record["changed_files"], ["src/marker.txt"]) - - def test_terminal_record_must_be_sole_final_content(self) -> None: - invalid = json.dumps({"terminal_status": "done", "summary": "x"}) - texts = { - "trailing-prose": "Done.\n" + _terminal_json() + "\nThanks.", - "trailing-invalid-object": _terminal_json() + "\n" + invalid, - "trailing-non-terminal-object": _terminal_json() + ' {"note":"x"}', - "truncated-terminal-object": _terminal_json() + '\n{"terminal_status":', - "two-records": _terminal_json() + "\n" + _terminal_json(), - "conflicting-records": _terminal_json() + "\n" + _terminal_json("blocked"), - } - for name, text in texts.items(): - for adapter, lines in ( - ("cursor", _cursor_stream_events(text)), - ("antigravity", _agy_stream_events(text)), - ("pi", _pi_json_events(text)), - ): - with self.subTest(case=name, adapter=adapter): - self.assertIsNone(self._decode(adapter, lines)) - with self.subTest(case=name, adapter="custom"): - self.assertIsNone( - self._decode("custom", [], stdout_tail="", result_text=text) - ) - - def test_cursor_user_echo_and_error_result_are_not_records(self) -> None: - lines = _cursor_stream_events("prose only") - echo = json.loads(lines[1]) - echo["message"]["content"][0]["text"] = _terminal_json() - lines[1] = json.dumps(echo) - self.assertIsNone(self._decode("cursor", lines)) - self.assertIsNone( - self._decode("cursor", _cursor_stream_events(_terminal_json(), is_error=True)) - ) - self.assertIsNone(self._decode("cursor", _cursor_stream_events(_terminal_json())[:-1])) - - def test_cursor_blocked_record_is_decoded_as_fail_stop(self) -> None: - record = self._decode("cursor", _cursor_stream_events(_terminal_json("blocked"))) - assert record is not None - self.assertEqual(record["terminal_status"], "blocked") - - def test_agy_stream_json_and_json_results_are_decoded(self) -> None: - record = self._decode("antigravity", _agy_stream_events("ok " + _terminal_json())) - assert record is not None - self.assertEqual(record["terminal_status"], "completed") - single = json.dumps( - {"conversation_id": "c", "response": "ok\n" + _terminal_json(), "num_turns": 1}, - indent=2, - ) - record = self._decode("antigravity", [], stdout_tail=single) - assert record is not None - self.assertEqual(record["terminal_status"], "completed") - self.assertIsNone( - self._decode("antigravity", _agy_stream_events(_terminal_json(), is_error=True)) - ) - self.assertIsNone(self._decode("antigravity", _agy_stream_events(_terminal_json())[:-1])) - - def test_pi_json_final_assistant_message_is_decoded(self) -> None: - lines = _pi_json_events("ok\n" + _terminal_json()) - record = self._decode("pi", lines) - assert record is not None - self.assertEqual(record["terminal_status"], "completed") - self.assertIsNone( - self._decode("pi", _pi_json_events("prose", prompt_text=_terminal_json())) - ) - self.assertIsNone( - self._decode("pi", _pi_json_events(_terminal_json(), stop_reason="error")) - ) - - def test_prose_missing_or_invalid_terminal_fails_closed(self) -> None: - for adapter, lines in ( - ("cursor", _cursor_stream_events("finished, no record")), - ("antigravity", _agy_stream_events("finished, no record")), - ("pi", _pi_json_events("finished, no record")), - ("cursor", []), - ("antigravity", []), - ("pi", []), - ): - with self.subTest(adapter=adapter, lines=len(lines)): - self.assertIsNone(self._decode(adapter, lines)) - invalid = json.dumps({"terminal_status": "done", "summary": "x"}) - self.assertIsNone(self._decode("cursor", _cursor_stream_events(invalid))) - missing_keys = json.dumps({"terminal_status": "completed", "summary": "x"}) - self.assertIsNone(self._decode("cursor", _cursor_stream_events(missing_keys))) - - def test_raw_stdout_record_is_not_an_envelope_for_builtins(self) -> None: - bare = [_terminal_json()] - for adapter in ("cursor", "antigravity", "pi"): - with self.subTest(adapter=adapter): - self.assertIsNone(self._decode(adapter, bare)) - record = self._decode("custom", [], stdout_tail="", result_text=_terminal_json()) - assert record is not None - self.assertEqual(record["terminal_status"], "completed") - - def test_prompt_states_schema_without_being_a_record(self) -> None: - from scripts import agent_exec_adapters as adapters - - prompt = adapters._build_bounded_unit_prompt('{"unit_id":"u"}') - self.assertIn( - json.dumps(adapters.TERMINAL_RECORD_SCHEMA, separators=(",", ":")), prompt - ) - for status in adapters.TERMINAL_STATUSES: - self.assertIn(status, prompt) - self.assertIsNone(adapters.extract_terminal_record(prompt)) - - def test_stdout_tail_keeps_final_envelope_behind_oversized_event(self) -> None: - big = 2 * agent_exec.TERMINAL_LINE_MAX_BYTES - stream = "".join( - line + "\n" - for line in _pi_json_events("ok " + _terminal_json(), agent_end_padding=big) - ).encode("utf-8") - tail = agent_exec._StdoutTail(agent_exec.OUTPUT_TAIL_BYTES) - for start in range(0, len(stream), 4093): - tail.extend(stream[start : start + 4093]) - self.assertNotIn(b"message_end", tail.text().encode("utf-8")) - record = self._decode("pi", tail.terminal_lines(), stdout_tail=tail.text()) - assert record is not None - self.assertEqual(record["terminal_status"], "completed") - - oversized = _cursor_stream_events("x" * big + _terminal_json()) - tail = agent_exec._StdoutTail(agent_exec.OUTPUT_TAIL_BYTES) - tail.extend("".join(line + "\n" for line in oversized).encode("utf-8")) - lines = tail.terminal_lines() - self.assertIsNone(lines[-1]) - self.assertIsNone(self._decode("cursor", lines, stdout_tail=tail.text())) - - -class AgentExecHistoryTests(unittest.TestCase): - def setUp(self) -> None: - self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-history-") - self.addCleanup(self.tmp.cleanup) - self.root = Path(self.tmp.name) - self.bindir = self.root / "bin" - self.bindir.mkdir() - self.repo = self.root / "repo" - self.repo.mkdir() - self.receipts = self.root / "receipts" - self.receipts.mkdir() - self.old_path = os.environ.get("PATH", "") - os.environ["PATH"] = f"{self.bindir}{os.pathsep}{self.old_path}" - self.addCleanup(self._restore_path) - _git(self.repo, "init") - _git(self.repo, "config", "user.email", "history@example.com") - _git(self.repo, "config", "user.name", "History Test") - (self.repo / "README.md").write_text("fixture\n", encoding="utf-8") - (self.repo / "src").mkdir() - (self.repo / "src" / ".gitkeep").write_text("", encoding="utf-8") - _git(self.repo, "add", "README.md", "src/.gitkeep") - _git(self.repo, "commit", "-m", "init") - _git(self.repo, "checkout", "-b", "feature/history-test") - - def _restore_path(self) -> None: - os.environ["PATH"] = self.old_path - - @property - def history_path(self) -> Path: - return self.repo / ".git" / "agent-exec" / "execution-history.jsonl" - - def test_history_appends_safe_success_and_fallback_records(self) -> None: - secret = "sk-historysecretdeadbeef123456" - _write_fake_worker( - self.bindir, - "history-writer", - body=textwrap.dedent( - """\ - workspace="$1" - result="$3" - mkdir -p "$workspace/src" - printf 'ok\n' > "$workspace/src/marker.txt" - printf '%s\n' '{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}' > "$result" - exit 0 - """ - ), - ) - _write_config( - self.repo, - profiles={ - "writer": { - "adapter": "fake", - "executable": "history-writer", - } - }, - role_order=["writer"], - ) - unit = _write_unit(self.root / "success.json", unit_id=f"unit-{secret}") - receipt_path = self.receipts / "success.json" - with mock.patch.dict(os.environ, {"AGENT_EXEC_TEST_SECRET": secret}): - code = agent_exec.cmd_run( - self.repo, - role="implementation", - unit_path=unit, - receipt_path=receipt_path, - ) - self.assertEqual(code, agent_exec.EXIT_SUCCESS) - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) - self.assertEqual(receipt["history"]["status"], "recorded") - self.assertEqual(Path(receipt["history"]["path"]), self.history_path) - self.assertEqual(stat.S_IMODE(self.history_path.parent.stat().st_mode), 0o700) - self.assertEqual(stat.S_IMODE(self.history_path.stat().st_mode), 0o600) - - _write_config( - self.repo, - profiles={ - "missing": { - "adapter": "fake", - "executable": "missing-history-cli", - } - }, - role_order=["missing"], - ) - fallback_unit = _write_unit(self.root / "fallback.json", unit_id="unit-fallback") - fallback_receipt = self.receipts / "fallback.json" - fallback_code = agent_exec.cmd_run( - self.repo, - role="implementation", - unit_path=fallback_unit, - receipt_path=fallback_receipt, - ) - self.assertEqual(fallback_code, agent_exec.EXIT_UNAVAILABLE_EXHAUSTION) - - text = self.history_path.read_text(encoding="utf-8") - self.assertNotIn(secret, text) - self.assertNotIn("raw_log", text) - self.assertNotIn('"summary"', text) - records = [json.loads(line) for line in text.splitlines()] - self.assertEqual(len(records), 2) - success, fallback = records - self.assertEqual(success["schema"], "agent-exec-history/v1") - self.assertEqual(success["terminal_status"], "success") - self.assertEqual(success["verification_result"], "NOT_RUN") - self.assertEqual(success["review_result"], "NOT_RUN") - self.assertEqual(success["final_result"], "NOT_RUN") - self.assertEqual(success["attempts"][0]["fresh_session"], True) - self.assertEqual(success["attempts"][0]["session_id"], "not-observable") - self.assertEqual(success["attempts"][0]["model_requested"], "auto") - self.assertEqual(fallback["terminal_status"], "unavailable") - self.assertEqual(fallback["host_fallback_authorizations"], 0) - - def test_history_failure_does_not_change_run_outcome(self) -> None: - _write_config( - self.repo, - profiles={ - "missing": { - "adapter": "fake", - "executable": "missing-history-cli", - } - }, - role_order=["missing"], - ) - unit = _write_unit(self.root / "unit.json") - receipt_path = self.receipts / "failed-history.json" - secret = "sk-historyfailuredeadbeef123456" - with mock.patch.dict(os.environ, {"AGENT_EXEC_TEST_SECRET": secret}), mock.patch.object( - agent_exec, - "_append_execution_history", - side_effect=OSError(f"history blocked {secret}"), - ): - code = agent_exec.cmd_run( - self.repo, - role="implementation", - unit_path=unit, - receipt_path=receipt_path, - ) - self.assertEqual(code, agent_exec.EXIT_UNAVAILABLE_EXHAUSTION) - receipt_text = receipt_path.read_text(encoding="utf-8") - self.assertNotIn(secret, receipt_text) - receipt = json.loads(receipt_text) - self.assertEqual(receipt["terminal_status"], "unavailable") - self.assertEqual(receipt.get("host_fallback_authorizations"), 0) - self.assertEqual(receipt["history"]["status"], "failed") - - def test_invalid_config_also_appends_one_history_record(self) -> None: - (self.repo / "AGENT-EXECUTION.md").write_text("invalid\n", encoding="utf-8") - _git(self.repo, "add", "AGENT-EXECUTION.md") - _git(self.repo, "commit", "-m", "invalid config fixture") - receipt_path = self.receipts / "invalid.json" - code = agent_exec.cmd_run( - self.repo, - role="implementation", - unit_path=self.root / "unused.json", - receipt_path=receipt_path, - ) - self.assertEqual(code, agent_exec.EXIT_INVALID_CONFIG) - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) - self.assertEqual(receipt["terminal_status"], "invalid-config") - self.assertEqual(receipt["history"]["status"], "recorded") - records = [ - json.loads(line) - for line in self.history_path.read_text(encoding="utf-8").splitlines() - ] - self.assertEqual(len(records), 1) - self.assertEqual(records[0]["terminal_status"], "invalid-config") - self.assertEqual(records[0]["attempts"], []) - - def _run_marker_success(self, name: str) -> tuple[Path, dict[str, object]]: - _write_fake_worker( - self.bindir, - name, - body=textwrap.dedent( - """\ - workspace="$1" - result="$3" - mkdir -p "$workspace/src" - printf 'ok\\n' > "$workspace/src/marker.txt" - printf '%s\\n' '{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}' > "$result" - exit 0 - """ - ), - ) - _write_config( - self.repo, - profiles={name: {"adapter": "fake", "executable": name}}, - role_order=[name], - ) - unit = _write_unit(self.root / f"{name}.json", unit_id=f"unit-{name}") - receipt_path = self.receipts / f"{name}.json" - code = agent_exec.cmd_run( - self.repo, - role="implementation", - unit_path=unit, - receipt_path=receipt_path, - ) - receipt_text = ( - receipt_path.read_text(encoding="utf-8") if receipt_path.is_file() else "" - ) - self.assertEqual(code, agent_exec.EXIT_SUCCESS, receipt_text) - return receipt_path, json.loads(receipt_text) - - def test_success_history_is_mode_correct_and_signed(self) -> None: - receipt_path, receipt = self._run_marker_success("signedhistory") - key_path = self.history_path.parent / "attestation.key" - self.assertTrue(key_path.is_file()) - self.assertFalse(key_path.is_symlink()) - self.assertFalse(key_path.parent.is_symlink()) - self.assertEqual(stat.S_IMODE(key_path.parent.stat().st_mode), 0o700) - self.assertEqual(stat.S_IMODE(key_path.stat().st_mode), 0o600) - key = key_path.read_bytes() - self.assertEqual(len(key), 32) - self.assertNotIn(key, receipt_path.read_bytes()) - self.assertNotIn(key, self.history_path.read_bytes()) - - records = [ - json.loads(line) - for line in self.history_path.read_text(encoding="utf-8").splitlines() - ] - self.assertEqual(len(records), 1) - record = records[0] - transport = record["transport"] - ref = transport["ref"] - commit = _git(self.repo, "rev-parse", ref).stdout.strip() - self.assertEqual(record["terminal_status"], "success") - self.assertEqual(record["run_id"], receipt["run_id"]) - self.assertEqual(transport["parent"], receipt["transport"]["parent"]) - self.assertEqual(transport["commit"], commit) - self.assertEqual(receipt["transport"]["commit"], commit) - self.assertEqual(record["changed_paths"], receipt["changed_paths"]) - 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") - self.assertEqual( - hmac.new(key, payload, hashlib.sha256).hexdigest(), - record["attestation"], - ) - identity = receipt["history"] - self.assertEqual(identity["status"], "recorded") - self.assertEqual(identity["run_id"], receipt["run_id"]) - self.assertEqual(identity["record_id"], record["record_id"]) - self.assertEqual(identity["local_integrity_mac"], record["attestation"]) - self.assertNotIn("attestation", identity) - self.assertNotIn(key.hex(), json.dumps(identity)) - - def test_attestation_key_symlink_is_not_followed(self) -> None: - history_dir = self.history_path.parent - history_dir.mkdir(mode=0o700) - outside = self.root / "outside-key" - outside.write_bytes(b"A" * 32) - key_path = history_dir / "attestation.key" - key_path.symlink_to(outside) - receipt_path, receipt = self._run_marker_success("symlinkkey") - self.assertTrue(key_path.is_symlink()) - self.assertEqual(outside.read_bytes(), b"A" * 32) - self.assertEqual(receipt["history"]["status"], "failed") - self.assertNotIn(b"A" * 32, receipt_path.read_bytes()) - - def test_attestation_key_first_run_race_shares_one_key(self) -> None: - found: list[bytes] = [] - errors: list[BaseException] = [] - barrier = threading.Barrier(8) - - def load() -> None: - try: - barrier.wait(timeout=5) - found.append(agent_exec._ensure_local_integrity_key(self.repo)) - except BaseException as exc: # noqa: BLE001 - race failures are the assertion - errors.append(exc) - - threads = [threading.Thread(target=load) for _ in range(8)] - for thread in threads: - thread.start() - for thread in threads: - thread.join(timeout=10) - self.assertFalse(thread.is_alive()) - self.assertEqual(errors, []) - self.assertEqual(len(found), 8) - self.assertEqual(len({item for item in found}), 1) - self.assertEqual(len(found[0]), 32) - key_path = self.history_path.parent / "attestation.key" - self.assertFalse(key_path.is_symlink()) - self.assertEqual(key_path.read_bytes(), found[0]) - self.assertEqual(stat.S_IMODE(key_path.stat().st_mode), 0o600) - self.assertEqual(stat.S_IMODE(key_path.parent.stat().st_mode), 0o700) - - -# Opt-in real CLI probes (U5). Default unittest must not contact providers. -U5_PROFILES = ( - "cursor", - "antigravity", - "claude", - "codex", - "pi", - "kimi", - "opencode", -) -U5_EXECUTABLE = { - "cursor": "cursor-agent", - "antigravity": "agy", - "claude": "claude", - "codex": "codex", - "pi": "pi", - "kimi": "kimi", - "opencode": "opencode", -} -# Normal roles need exactly one enabled cursor, antigravity, and PI profile and -# reject every other adapter. The probe runs its one selected profile alone -# from review; normal roles keep only uninvoked holders. -_PROBE_NORMAL_HOLDERS = [_holder_name(adapter) for adapter in _NORMAL_ADAPTER_ORDER] -U5_BRANCH = "feature/u5-real-probe" -U5_MARKER_REL = "src/u5-marker.txt" -U5_MARKER_BODY = "U5-REAL-PROBE-MARKER\n" -# Envelope adapters JSON-escape the final text, so quotes may be backslashed. -_MODEL_RECEIPT_RE = re.compile( - r'\\?"model_receipt_status\\?"\s*:\s*\\?"(verified|not-observable|mismatch|unavailable)\\?"' -) - - -def real_probe_opt_in(env: os._Environ[str] | dict[str, str] | None = None) -> bool: - """True only for the explicit host gate. Other truthy strings stay closed.""" - source = os.environ if env is None else env - return source.get("RUN_REAL_AGENT_EXEC") == "1" - - -def require_single_real_profile(env: dict[str, str]) -> str: - """Select exactly one installed-CLI profile. Reject lists and unknown names.""" - raw = env.get("AGENT_EXEC_REAL_PROFILE", "") - if not raw or raw.strip() != raw: - raise ValueError( - "AGENT_EXEC_REAL_PROFILE must name exactly one profile" - ) - if any(sep in raw for sep in (",", " ", "\t", "\n", ";", ":")): - raise ValueError( - "AGENT_EXEC_REAL_PROFILE must name exactly one profile" - ) - if raw not in U5_PROFILES: - raise ValueError(f"unknown real probe profile: {raw}") - return raw - - -def _observe_model_receipt(attempt: dict[str, object]) -> str: - """Record a model receipt only when the raw output actually carries one.""" - chunks = [str(attempt.get("summary") or "")] - raw_log = attempt.get("raw_log") - if isinstance(raw_log, str) and raw_log: - path = Path(raw_log) - if path.is_file(): - chunks.append(path.read_text(encoding="utf-8", errors="replace")) - found = _MODEL_RECEIPT_RE.findall("\n".join(chunks)) - if not found: - return "not-observable" - if "mismatch" in found: - return "mismatch" - return found[-1] - - -def _probe_tool_bindir(extra: Path | None = None) -> tempfile.TemporaryDirectory[str]: - """PATH containing only git, python3, and an optional fake CLI directory.""" - tool_dir = tempfile.TemporaryDirectory(prefix="u5-probe-path-") - bindir = Path(tool_dir.name) - if extra is not None: - for entry in extra.iterdir(): - target = bindir / entry.name - if entry.is_file(): - target.symlink_to(entry.resolve()) - for tool in ("git", "python3"): - found = shutil.which(tool) - if not found: - tool_dir.cleanup() - raise RuntimeError(f"required tool missing from PATH: {tool}") - link = bindir / tool - if not link.exists(): - link.symlink_to(found) - return tool_dir - - -def _write_probe_double( - bindir: Path, - name: str, - *, - help_text: str, - run_body: str, -) -> None: - path = bindir / name - # printf is a shell builtin. The offline PATH has no cat/mkdir. - help_lines = help_text.splitlines() or [""] - help_printf = "".join( - f" printf '%s\\n' {shlex.quote(line)}\n" for line in help_lines - ) - path.write_text( - "#!/bin/sh\n" - 'if [ "$1" = "--version" ] || [ "$1" = "-v" ] || [ "$1" = "-V" ]; then\n' - ' echo "0.0.1-probe"\n' - " exit 0\n" - "fi\n" - 'if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then\n' - + help_printf - + " exit 0\n" - "fi\n" - + run_body, - encoding="utf-8", - ) - path.chmod(path.stat().st_mode | stat.S_IEXEC) - - -def _cursor_help() -> str: - from scripts.agent_exec_adapters import _REQUIRED_HELP_MARKERS - - return "\n".join(_REQUIRED_HELP_MARKERS["cursor"]) + "\n" - - -def _marker_run_body(*, body: str, model_receipt: str | None) -> str: - payload: dict[str, object] = { - "terminal_status": "completed", - "summary": "wrote marker", - "changed_files": [U5_MARKER_REL], - "evidence": [], - "scope_expansion": None, - } - if model_receipt is not None: - payload["model_receipt_status"] = model_receipt - final_text = "Wrote the marker.\n" + json.dumps(payload, separators=(",", ":")) - events = _cursor_stream_events(final_text) - writer = ( - "from pathlib import Path; " - f"p=Path({json.dumps(U5_MARKER_REL)}); " - "p.parent.mkdir(parents=True); " - f"p.write_text({json.dumps(body)}, encoding='utf-8')" - ) - printed = "".join(f"printf '%s\\n' {shlex.quote(line)}\n" for line in events) - return f"python3 -c {shlex.quote(writer)}\n" + printed + "exit 0\n" - - -def _cursor_stream_events(final_text: str, *, is_error: bool = False) -> list[str]: - """Representative Cursor ``--output-format stream-json`` lines.""" - session = "00000000-0000-4000-8000-000000000000" - events: list[dict[str, object]] = [ - {"type": "system", "subtype": "init", "session_id": session}, - { - "type": "user", - "message": {"role": "user", "content": [{"type": "text", "text": "unit"}]}, - "session_id": session, - }, - { - "type": "assistant", - "message": { - "role": "assistant", - "content": [{"type": "text", "text": final_text}], - }, - "session_id": session, - }, - { - "type": "result", - "subtype": "error" if is_error else "success", - "duration_ms": 10, - "is_error": is_error, - "result": final_text, - "session_id": session, - }, - ] - return [json.dumps(event, separators=(",", ":")) for event in events] - - -def _write_probe_unit(path: Path, timeout_seconds: int) -> None: - packet = { - "unit_id": "u5-real-probe", - "goal": ( - "Create src/u5-marker.txt containing the ASCII bytes " - "U5-REAL-PROBE-MARKER followed by one newline (0x0A). " - "Do not create or modify any other path." - ), - "allowed_paths": [U5_MARKER_REL], - "verification": ["test -f src/u5-marker.txt"], - "constraints": [ - "Write only src/u5-marker.txt", - "Exact bytes are U5-REAL-PROBE-MARKER plus a single trailing newline", - "Leave the change uncommitted", - "Do not commit, push, open a PR, merge, or deploy", - ], - "network": True, - "external_cost": True, - "timeout_seconds": timeout_seconds, - } - path.write_text(json.dumps(packet, indent=2) + "\n", encoding="utf-8") - - -def _cleanup_probe_runtime(repo: Path, receipt: dict[str, object] | None) -> None: - if not receipt: - return - run_dir = receipt.get("run_dir") - attempts = receipt.get("attempts") - if isinstance(attempts, list): - for attempt in attempts: - if not isinstance(attempt, dict): - continue - worktree = attempt.get("worktree") - if isinstance(worktree, str) and worktree: - _git(repo, "worktree", "remove", "--force", worktree, check=False) - shutil.rmtree(worktree, ignore_errors=True) - if isinstance(run_dir, str) and run_dir: - shutil.rmtree(run_dir, ignore_errors=True) - - -def _marker_probe_config( - profile: str, -) -> tuple[dict[str, dict[str, object]], list[str], list[str] | None, str]: - """Run exactly the selected profile from review; normal roles get holders.""" - selected = { - "adapter": profile, - "executable": U5_EXECUTABLE[profile], - "model": "auto", - } - return {profile: selected}, [], [profile], "review" - - -def execute_marker_probe( - profile: str, - *, - offline_bindir: Path | None, - timeout_seconds: int = 20, - retain_fixture: bool = False, -) -> dict[str, object]: - """Run one marker unit against a single profile. - - Offline fixtures pass ``offline_bindir`` and never use the ambient PATH. - A real profile requires RUN_REAL_AGENT_EXEC=1 and keeps its fixture. - """ - if profile not in U5_EXECUTABLE: - raise ValueError(f"unknown real probe profile: {profile}") - if offline_bindir is None and not real_probe_opt_in(): - raise RuntimeError( - "real Agent CLI probe refused without RUN_REAL_AGENT_EXEC=1" - ) - - tool_dir: tempfile.TemporaryDirectory[str] | None = None - repo_dir: tempfile.TemporaryDirectory[str] | None = None - repo: Path | None = None - receipt: dict[str, object] | None = None - try: - if offline_bindir is None: - probe_path = os.environ.get("PATH", "") - else: - tool_dir = _probe_tool_bindir(offline_bindir) - probe_path = tool_dir.name - if retain_fixture: - repo_root = Path(tempfile.mkdtemp(prefix="u5-real-probe-")) - else: - repo_dir = tempfile.TemporaryDirectory(prefix="u5-real-probe-") - repo_root = Path(repo_dir.name) - repo = repo_root / "repo" - repo.mkdir() - receipts = repo_root / "receipts" - receipts.mkdir() - init = _git(repo, "init", "-b", U5_BRANCH, check=False) - if init.returncode != 0: - _git(repo, "init") - _git(repo, "checkout", "-b", U5_BRANCH) - _git(repo, "config", "user.email", "agent-exec@example.com") - _git(repo, "config", "user.name", "Agent Exec") - (repo / "README.md").write_text("fixture\n", encoding="utf-8") - _git(repo, "add", "README.md") - _git(repo, "commit", "-m", "init") - branch = _git(repo, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() - if branch != U5_BRANCH: - raise RuntimeError( - f"probe fixture must use non-default branch {U5_BRANCH}, got {branch}" - ) - profiles, role_order, review_order, probe_role = _marker_probe_config(profile) - _write_config( - repo, - profiles=profiles, - role_order=role_order, - review_order=review_order, - timeout_seconds=timeout_seconds, - summary_bytes=4096, - raw_output_bytes=65536, - ) - before = _canonical_snapshot(repo) - unit = repo_root / "unit.json" - _write_probe_unit(unit, timeout_seconds) - receipt_path = receipts / "probe.json" - env = os.environ.copy() - env["PYTHONDONTWRITEBYTECODE"] = "1" - env["PATH"] = probe_path - workspace = Path(__file__).resolve().parents[1] - existing = env.get("PYTHONPATH", "") - env["PYTHONPATH"] = ( - f"{workspace}{os.pathsep}{existing}" if existing else str(workspace) - ) - proc = subprocess.run( - [ - "python3", - "-m", - "scripts.agent_exec", - "run", - "--repo", - str(repo), - "--role", - probe_role, - "--unit", - str(unit), - "--receipt", - str(receipt_path), - ], - cwd=str(repo_root), - capture_output=True, - text=True, - env=env, - timeout=timeout_seconds + 30, - ) - receipt = ( - json.loads(receipt_path.read_text(encoding="utf-8")) - if receipt_path.is_file() - else None - ) - outcome = _classify_marker_probe( - profile=profile, - repo=repo, - before=before, - receipt=receipt, - exit_code=proc.returncode, - stderr=proc.stderr, - ) - outcome["fixture_repo"] = str(repo) - if retain_fixture and receipt_path.is_file(): - durable = Path(f"/tmp/agent-exec-{os.getuid()}") / "u5-probes" - durable.mkdir(mode=0o700, parents=True, exist_ok=True) - os.chmod(durable, 0o700) - stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) - copied = durable / f"{profile}-{stamp}.json" - shutil.copy2(receipt_path, copied) - outcome["source_external_receipt"] = str(copied) - return outcome - finally: - if not retain_fixture and repo is not None: - _cleanup_probe_runtime(repo, receipt if isinstance(receipt, dict) else None) - if tool_dir is not None: - tool_dir.cleanup() - if repo_dir is not None: - repo_dir.cleanup() - - -def _probe_config_mismatch( - profile: str, - profiles: object, - roles: object, -) -> str | None: - """Reject a fixture that does not isolate the selected adapter. - - The selected profile is the whole review role. Implementation, repair, - and test hold only the uninvoked cursor/antigravity/PI holders, so strict - role validation passes and the selected CLI can reach PASS. - """ - if not isinstance(profiles, dict) or not isinstance(roles, dict): - return "probe configured a profile other than the selection" - if set(profiles) != {profile, *_PROBE_NORMAL_HOLDERS}: - return "probe configured a profile other than the selection" - for role in ("implementation", "repair", "test"): - if roles.get(role) != _PROBE_NORMAL_HOLDERS: - return f"{role} role must hold only the uninvoked normal holders" - if roles.get("review") != [profile]: - return "selected profile must run alone from the review role" - selected = profiles.get(profile) - if not isinstance(selected, dict) or selected.get("adapter") != profile: - return "probe configured a profile other than the selection" - return None - - -def _classify_marker_probe( - *, - profile: str, - repo: Path, - before: dict[str, str], - receipt: dict[str, object] | None, - exit_code: int, - stderr: str, -) -> dict[str, object]: - base: dict[str, object] = { - "profile": profile, - "exit_code": exit_code, - "canonical_preserved": _canonical_snapshot(repo) == before, - "marker_in_canonical": (repo / U5_MARKER_REL).exists(), - "configured_model": None, - "model_receipt_status": "not-observable", - "branch": None, - "changed_paths": [], - "transport_parent": None, - "transport_ref": None, - "attempt_status": None, - "run_terminal": None, - "cleanup": {}, - "host_fallback_authorizations": 0, - "base_head": before["head"], - } - if not base["canonical_preserved"]: - base["capability"] = "failed" - base["reason"] = "canonical checkout mutated" - return base - if base["marker_in_canonical"]: - base["capability"] = "failed" - base["reason"] = "canonical checkout contains the probe marker" - return base - if not isinstance(receipt, dict): - base["capability"] = "failed" - base["reason"] = f"missing receipt: {stderr[-500:]}" - return base - - base["run_terminal"] = receipt.get("terminal_status") - base["host_fallback_authorizations"] = receipt.get( - "host_fallback_authorizations", 0 - ) - summary = receipt.get("config_summary") - profiles = summary.get("profiles") if isinstance(summary, dict) else None - roles = summary.get("roles") if isinstance(summary, dict) else None - mismatch = _probe_config_mismatch(profile, profiles, roles) - if mismatch is not None: - base["capability"] = "failed" - base["reason"] = mismatch - return base - assert isinstance(profiles, dict) - selected = profiles[profile] - if isinstance(selected, dict): - base["configured_model"] = selected.get("model") - recorded_base = receipt.get("base") - if isinstance(recorded_base, dict): - base["branch"] = recorded_base.get("branch") - if base["branch"] in {"main", "master", "HEAD", None}: - base["capability"] = "failed" - base["reason"] = f"probe branch is not a non-default branch: {base['branch']}" - return base - - attempts = receipt.get("attempts") - if not isinstance(attempts, list) or len(attempts) != 1: - base["capability"] = "failed" - base["reason"] = "probe must record exactly one attempt" - return base - attempt = attempts[0] - if not isinstance(attempt, dict) or attempt.get("profile") != profile: - base["capability"] = "failed" - base["reason"] = "attempt profile does not match the selection" - return base - base["attempt_status"] = attempt.get("terminal_status") - base["changed_paths"] = list(attempt.get("changed_paths") or []) - base["transport_ref"] = attempt.get("transport_ref") - cleanup = attempt.get("cleanup") if isinstance(attempt.get("cleanup"), dict) else {} - base["cleanup"] = { - "exact_restore": bool(cleanup.get("exact_restore")), - "transport_created": bool(cleanup.get("transport_created")), - "workspace_preserved": bool(attempt.get("workspace_preserved")), - "skipped": bool(cleanup.get("skipped")), - } - base["model_receipt_status"] = _observe_model_receipt(attempt) - reason = attempt.get("reason") or receipt.get("reason") - - if attempt.get("terminal_status") == "unavailable": - base["capability"] = "unavailable" - base["reason"] = reason or "adapter unavailable" - if base["transport_ref"] or base["cleanup"]["transport_created"]: - base["capability"] = "failed" - base["reason"] = "unavailable probe still created transport" - return base - - if ( - receipt.get("terminal_status") == "success" - and attempt.get("terminal_status") == "success" - ): - return _classify_successful_marker( - repo=repo, - receipt=receipt, - attempt=attempt, - before=before, - base=base, - ) - - changed = list(attempt.get("changed_paths") or []) - if ( - base["canonical_preserved"] - and not base["marker_in_canonical"] - and not attempt.get("transport_ref") - and not changed - and attempt.get("terminal_status") - in {"failed-clean", "failed-unknown", "unavailable"} - ): - base["capability"] = "unavailable" - base["reason"] = reason or "probe produced no marker" - return base - - base["capability"] = "failed" - base["reason"] = reason or f"unclassified probe status {attempt.get('terminal_status')}" - return base - - -def _classify_successful_marker( - *, - repo: Path, - receipt: dict[str, object], - attempt: dict[str, object], - before: dict[str, str], - base: dict[str, object], -) -> dict[str, object]: - if base["model_receipt_status"] == "mismatch": - base["capability"] = "failed" - base["reason"] = "model receipt mismatch" - return base - if base["configured_model"] is not None: - base["capability"] = "failed" - base["reason"] = "auto model must omit the configured model value" - return base - changed = list(attempt.get("changed_paths") or []) - if changed != [U5_MARKER_REL]: - base["capability"] = "failed" - base["reason"] = f"changed paths are not exactly the marker: {changed}" - return base - cleanup = base["cleanup"] - if not isinstance(cleanup, dict): - base["capability"] = "failed" - base["reason"] = "missing cleanup record" - return base - if cleanup.get("exact_restore") or not cleanup.get("transport_created"): - base["capability"] = "failed" - base["reason"] = "success must retain transport and skip exact restore" - return base - if not cleanup.get("workspace_preserved"): - base["capability"] = "failed" - base["reason"] = "success must retain the isolated workspace until host review" - return base - transport = receipt.get("transport") - ref = attempt.get("transport_ref") - if not isinstance(transport, dict) or not isinstance(ref, str) or not ref: - base["capability"] = "failed" - base["reason"] = "success is missing a transport ref" - return base - if transport.get("ref") != ref: - base["capability"] = "failed" - base["reason"] = "receipt transport ref does not match the attempt" - return base - commit = _git(repo, "rev-parse", ref).stdout.strip() - parents = _git(repo, "rev-list", "--parents", "-n", "1", commit).stdout.split() - if len(parents) != 2: - base["capability"] = "failed" - base["reason"] = f"transport commit is not base-parented: {parents}" - return base - parent = parents[1] - base_head = before["head"] - recorded = receipt.get("base") - recorded_head = recorded.get("head") if isinstance(recorded, dict) else None - if parent != base_head or transport.get("parent") != base_head or recorded_head != base_head: - base["capability"] = "failed" - base["reason"] = "transport parent is not the fixture base HEAD" - return base - blob = _git(repo, "cat-file", "-p", f"{commit}:{U5_MARKER_REL}").stdout - worktree = attempt.get("worktree") - live = "" - if isinstance(worktree, str): - marker_path = Path(worktree) / U5_MARKER_REL - if marker_path.is_file(): - live = marker_path.read_text(encoding="utf-8") - if blob != U5_MARKER_BODY or live != U5_MARKER_BODY: - base["capability"] = "failed" - base["reason"] = "marker bytes differ from the required probe content" - base["marker_bytes"] = blob - return base - base["transport_parent"] = parent - base["marker_bytes"] = blob - base["capability"] = "PASS" - base["reason"] = None - return base - - -class RealAgentCliProbeTests(unittest.TestCase): - """U5 marker probe. Real providers run only behind RUN_REAL_AGENT_EXEC=1.""" - - def test_profile_selector_accepts_exactly_one_profile(self) -> None: - self.assertEqual(U5_PROFILES, tuple(U5_EXECUTABLE)) - self.assertFalse(real_probe_opt_in({"RUN_REAL_AGENT_EXEC": "true"})) - self.assertFalse(real_probe_opt_in({})) - self.assertTrue(real_probe_opt_in({"RUN_REAL_AGENT_EXEC": "1"})) - self.assertEqual( - require_single_real_profile({"AGENT_EXEC_REAL_PROFILE": "pi"}), - "pi", - ) - for raw in ("", " cursor", "cursor ", "cursor,codex", "cursor codex", "nope"): - with self.assertRaises(ValueError): - require_single_real_profile({"AGENT_EXEC_REAL_PROFILE": raw}) - - def test_real_probe_refuses_without_opt_in(self) -> None: - self.assertNotEqual(os.environ.get("RUN_REAL_AGENT_EXEC"), "1") - with self.assertRaises(RuntimeError) as caught: - execute_marker_probe("cursor", offline_bindir=None) - self.assertIn("RUN_REAL_AGENT_EXEC=1", str(caught.exception)) - - def test_offline_success_checks_marker_transport_and_canonical(self) -> None: - outcome = self._run_cursor( - _marker_run_body(body=U5_MARKER_BODY, model_receipt=None) - ) - self.assertEqual(outcome["capability"], "PASS", outcome) - self.assertEqual(outcome["marker_bytes"], U5_MARKER_BODY) - self.assertEqual(outcome["changed_paths"], [U5_MARKER_REL]) - self.assertEqual(outcome["transport_parent"], outcome["base_head"]) - self.assertTrue(outcome["transport_parent"]) - self.assertEqual(outcome["branch"], U5_BRANCH) - self.assertIsNone(outcome["configured_model"]) - self.assertEqual(outcome["model_receipt_status"], "not-observable") - self.assertEqual(outcome["run_terminal"], "success") - cleanup = outcome["cleanup"] - self.assertIsInstance(cleanup, dict) - assert isinstance(cleanup, dict) - self.assertTrue(cleanup["transport_created"]) - self.assertTrue(cleanup["workspace_preserved"]) - self.assertFalse(cleanup["exact_restore"]) - self.assertTrue(outcome["canonical_preserved"]) - self.assertFalse(outcome["marker_in_canonical"]) - self.assertEqual(outcome["host_fallback_authorizations"], 0) - - def test_observable_verified_model_receipt_stays_attached_to_success(self) -> None: - outcome = self._run_cursor( - _marker_run_body(body=U5_MARKER_BODY, model_receipt="verified") - ) - self.assertEqual(outcome["capability"], "PASS", outcome) - self.assertEqual(outcome["model_receipt_status"], "verified") - - def test_model_receipt_mismatch_is_not_pass(self) -> None: - outcome = self._run_cursor( - _marker_run_body(body=U5_MARKER_BODY, model_receipt="mismatch") - ) - self.assertEqual(outcome["capability"], "failed", outcome) - self.assertNotEqual(outcome["capability"], "PASS") - self.assertEqual(outcome["model_receipt_status"], "mismatch") - - def test_wrong_marker_is_not_pass(self) -> None: - outcome = self._run_cursor( - _marker_run_body(body="WRONG\n", model_receipt=None) - ) - self.assertEqual(outcome["capability"], "failed", outcome) - self.assertNotEqual(outcome["capability"], "PASS") - self.assertTrue(outcome["canonical_preserved"]) - - def test_missing_opencode_is_unavailable_not_pass(self) -> None: - outcome = self._run_offline("opencode", help_text="", run_body="exit 0\n", install=False) - self.assertEqual(outcome["capability"], "unavailable", outcome) - self.assertNotEqual(outcome["capability"], "PASS") - self.assertEqual(outcome["attempt_status"], "unavailable") - self.assertEqual(outcome["run_terminal"], "unavailable") - self.assertEqual(outcome["host_fallback_authorizations"], 0) - self.assertFalse(outcome["marker_in_canonical"]) - self.assertTrue(outcome["canonical_preserved"]) - cleanup = outcome["cleanup"] - assert isinstance(cleanup, dict) - self.assertTrue(cleanup["skipped"]) - self.assertFalse(cleanup["transport_created"]) - - def test_incompatible_opencode_is_unavailable_not_pass(self) -> None: - outcome = self._run_offline( - "opencode", - help_text="opencode\nusage: opencode\n", - run_body=( - "mkdir -p src\n" - f"printf '%s\\n' 'SHOULD-NOT-RUN' > {U5_MARKER_REL}\n" - "exit 0\n" - ), - install=True, - ) - self.assertEqual(outcome["capability"], "unavailable", outcome) - self.assertNotEqual(outcome["capability"], "PASS") - self.assertEqual(outcome["attempt_status"], "unavailable") - self.assertIn("missing required capability", str(outcome["reason"])) - self.assertFalse(outcome["marker_in_canonical"]) - self.assertNotEqual(outcome["run_terminal"], "success") - - def test_launched_failure_without_marker_is_unavailable_not_pass(self) -> None: - outcome = self._run_cursor("echo 'auth failed' >&2\nexit 3\n") - self.assertEqual(outcome["capability"], "unavailable", outcome) - self.assertNotEqual(outcome["capability"], "PASS") - self.assertNotEqual(outcome["run_terminal"], "success") - self.assertEqual(outcome["host_fallback_authorizations"], 0) - self.assertTrue(outcome["canonical_preserved"]) - self.assertFalse(outcome["marker_in_canonical"]) - - @unittest.skipUnless( - real_probe_opt_in(), - "real CLI probe NOT_RUN: set RUN_REAL_AGENT_EXEC=1 and " - "AGENT_EXEC_REAL_PROFILE to exactly one profile", - ) - def test_opt_in_real_profile_probe(self) -> None: - profile = require_single_real_profile(dict(os.environ)) - outcome = execute_marker_probe( - profile, - offline_bindir=None, - timeout_seconds=1800, - retain_fixture=True, - ) - print("U5-PROBE " + json.dumps(outcome, sort_keys=True, default=str)) - if profile == "opencode" and outcome["capability"] == "unavailable": - self.assertNotEqual(outcome["capability"], "PASS") - self.assertEqual(outcome["attempt_status"], "unavailable") - self.assertFalse(outcome["marker_in_canonical"]) - self.assertTrue(outcome["canonical_preserved"]) - return - self.assertEqual(outcome["capability"], "PASS", outcome) - self.assertEqual(outcome["marker_bytes"], U5_MARKER_BODY) - self.assertEqual(outcome["changed_paths"], [U5_MARKER_REL]) - self.assertTrue(outcome["transport_parent"]) - self.assertEqual(outcome["branch"], U5_BRANCH) - self.assertNotEqual(outcome["model_receipt_status"], "mismatch") - self.assertTrue(outcome["canonical_preserved"]) - - def _run_cursor(self, run_body: str) -> dict[str, object]: - return self._run_offline( - "cursor", - help_text=_cursor_help(), - run_body=run_body, - install=True, - ) - - def _run_offline( - self, - profile: str, - *, - help_text: str, - run_body: str, - install: bool, - ) -> dict[str, object]: - fake = tempfile.TemporaryDirectory(prefix="u5-fake-cli-") - self.addCleanup(fake.cleanup) - bindir = Path(fake.name) - if install: - _write_probe_double( - bindir, - U5_EXECUTABLE[profile], - help_text=help_text, - run_body=run_body, - ) - return execute_marker_probe( - profile, - offline_bindir=bindir, - timeout_seconds=20, - retain_fixture=False, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_agent_exec_adapters.py b/tests/test_agent_exec_adapters.py deleted file mode 100644 index 1171aa84..00000000 --- a/tests/test_agent_exec_adapters.py +++ /dev/null @@ -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 = "" - - -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 - -f, --force - --yolo - --sandbox choices: enabled, disabled - --trust - --workspace - --output-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 bypassPermissions, manual, dontAsk - --model - --output-format text, json, stream-json -""" - -CODEX_HELP = """ -Usage: codex exec [OPTIONS] [PROMPT] - exec - -s, --sandbox read-only, workspace-write, danger-full-access - -c, --config - -m, --model - -C, --cd - --json - -o, --output-last-message -""" - -PI_HELP = """ -Usage: pi [options] - --print, -p - --approve, -a - --model - --mode text, json, or rpc -""" - -KIMI_HELP = """ -Usage: kimi [options] - -p, --prompt - --auto - -m, --model - --output-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 \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() diff --git a/tests/test_agent_exec_admission.py b/tests/test_agent_exec_admission.py deleted file mode 100644 index ae3f49e4..00000000 --- a/tests/test_agent_exec_admission.py +++ /dev/null @@ -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") diff --git a/tests/test_agent_exec_config.py b/tests/test_agent_exec_config.py deleted file mode 100644 index 25b779a2..00000000 --- a/tests/test_agent_exec_config.py +++ /dev/null @@ -1,1062 +0,0 @@ -"""Configuration parser and schema contract for AGENT-EXECUTION.md.""" -from __future__ import annotations - -import tempfile -import textwrap -import unittest -from pathlib import Path - -from scripts.agent_exec_config import ( - DEFAULT_IDLE_TIMEOUT_SECONDS, - ConfigError, - ExecutionConfig, - load_config, - validate_config, -) - - -SCHEMA = "agent-execution/v1" - -MINIMAL_BLOCK = textwrap.dedent( - """\ - schema: agent-execution/v1 - - execution: - fresh_process_per_unit: true - permission: run-everything - fallback_to_host: all-unavailable - max_parallel: 3 - timeout_seconds: 7200 - summary_bytes: 16384 - raw_output_bytes: 10485760 - - roles: - implementation: [cursor, antigravity, pi] - repair: [cursor, antigravity, pi] - test: [pi, antigravity, cursor] - review: [pi] - - profiles: - cursor: - adapter: cursor - executable: cursor-agent - model: auto - enabled: true - - antigravity: - adapter: antigravity - executable: agy - model: auto - enabled: true - - pi: - adapter: pi - executable: pi - model: auto - enabled: true - """ -) - - -def write_repo(root: Path, body: str, *, extra_blocks: list[str] | None = None) -> Path: - parts = ["# Fixture\n", "```agent-execution\n", body.rstrip(), "\n```\n"] - if extra_blocks: - for block in extra_blocks: - parts.extend(["\n```agent-execution\n", block.rstrip(), "\n```\n"]) - (root / "AGENT-EXECUTION.md").write_text("".join(parts), encoding="utf-8") - return root - - -class AgentExecConfigTests(unittest.TestCase): - def setUp(self) -> None: - self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-config-") - self.addCleanup(self.tmp.cleanup) - self.repo = Path(self.tmp.name) - - def test_loads_single_agent_execution_block(self) -> None: - write_repo(self.repo, MINIMAL_BLOCK) - self.assertTrue(issubclass(ConfigError, ValueError)) - cfg = load_config(self.repo) - self.assertIsInstance(cfg, ExecutionConfig) - self.assertEqual(cfg.schema, SCHEMA) - self.assertTrue(cfg.execution.fresh_process_per_unit) - self.assertEqual(cfg.execution.permission, "run-everything") - self.assertEqual(cfg.execution.fallback_to_host, "all-unavailable") - self.assertEqual(cfg.roles["implementation"], ("cursor", "antigravity", "pi")) - self.assertEqual(cfg.profiles["cursor"].executable, "cursor-agent") - self.assertEqual(cfg.custom_adapters, {}) - self.assertEqual(validate_config(self.repo), []) - - def test_rejects_missing_or_duplicate_blocks(self) -> None: - (self.repo / "AGENT-EXECUTION.md").write_text("# no block\n", encoding="utf-8") - with self.assertRaises(ConfigError): - load_config(self.repo) - - write_repo(self.repo, MINIMAL_BLOCK, extra_blocks=[MINIMAL_BLOCK]) - with self.assertRaises(ConfigError): - load_config(self.repo) - - def test_rejects_unknown_schema_and_unknown_keys(self) -> None: - bad_schema = MINIMAL_BLOCK.replace( - "schema: agent-execution/v1", "schema: agent-execution/v0" - ) - write_repo(self.repo, bad_schema) - with self.assertRaises(ConfigError): - load_config(self.repo) - - bad_key = MINIMAL_BLOCK + "\nextra_top_level: true\n" - write_repo(self.repo, bad_key) - with self.assertRaises(ConfigError): - load_config(self.repo) - - bad_exec = MINIMAL_BLOCK.replace( - "raw_output_bytes: 10485760\n", - "raw_output_bytes: 10485760\n mystery: 1\n", - ) - write_repo(self.repo, bad_exec) - with self.assertRaises(ConfigError): - load_config(self.repo) - - def test_role_order_does_not_follow_profile_order(self) -> None: - # profiles listed pi before cursor; role order must stay cursor -> pi - block = textwrap.dedent( - """\ - schema: agent-execution/v1 - - execution: - 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 - - roles: - implementation: [cursor, antigravity, pi] - repair: [cursor, antigravity, pi] - test: [cursor, antigravity, pi] - review: [pi] - - profiles: - pi: - adapter: pi - executable: pi - model: auto - enabled: true - antigravity: - adapter: antigravity - executable: agy - model: auto - enabled: true - cursor: - adapter: cursor - executable: cursor-agent - model: auto - enabled: true - """ - ) - write_repo(self.repo, block) - cfg = load_config(self.repo) - self.assertEqual(cfg.roles["implementation"], ("cursor", "antigravity", "pi")) - self.assertEqual(list(cfg.profiles.keys())[0], "pi") - - def test_model_auto_is_preserved_as_none(self) -> None: - write_repo(self.repo, MINIMAL_BLOCK) - cfg = load_config(self.repo) - self.assertIsNone(cfg.profiles["cursor"].model) - self.assertIsNone(cfg.profiles["pi"].model) - - def test_rejects_unknown_profile_reference(self) -> None: - block = MINIMAL_BLOCK.replace( - "implementation: [cursor, antigravity, pi]", - "implementation: [cursor, antigravity, pi, missing]", - ) - write_repo(self.repo, block) - with self.assertRaises(ConfigError): - load_config(self.repo) - - def test_rejects_shell_command_and_secret_shaped_values(self) -> None: - shell = MINIMAL_BLOCK.replace( - "executable: cursor-agent", - "executable: \"cursor-agent && rm -rf /\"", - ) - write_repo(self.repo, shell) - with self.assertRaises(ConfigError): - load_config(self.repo) - - secret_key = MINIMAL_BLOCK.replace( - "enabled: true\n\n pi:", - "enabled: true\n api_token: \"sk-secret-value\"\n\n pi:", - ) - write_repo(self.repo, secret_key) - with self.assertRaises(ConfigError): - load_config(self.repo) - - secret_val = MINIMAL_BLOCK.replace( - "executable: pi", - "executable: \"Bearer abcdef0123456789abcdef0123456789\"", - ) - write_repo(self.repo, secret_val) - with self.assertRaises(ConfigError): - load_config(self.repo) - - def test_custom_placeholders_must_fill_whole_argv_items(self) -> None: - good = MINIMAL_BLOCK.replace("review: [pi]", "review: [mycustom]") + textwrap.dedent( - """\ - - mycustom: - adapter: custom - executable: mycli - model: auto - enabled: true - - custom_adapters: - mycustom: - base_argv: ["run", "${unit_packet}"] - run_everything_argv: ["--yes"] - workspace_argv: ["--cwd", "${workspace}"] - model_argv: ["--model", "${model}"] - prompt_transport: stdin - output_format: json - """ - ) - write_repo(self.repo, good) - cfg = load_config(self.repo) - self.assertEqual( - cfg.custom_adapters["mycustom"].workspace_argv, - ("--cwd", "${workspace}"), - ) - - bad = good.replace( - 'workspace_argv: ["--cwd", "${workspace}"]', - 'workspace_argv: ["--cwd=${workspace}"]', - ) - write_repo(self.repo, bad) - with self.assertRaises(ConfigError): - load_config(self.repo) - - unknown_ph = good.replace( - 'base_argv: ["run", "${unit_packet}"]', - 'base_argv: ["run", "${other}"]', - ) - write_repo(self.repo, unknown_ph) - with self.assertRaises(ConfigError): - load_config(self.repo) - - def test_numeric_limits_are_positive_and_bounded(self) -> None: - zero = MINIMAL_BLOCK.replace("max_parallel: 3", "max_parallel: 0") - write_repo(self.repo, zero) - with self.assertRaises(ConfigError): - load_config(self.repo) - - negative = MINIMAL_BLOCK.replace("timeout_seconds: 7200", "timeout_seconds: -1") - write_repo(self.repo, negative) - with self.assertRaises(ConfigError): - load_config(self.repo) - - huge = MINIMAL_BLOCK.replace("summary_bytes: 16384", "summary_bytes: 999999999") - write_repo(self.repo, huge) - with self.assertRaises(ConfigError): - load_config(self.repo) - - inverted = MINIMAL_BLOCK.replace( - "summary_bytes: 16384\n raw_output_bytes: 10485760", - "summary_bytes: 10485760\n raw_output_bytes: 16384", - ) - write_repo(self.repo, inverted) - with self.assertRaises(ConfigError): - load_config(self.repo) - - def test_fresh_process_per_unit_must_be_true(self) -> None: - block = MINIMAL_BLOCK.replace( - "fresh_process_per_unit: true", - "fresh_process_per_unit: false", - ) - write_repo(self.repo, block) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - self.assertIn("fresh_process_per_unit", str(ctx.exception)) - - def test_rejects_indentation_jump(self) -> None: - # root mapping child jumps from 0 spaces to 4 spaces (must be exactly +2) - jumped = textwrap.dedent( - """\ - schema: agent-execution/v1 - - execution: - 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 - - roles: - implementation: [cursor] - repair: [cursor] - test: [cursor] - review: [cursor] - - profiles: - cursor: - adapter: cursor - executable: cursor-agent - model: auto - enabled: true - """ - ) - write_repo(self.repo, jumped) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - self.assertRegex(str(ctx.exception).lower(), r"indent|indentation") - - def test_quoted_unicode_string_round_trips(self) -> None: - block = MINIMAL_BLOCK.replace( - "executable: pi", - 'executable: "模型-β-测试"', - ) - write_repo(self.repo, block) - cfg = load_config(self.repo) - self.assertEqual(cfg.profiles["pi"].executable, "模型-β-测试") - - escaped = MINIMAL_BLOCK.replace( - "executable: pi", - r'executable: "say \"hi\" 模型"', - ) - write_repo(self.repo, escaped) - cfg = load_config(self.repo) - self.assertEqual(cfg.profiles["pi"].executable, 'say "hi" 模型') - - def test_idle_timeout_defaults_within_total_and_rejects_bounds(self) -> None: - self.assertEqual(DEFAULT_IDLE_TIMEOUT_SECONDS, 600) - write_repo(self.repo, MINIMAL_BLOCK) - cfg = load_config(self.repo) - self.assertEqual(cfg.execution.idle_timeout_seconds, 600) - self.assertLessEqual(cfg.execution.idle_timeout_seconds, cfg.execution.timeout_seconds) - - short = MINIMAL_BLOCK.replace("timeout_seconds: 7200", "timeout_seconds: 60") - write_repo(self.repo, short) - cfg = load_config(self.repo) - self.assertEqual(cfg.execution.timeout_seconds, 60) - self.assertEqual(cfg.execution.idle_timeout_seconds, 60) - - explicit = MINIMAL_BLOCK.replace( - "timeout_seconds: 7200\n", - "timeout_seconds: 7200\n idle_timeout_seconds: 600\n", - ) - write_repo(self.repo, explicit) - self.assertEqual(load_config(self.repo).execution.idle_timeout_seconds, 600) - - equal = MINIMAL_BLOCK.replace( - "timeout_seconds: 7200\n", - "timeout_seconds: 100\n idle_timeout_seconds: 100\n", - ) - write_repo(self.repo, equal) - cfg = load_config(self.repo) - self.assertEqual(cfg.execution.idle_timeout_seconds, 100) - self.assertEqual(cfg.execution.timeout_seconds, 100) - - above = MINIMAL_BLOCK.replace( - "timeout_seconds: 7200\n", - "timeout_seconds: 100\n idle_timeout_seconds: 101\n", - ) - write_repo(self.repo, above) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - self.assertIn("idle_timeout_seconds", str(ctx.exception)) - - zero = MINIMAL_BLOCK.replace( - "timeout_seconds: 7200\n", - "timeout_seconds: 7200\n idle_timeout_seconds: 0\n", - ) - write_repo(self.repo, zero) - with self.assertRaises(ConfigError): - load_config(self.repo) - - negative = MINIMAL_BLOCK.replace( - "timeout_seconds: 7200\n", - "timeout_seconds: 7200\n idle_timeout_seconds: -5\n", - ) - write_repo(self.repo, negative) - with self.assertRaises(ConfigError): - load_config(self.repo) - - flagged = MINIMAL_BLOCK.replace( - "timeout_seconds: 7200\n", - "timeout_seconds: 7200\n idle_timeout_seconds: true\n", - ) - write_repo(self.repo, flagged) - with self.assertRaises(ConfigError): - load_config(self.repo) - - fractional = MINIMAL_BLOCK.replace( - "timeout_seconds: 7200\n", - "timeout_seconds: 7200\n idle_timeout_seconds: 1.5\n", - ) - write_repo(self.repo, fractional) - with self.assertRaises(ConfigError): - load_config(self.repo) - - def test_exposes_max_attempts_per_profile_and_emergency_profile(self) -> None: - write_repo(self.repo, _emergency_block()) - cfg = load_config(self.repo) - self.assertEqual(cfg.execution.max_attempts_per_profile, 3) - self.assertEqual(cfg.execution.emergency_profile, "codex") - - def test_configured_emergency_profile_must_exist_be_enabled_and_use_adapter_codex( - self, - ) -> None: - write_repo(self.repo, _emergency_block(include_profile=False)) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - self.assertIn("does not exist", str(ctx.exception)) - - write_repo(self.repo, _emergency_block(enabled="false")) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - self.assertIn("must be enabled", str(ctx.exception)) - - write_repo(self.repo, _emergency_block(adapter="pi")) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - self.assertIn("adapter codex", str(ctx.exception)) - - def test_emergency_profile_is_excluded_from_normal_roles(self) -> None: - for role, replacement in ( - ("implementation", "[cursor, pi, codex]"), - ("repair", "[cursor, codex]"), - ("test", "[pi, codex]"), - ): - write_repo(self.repo, _emergency_block(**{role: replacement})) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - self.assertIn("must not appear", str(ctx.exception)) - self.assertIn(role, str(ctx.exception)) - - def test_review_may_include_emergency_profile(self) -> None: - write_repo(self.repo, _emergency_block(review="[pi, codex]")) - cfg = load_config(self.repo) - self.assertEqual(cfg.execution.emergency_profile, "codex") - self.assertTrue(cfg.profiles["codex"].enabled) - self.assertEqual(cfg.profiles["codex"].adapter, "codex") - self.assertEqual(cfg.roles["review"], ("pi", "codex")) - self.assertNotIn("codex", cfg.roles["implementation"]) - self.assertNotIn("codex", cfg.roles["repair"]) - self.assertNotIn("codex", cfg.roles["test"]) - - def test_accepts_fallback_to_host_never(self) -> None: - write_repo(self.repo, _strict_routing_block(fallback="never")) - cfg = load_config(self.repo) - self.assertEqual(cfg.execution.fallback_to_host, "never") - self.assertEqual(cfg.execution.max_attempts_per_profile, 3) - self.assertEqual(cfg.execution.emergency_profile, "codex") - - def test_legacy_all_unavailable_remains_parseable(self) -> None: - write_repo(self.repo, _strict_routing_block(fallback="all-unavailable")) - cfg = load_config(self.repo) - self.assertEqual(cfg.execution.fallback_to_host, "all-unavailable") - - def test_emergency_profile_requires_max_attempts_exactly_three(self) -> None: - for raw_max in ("1", "2", "4", None): - with self.subTest(max_attempts=raw_max): - write_repo( - self.repo, - _strict_routing_block(fallback="all-unavailable", max_attempts=raw_max), - ) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - self.assertIn("max_attempts_per_profile", str(ctx.exception)) - - def test_normal_roles_reject_forbidden_adapters_and_alternate_codex_profile( - self, - ) -> None: - claude = textwrap.dedent( - """\ - claude: - adapter: claude - executable: claude - model: auto - enabled: true - """ - ) - claude = "\n".join( - f" {line}" if line else line for line in claude.strip("\n").splitlines() - ) - write_repo( - self.repo, - _strict_routing_block( - fallback="all-unavailable", - implementation="[cursor, claude]", - extra_profiles=claude, - ), - ) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - self.assertIn("implementation", str(ctx.exception)) - self.assertIn("cursor, antigravity, or pi", str(ctx.exception)) - - planner = textwrap.dedent( - """\ - planner: - adapter: codex - executable: planner - model: auto - enabled: true - """ - ) - planner = "\n".join( - f" {line}" if line else line for line in planner.strip("\n").splitlines() - ) - write_repo( - self.repo, - _strict_routing_block( - fallback="all-unavailable", - implementation="[cursor, planner]", - extra_profiles=planner, - ), - ) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - self.assertIn("planner", str(ctx.exception)) - self.assertIn("implementation", str(ctx.exception)) - self.assertNotIn("host-fallback", str(ctx.exception).lower()) - - def test_strict_emergency_mode_rejects_custom_normal_role_adapters(self) -> None: - worker = textwrap.dedent( - """\ - worker: - adapter: custom - executable: worker - model: auto - enabled: true - """ - ) - worker = "\n".join( - f" {line}" if line else line for line in worker.strip("\n").splitlines() - ) - adapters = textwrap.dedent( - """\ - custom_adapters: - worker: - base_argv: ["${workspace}", "${unit_packet}", "${result_path}"] - run_everything_argv: [] - workspace_argv: [] - model_argv: [] - prompt_transport: stdin - output_format: text - """ - ) - for role in ("implementation", "repair", "test"): - with self.subTest(role=role): - roles = { - "implementation": "[cursor]", - "repair": "[cursor]", - "test": "[pi]", - } - roles[role] = "[worker]" - block = _strict_routing_block( - implementation=roles["implementation"], - repair=roles["repair"], - test=roles["test"], - extra_profiles=worker, - ) - write_repo(self.repo, block.rstrip() + "\n\n" + adapters) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - message = str(ctx.exception) - self.assertIn("worker", message) - self.assertIn(role, message) - self.assertIn("cursor, antigravity, or pi", message) - - def test_emergency_normal_roles_reject_missing_required_adapter(self) -> None: - for role, replacement, missing in ( - ("implementation", "[cursor, pi]", "antigravity"), - ("repair", "[cursor, antigravity]", "pi"), - ("test", "[pi, antigravity]", "cursor"), - ): - with self.subTest(role=role): - write_repo(self.repo, _strict_routing_block(**{role: replacement})) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - message = str(ctx.exception) - self.assertIn(role, message) - self.assertIn(missing, message) - self.assertIn("exactly one enabled", message) - - def test_emergency_normal_roles_reject_disabled_only_adapter(self) -> None: - for role in ("implementation", "repair", "test"): - with self.subTest(role=role): - write_repo( - self.repo, - _strict_routing_block( - **{role: "[cursor, agy_off, pi]"}, - extra_profiles=_profile_lines("agy_off", "antigravity", "false"), - ), - ) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - message = str(ctx.exception) - self.assertIn(role, message) - self.assertIn("antigravity", message) - self.assertIn("exactly one enabled", message) - - def test_emergency_normal_roles_reject_multiple_enabled_same_adapter(self) -> None: - for role in ("implementation", "repair", "test"): - with self.subTest(role=role): - write_repo( - self.repo, - _strict_routing_block( - **{role: "[cursor, antigravity, pi, cursor2]"}, - extra_profiles=_profile_lines("cursor2", "cursor", "true"), - ), - ) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - message = str(ctx.exception) - self.assertIn(role, message) - self.assertIn("cursor2", message) - self.assertIn("exactly one enabled", message) - - def test_emergency_normal_roles_allow_extra_disabled_and_keep_order(self) -> None: - write_repo( - self.repo, - _strict_routing_block( - implementation="[cursor_off, pi, cursor, antigravity]", - repair="[antigravity, pi, cursor_off, cursor]", - extra_profiles=_profile_lines("cursor_off", "cursor", "false"), - ), - ) - cfg = load_config(self.repo) - self.assertEqual( - cfg.roles["implementation"], ("cursor_off", "pi", "cursor", "antigravity") - ) - self.assertEqual(cfg.roles["repair"], ("antigravity", "pi", "cursor_off", "cursor")) - self.assertEqual(cfg.roles["test"], ("pi", "antigravity", "cursor")) - self.assertFalse(cfg.profiles["cursor_off"].enabled) - - def test_emergency_exactness_does_not_apply_to_review(self) -> None: - write_repo(self.repo, _strict_routing_block(review="[codex]")) - cfg = load_config(self.repo) - self.assertEqual(cfg.roles["review"], ("codex",)) - - def test_without_emergency_custom_normal_roles_are_rejected(self) -> None: - write_repo(self.repo, _legacy_custom_block()) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - message = str(ctx.exception) - self.assertIn("mycustom", message) - self.assertIn("cursor, antigravity, or pi", message) - - def test_without_emergency_review_may_use_custom_adapter(self) -> None: - block = _legacy_custom_block().replace( - "implementation: [mycustom]\n" - " repair: [mycustom]\n" - " test: [mycustom]\n", - "implementation: [cursor, antigravity, pi]\n" - " repair: [cursor, antigravity, pi]\n" - " test: [pi, antigravity, cursor]\n", - ).replace( - "\ncustom_adapters:", - "\n" - + _profile_lines("cursor", "cursor", "true") - + "\n" - + _profile_lines("antigravity", "antigravity", "true") - + "\n" - + _profile_lines("pi", "pi", "true") - + "\n\ncustom_adapters:", - ) - write_repo(self.repo, block) - cfg = load_config(self.repo) - self.assertIsNone(cfg.execution.emergency_profile) - self.assertEqual(cfg.roles["review"], ("mycustom",)) - self.assertEqual(cfg.profiles["mycustom"].adapter, "custom") - - def test_without_emergency_normal_roles_need_exact_enabled_adapter_set(self) -> None: - no_emergency = {"emergency": None, "max_attempts": None, "review": "[pi]"} - worker = _profile_lines("worker", "custom", "true") - adapters = ( - "custom_adapters:\n" - " worker:\n" - ' base_argv: ["${workspace}", "${unit_packet}", "${result_path}"]\n' - " run_everything_argv: []\n" - " workspace_argv: []\n" - " model_argv: []\n" - " prompt_transport: stdin\n" - " output_format: text\n" - ) - claude = _profile_lines("claude", "claude", "true") - for role in ("implementation", "repair", "test"): - cases = ( - ("missing", "[cursor, pi]", "", "exactly one enabled"), - ( - "disabled-only", - "[cursor, agy_off, pi]", - _profile_lines("agy_off", "antigravity", "false"), - "exactly one enabled", - ), - ( - "duplicate-enabled", - "[cursor, antigravity, pi, cursor2]", - _profile_lines("cursor2", "cursor", "true"), - "exactly one enabled", - ), - ( - "custom-extra", - "[cursor, antigravity, pi, worker]", - worker, - "cursor, antigravity, or pi", - ), - ( - "custom-only", - "[worker]", - worker, - "cursor, antigravity, or pi", - ), - ( - "other-builtin", - "[cursor, antigravity, pi, claude]", - claude, - "cursor, antigravity, or pi", - ), - ) - for label, replacement, extra, expected in cases: - with self.subTest(role=role, case=label): - block = _strict_routing_block( - **no_emergency, **{role: replacement}, extra_profiles=extra - ) - if extra is worker: - block = block.rstrip() + "\n\n" + adapters - write_repo(self.repo, block) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - message = str(ctx.exception) - self.assertIn(role, message) - self.assertIn(expected, message) - - def test_without_emergency_disabled_extras_are_ignored_and_order_kept(self) -> None: - write_repo( - self.repo, - _strict_routing_block( - emergency=None, - max_attempts=None, - review="[pi]", - implementation="[pi, cursor_off, antigravity, cursor]", - test="[antigravity, cursor, pi, cursor_off]", - extra_profiles=_profile_lines("cursor_off", "cursor", "false"), - ), - ) - cfg = load_config(self.repo) - self.assertIsNone(cfg.execution.emergency_profile) - self.assertEqual( - cfg.roles["implementation"], ("pi", "cursor_off", "antigravity", "cursor") - ) - self.assertEqual(cfg.roles["repair"], ("cursor", "antigravity", "pi")) - self.assertEqual(cfg.roles["test"], ("antigravity", "cursor", "pi", "cursor_off")) - - def test_max_attempts_per_profile_never_exceeds_three(self) -> None: - for raw_max in ("1", "2", "3"): - with self.subTest(max_attempts=raw_max): - write_repo( - self.repo, - _strict_routing_block( - emergency=None, max_attempts=raw_max, review="[pi]" - ), - ) - cfg = load_config(self.repo) - self.assertEqual(cfg.execution.max_attempts_per_profile, int(raw_max)) - for raw_max in ("4", "10"): - for emergency in (None, "codex"): - with self.subTest(max_attempts=raw_max, emergency=emergency): - write_repo( - self.repo, - _strict_routing_block(emergency=emergency, max_attempts=raw_max), - ) - with self.assertRaises(ConfigError) as ctx: - load_config(self.repo) - self.assertIn("max_attempts_per_profile", str(ctx.exception)) - - def test_checked_in_project_config_never_authorizes_host_fallback(self) -> None: - repo = Path(__file__).resolve().parents[1] - cfg = load_config(repo) - self.assertEqual(cfg.execution.fallback_to_host, "never") - self.assertEqual(cfg.execution.max_attempts_per_profile, 3) - self.assertEqual(cfg.execution.emergency_profile, "codex") - allowed = {"cursor", "antigravity", "pi"} - for role in ("implementation", "repair", "test"): - self.assertNotIn("codex", cfg.roles[role]) - for name in cfg.roles[role]: - self.assertIn(cfg.profiles[name].adapter, allowed) - self.assertIn("codex", cfg.roles["review"]) - self.assertTrue(cfg.profiles["codex"].enabled) - self.assertEqual(cfg.profiles["codex"].adapter, "codex") - - def test_valid_integration_block_parses_and_is_exposed(self) -> None: - write_repo(self.repo, MINIMAL_BLOCK + "\n" + VALID_INTEGRATION) - cfg = load_config(self.repo) - self.assertIsNotNone(cfg.integration) - assert cfg.integration is not None - self.assertEqual( - cfg.integration.exempt_paths, - (".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"), - ) - self.assertEqual( - cfg.integration.transport_command, "python3 scripts/agent_exec.py apply" - ) - self.assertEqual( - cfg.integration.admission_command, "python3 scripts/agent_exec.py admit" - ) - self.assertEqual(cfg.integration.cleanup_command, "make task-finish") - - def test_missing_integration_still_parses_as_none(self) -> None: - write_repo(self.repo, MINIMAL_BLOCK) - self.assertIsNone(load_config(self.repo).integration) - - def test_integration_rejects_unknown_and_missing_keys(self) -> None: - cases = { - "unknown key in integration": VALID_INTEGRATION.replace( - " cleanup:\n", " extra: x\n cleanup:\n" - ), - "unknown key in transport": VALID_INTEGRATION.replace( - " command: python3 scripts/agent_exec.py apply\n", - " command: python3 scripts/agent_exec.py apply\n extra: x\n", - ), - "missing cleanup": VALID_INTEGRATION.split(" cleanup:")[0], - } - for label, block in cases.items(): - with self.subTest(label): - write_repo(self.repo, MINIMAL_BLOCK + "\n" + block) - with self.assertRaises(ConfigError): - load_config(self.repo) - - def test_integration_exempt_paths_must_be_unique_nonempty_and_exact(self) -> None: - cases = { - "empty": 'exempt_paths: []', - "duplicate": ( - 'exempt_paths: [".task/**", ".task/**", ' - '"docs/superpowers/specs/**", "docs/superpowers/plans/**"]' - ), - "widened": ( - 'exempt_paths: [".task/**", "docs/superpowers/specs/**", ' - '"docs/superpowers/plans/**", "src/**"]' - ), - "narrowed": 'exempt_paths: [".task/**", "docs/superpowers/specs/**"]', - "parent traversal": ( - 'exempt_paths: [".task/**", "docs/superpowers/specs/**", ' - '"docs/superpowers/plans/../../**"]' - ), - "absolute": ( - 'exempt_paths: [".task/**", "docs/superpowers/specs/**", ' - '"/docs/superpowers/plans/**"]' - ), - } - for label, value in cases.items(): - with self.subTest(label): - block = VALID_INTEGRATION.replace( - 'exempt_paths: [".task/**", "docs/superpowers/specs/**", ' - '"docs/superpowers/plans/**"]', - value, - ) - write_repo(self.repo, MINIMAL_BLOCK + "\n" + block) - with self.assertRaises(ConfigError): - load_config(self.repo) - - def test_integration_command_rejects_shell_metacharacters(self) -> None: - block = VALID_INTEGRATION.replace( - "command: python3 scripts/agent_exec.py apply", - "command: python3 scripts/agent_exec.py apply; rm -rf /", - ) - write_repo(self.repo, MINIMAL_BLOCK + "\n" + block) - with self.assertRaises(ConfigError): - load_config(self.repo) - - def test_host_exempt_paths_match_executor_enforcement(self) -> None: - from scripts import agent_exec - from scripts.agent_exec_config import HOST_EXEMPT_PATHS - - enforced = {prefix.rstrip("/") for prefix in agent_exec._HOST_MANAGED_PREFIXES} - declared = {glob.removesuffix("/**") for glob in HOST_EXEMPT_PATHS} - self.assertEqual(declared, enforced) - - def test_checked_in_project_config_declares_integration(self) -> None: - cfg = load_config(Path(__file__).resolve().parents[1]) - self.assertIsNotNone(cfg.integration) - assert cfg.integration is not None - self.assertEqual( - set(cfg.integration.exempt_paths), - {".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"}, - ) - self.assertEqual(cfg.integration.cleanup_command, "make task-finish") - - -VALID_INTEGRATION = textwrap.dedent( - """\ - integration: - exempt_paths: [".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"] - transport: - command: python3 scripts/agent_exec.py apply - admission: - command: python3 scripts/agent_exec.py admit - cleanup: - command: make task-finish - """ -) - - -def _legacy_custom_block() -> str: - """No emergency_profile and adapter custom on every role.""" - return textwrap.dedent( - """\ - schema: agent-execution/v1 - - execution: - 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 - - roles: - implementation: [mycustom] - repair: [mycustom] - test: [mycustom] - review: [mycustom] - - profiles: - mycustom: - adapter: custom - executable: mycli - model: auto - enabled: true - - custom_adapters: - mycustom: - base_argv: ["run", "${unit_packet}"] - run_everything_argv: ["--yes"] - workspace_argv: ["--cwd", "${workspace}"] - model_argv: ["--model", "${model}"] - prompt_transport: stdin - output_format: json - """ - ) - - -def _strict_routing_block( - *, - fallback: str = "never", - max_attempts: str | None = "3", - emergency: str | None = "codex", - implementation: str = "[cursor, antigravity, pi]", - repair: str = "[cursor, antigravity, pi]", - test: str = "[pi, antigravity, cursor]", - review: str = "[pi, codex]", - extra_profiles: str = "", -) -> str: - lines = [ - "schema: agent-execution/v1", - "", - "execution:", - " fresh_process_per_unit: true", - " permission: run-everything", - f" fallback_to_host: {fallback}", - " max_parallel: 1", - " timeout_seconds: 60", - " summary_bytes: 1024", - " raw_output_bytes: 4096", - ] - if max_attempts is not None: - lines.append(f" max_attempts_per_profile: {max_attempts}") - if emergency is not None: - lines.append(f" emergency_profile: {emergency}") - lines.extend( - [ - "", - "roles:", - f" implementation: {implementation}", - f" repair: {repair}", - f" test: {test}", - f" review: {review}", - "", - "profiles:", - " cursor:", - " adapter: cursor", - " executable: cursor-agent", - " model: auto", - " enabled: true", - "", - " antigravity:", - " adapter: antigravity", - " executable: agy", - " model: auto", - " enabled: true", - "", - " pi:", - " adapter: pi", - " executable: pi", - " model: auto", - " enabled: true", - "", - " codex:", - " adapter: codex", - " executable: codex", - " model: auto", - " enabled: true", - ] - ) - if extra_profiles: - lines.append(extra_profiles.rstrip("\n")) - return "\n".join(lines) + "\n" - - -def _profile_lines(name: str, adapter: str, enabled: str) -> str: - return ( - f" {name}:\n" - f" adapter: {adapter}\n" - f" executable: {name}\n" - " model: auto\n" - f" enabled: {enabled}" - ) - - -def _emergency_block( - *, - include_profile: bool = True, - adapter: str = "codex", - enabled: str = "true", - implementation: str = "[cursor, antigravity, pi]", - repair: str = "[cursor, antigravity, pi]", - test: str = "[pi, antigravity, cursor]", - review: str = "[pi]", -) -> str: - block = MINIMAL_BLOCK.replace( - " raw_output_bytes: 10485760\n", - " raw_output_bytes: 10485760\n" - " max_attempts_per_profile: 3\n" - " emergency_profile: codex\n", - ) - block = block.replace( - "implementation: [cursor, antigravity, pi]", f"implementation: {implementation}" - ) - block = block.replace("repair: [cursor, antigravity, pi]", f"repair: {repair}") - block = block.replace("test: [pi, antigravity, cursor]", f"test: {test}") - block = block.replace("review: [pi]", f"review: {review}") - if not include_profile: - return block - return block.replace( - " enabled: true\n", - " enabled: true\n" - "\n" - " codex:\n" - f" adapter: {adapter}\n" - " executable: codex\n" - " model: auto\n" - f" enabled: {enabled}\n", - 1, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_task.py b/tests/test_task.py index e91712fe..dd6ff536 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -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)