agent-exec transport b11015860dac4bb8a1943889211374b7/0dd2eb729ad2
This commit is contained in:
@@ -43,7 +43,10 @@ policy/delivery.md。
|
||||
- 外部 Agent 在新进程/隔离工作区执行一个有界单元;host 负责计划、拆解、范围审阅、权威验证、规范提交、
|
||||
PR/CI/合入、证据与任务元数据及安全清理。Worker 不得提交、推送、开或合 PR、部署、改路由或派遣同伴。
|
||||
- 实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI,顺序以配置为准。每个候选对
|
||||
安全可恢复失败(`unavailable` 或已精确恢复的 `failed-clean`)最多 3 次全新尝试,然后才换下一候选。
|
||||
安全可恢复失败(`unavailable`,或外部无副作用已被机制证明的 `failed-clean`)最多 3 次全新尝试,然后才换下一候选。
|
||||
单元包的 `network=false`/`external_cost=false` 只是声明,不是证明;没有强制隔离或可信对账时,
|
||||
已启动的失败尝试即使私有克隆可精确恢复也记 `failed-unknown`。成功还须从该 CLI 实际事件信封解出
|
||||
有效的 `completed` 终态记录(schema 写在 worker 提示词中);缺失或无效即 fail closed。
|
||||
三者都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、实施计划
|
||||
或独立 Review,不进入这三个正常角色。`fallback_to_host` 为 `never`。安全耗尽不发出
|
||||
host-fallback-authorized,终态为 unavailable 或 blocked,不授权 host 撰写该单元。旧值
|
||||
@@ -54,7 +57,9 @@ policy/delivery.md。
|
||||
- 一次尝试只在显式终态失败、进程退出、总超时或上述空闲超时时结束。CLI 可在目标、范围与安全边界内
|
||||
自主检查、计划、测试并迭代。
|
||||
- 实施变更只经成功 transport 由 `scripts/agent_exec.py apply` 集成,或由 pre-commit `admit` 按精确内容
|
||||
证明来源。host 直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**` 与
|
||||
做本地完整性核对。本地 HMAC 历史与该钩子只是本地完整性/篡改证据(SELF),不是 GATE、可信身份,
|
||||
也不防同一主机 OS 用户;本地记录缺失或无效仍拒绝,交付信任只来自精确 HEAD 的 CI 或独立 REPLAY。
|
||||
host 直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**` 与
|
||||
`docs/superpowers/plans/**`。host 不直接撰写实施单元。细则见
|
||||
[policy/delivery.md](policy/delivery.md) 与 [policy/governance.md](policy/governance.md)。
|
||||
不削弱 goal-first、严格范围、先可用、独立 Review、证据、平台准入与高后果双钥匙规则。
|
||||
|
||||
+13
-2
@@ -74,8 +74,10 @@
|
||||
隔离工作区中尝试外部 Agent。不得跨单元复用同一外部进程,也不得 resume 同一次尝试。
|
||||
|
||||
实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI。每个候选对安全可恢复失败
|
||||
最多 3 次全新尝试:`unavailable`,或工作区已精确恢复的 `failed-clean`。三次仍属这类失败后才进入
|
||||
下一候选。三个正常候选都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试;这次不按每候选 3 次计算。
|
||||
最多 3 次全新尝试:`unavailable`,或外部无副作用已被机制证明的 `failed-clean`。三次仍属这类失败后才进入
|
||||
下一候选。单元包的 `network=false` 与 `external_cost=false` 只是声明,不证明外部干净;当前没有强制的
|
||||
网络/外部费用隔离或可信对账,所以已启动的失败尝试即使私有克隆可精确恢复,也记为 `failed-unknown`
|
||||
并保留现场。三个正常候选都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试;这次不按每候选 3 次计算。
|
||||
紧急尝试若仍是安全可恢复失败,运行停在 `blocked`。host 不接过该单元继续撰写。
|
||||
|
||||
Codex 平时只用于规划、方案设计、实施计划,或独立 Review。执行配置没有单独的 plan 角色;
|
||||
@@ -89,6 +91,12 @@ Codex 平时只用于规划、方案设计、实施计划,或独立 Review。
|
||||
一次尝试只在显式终态失败、进程退出、总超时或该空闲超时时结束。推理时长、工具次数、规划方式和
|
||||
首次写文件时间都不是结束条件。CLI 可在该单元的目标、范围与安全边界内自主检查、计划、测试并迭代。
|
||||
|
||||
worker 提示词写明终态记录的 JSON Schema(`terminal_status`、`summary`、`changed_files`、`evidence`、
|
||||
`scope_expansion`)。控制器只从该 CLI 实际事件信封解码:Cursor `stream-json` 的 `result` 事件、
|
||||
agy `stream-json` 的 `result` 事件或 `json` 输出对象、PI `json` 模式最后一条 assistant `message_end`。
|
||||
退出码 0 且有改动时,只有有效的 `completed` 记录才可能成功;记录缺失、无效或仅有散文均记
|
||||
`failed-unknown`。`blocked`、`cancelled`、`failed-unknown` 记录直接按该终态停止,不得成功。
|
||||
|
||||
主模型(host)负责计划、拆解、有界单元包、强制范围、检查与审阅实际 diff、运行权威验证、
|
||||
规范提交、管理 PR/CI/合入、写入证据与任务管理元数据,以及安全清理。权威验证、集成、合入与
|
||||
任务卡证据所有权属于 host;worker 的局部自检不能替代任务卡证据、独立 Review、当前 HEAD 的 CI
|
||||
@@ -103,6 +111,9 @@ Codex 平时只用于规划、方案设计、实施计划,或独立 Review。
|
||||
实施结果进入当前分支,必须来自一次成功 transport,由 host 执行
|
||||
`scripts/agent_exec.py apply` 在干净分支上快进;或者本次暂存内容通过 pre-commit 调用的
|
||||
`admit`:非豁免路径与该成功 transport 的路径和精确内容一致,且 transport 的父提交是当前 HEAD。
|
||||
apply/admit 依赖的本地 HMAC 执行历史与 checkout 钩子只是本地完整性/篡改证据(SELF):同一主机
|
||||
OS 用户可读取密钥或绕过钩子,因此它不是 GATE、可信身份或防恶意主体的保护。本地记录缺失或无效
|
||||
仍拒绝;交付信任只来自精确 HEAD 的必需 CI(GATE)或独立 REPLAY。
|
||||
host 可以直接编写并提交的豁免只有 `.task/**`、`docs/superpowers/specs/**` 和
|
||||
`docs/superpowers/plans/**`。除此之外,host 不直接撰写实施单元。
|
||||
|
||||
|
||||
@@ -25,9 +25,12 @@
|
||||
[迭代交付](delivery.md)。
|
||||
|
||||
实施变更进入仓库,只通过成功 transport 的 `scripts/agent_exec.py apply`,或 pre-commit `admit`
|
||||
对暂存内容的精确来源核对。host 可以直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**`
|
||||
对暂存内容的本地完整性核对。本地 HMAC 执行历史与该钩子只记作本地完整性/篡改证据(SELF),
|
||||
不是 GATE、可信身份,也不防同一主机 OS 用户;本地记录缺失或无效仍拒绝,交付信任来自精确 HEAD 的
|
||||
CI 或独立 REPLAY。host 可以直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**`
|
||||
与 `docs/superpowers/plans/**`。host 不直接撰写实施单元。`failed-unknown`、`scope-expansion`、
|
||||
无效配置、未对账 Git 状态与安全失败都不授权继续路由或代写。
|
||||
无效配置、未对账 Git 状态与安全失败都不授权继续路由或代写。`network=false`/`external_cost=false`
|
||||
声明不证明外部干净,已启动的失败尝试在没有强制隔离或可信对账时记 `failed-unknown`。
|
||||
|
||||
## 启动与恢复
|
||||
|
||||
|
||||
+196
-230
@@ -1,4 +1,10 @@
|
||||
"""Recoverable agent-exec controller: validate, run, apply transports, admit provenance."""
|
||||
"""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
|
||||
@@ -29,7 +35,11 @@ _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, probe_adapter
|
||||
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,
|
||||
@@ -183,7 +193,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
p_admit = sub.add_parser(
|
||||
"admit",
|
||||
help="Admit staged paths backed by a success transport or host exemption",
|
||||
help=(
|
||||
"Locally check staged paths against a success transport or host "
|
||||
"exemption (local integrity evidence, not GATE)"
|
||||
),
|
||||
)
|
||||
p_admit.add_argument("--repo", required=True)
|
||||
|
||||
@@ -791,7 +804,7 @@ def _run_attempt(
|
||||
pointer_delta = _snapshot_delta(pointer_before, pointer_after)
|
||||
canonical_delta = _snapshot_delta(canonical_before, canonical_after)
|
||||
|
||||
worker_result = _load_worker_result(result_path, proc_meta["stdout_tail"])
|
||||
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,
|
||||
@@ -922,7 +935,17 @@ def _run_attempt(
|
||||
cleanup_extra={"reason": "worker fail-stop terminal"},
|
||||
)
|
||||
|
||||
# Never report success from worker prose alone — require exit + admin + in-scope diff + transport.
|
||||
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:
|
||||
@@ -975,121 +998,23 @@ def _run_attempt(
|
||||
observed_version=probe.observed_version,
|
||||
)
|
||||
|
||||
if clean_exit and not changed:
|
||||
# No diff does not prove an authorized external effect was reconciled.
|
||||
if _external_side_effects_unproven(unit, worker_result):
|
||||
return _preserved(
|
||||
"failed-unknown",
|
||||
(
|
||||
"worker exited 0 without in-scope changes; "
|
||||
"external side effects cannot be reconciled"
|
||||
),
|
||||
cleanup_extra={
|
||||
"reason": "network or external_cost authorized without trustworthy receipt",
|
||||
"network": unit.network,
|
||||
"external_cost": unit.external_cost,
|
||||
},
|
||||
)
|
||||
restored = _restore_worktree(repo, worktree, base["head"])
|
||||
if not restored:
|
||||
return _preserved(
|
||||
"failed-unknown",
|
||||
"exact restore failed",
|
||||
cleanup_extra={"reason": "exact restore failed"},
|
||||
)
|
||||
return AttemptRecord(
|
||||
attempt_id=attempt_id,
|
||||
profile=profile.name,
|
||||
adapter=profile.adapter,
|
||||
terminal_status="failed-clean",
|
||||
reason="worker exited 0 without in-scope changes",
|
||||
worktree=str(worktree),
|
||||
pid=proc_meta["pid"],
|
||||
exit_code=proc_meta["exit_code"],
|
||||
changed_paths=[],
|
||||
inventory_paths=[],
|
||||
raw_log=str(raw_log),
|
||||
summary=summary,
|
||||
cleanup={"exact_restore": True},
|
||||
observed_version=probe.observed_version,
|
||||
)
|
||||
|
||||
# Nonzero / aborted without trustworthy external reconciliation.
|
||||
if _external_side_effects_unproven(unit, worker_result):
|
||||
return _preserved(
|
||||
"failed-unknown",
|
||||
(
|
||||
f"worker failed with exit {proc_meta['exit_code']}; "
|
||||
"external side effects cannot be reconciled"
|
||||
),
|
||||
cleanup_extra={
|
||||
"reason": "network or external_cost authorized without trustworthy receipt",
|
||||
"network": unit.network,
|
||||
"external_cost": unit.external_cost,
|
||||
},
|
||||
)
|
||||
|
||||
# Controlled case: network=false and external_cost=false may become failed-clean
|
||||
# only after exact worktree restoration proves no unknown authorized external object.
|
||||
if changed:
|
||||
restored = _restore_worktree(repo, worktree, base["head"])
|
||||
if restored:
|
||||
return AttemptRecord(
|
||||
attempt_id=attempt_id,
|
||||
profile=profile.name,
|
||||
adapter=profile.adapter,
|
||||
terminal_status="failed-clean",
|
||||
reason=f"worker failed with exit {proc_meta['exit_code']}; restored",
|
||||
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,
|
||||
cleanup={
|
||||
"exact_restore": True,
|
||||
"external_objects_reconciled": True,
|
||||
"network": False,
|
||||
"external_cost": False,
|
||||
},
|
||||
observed_version=probe.observed_version,
|
||||
)
|
||||
return _preserved(
|
||||
"failed-unknown",
|
||||
"worker failed; exact restore not proven",
|
||||
)
|
||||
|
||||
restored = _restore_worktree(repo, worktree, base["head"])
|
||||
if not restored:
|
||||
return _preserved(
|
||||
"failed-unknown",
|
||||
"exact restore failed",
|
||||
cleanup_extra={"reason": "exact restore failed"},
|
||||
)
|
||||
return AttemptRecord(
|
||||
attempt_id=attempt_id,
|
||||
profile=profile.name,
|
||||
adapter=profile.adapter,
|
||||
terminal_status="failed-clean",
|
||||
reason=f"worker failed with exit {proc_meta['exit_code']}",
|
||||
worktree=str(worktree),
|
||||
pid=proc_meta["pid"],
|
||||
exit_code=proc_meta["exit_code"],
|
||||
changed_paths=[],
|
||||
inventory_paths=[],
|
||||
raw_log=str(raw_log),
|
||||
summary=summary,
|
||||
cleanup={
|
||||
"exact_restore": True,
|
||||
"external_objects_reconciled": True,
|
||||
"network": False,
|
||||
"external_cost": False,
|
||||
# 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,
|
||||
},
|
||||
observed_version=probe.observed_version,
|
||||
)
|
||||
|
||||
|
||||
def _supervise(
|
||||
argv: tuple[str, ...],
|
||||
*,
|
||||
@@ -1113,7 +1038,7 @@ def _supervise(
|
||||
exit_code: int | None = None
|
||||
sig: int | None = None
|
||||
pid: int | None = None
|
||||
stdout_tail = _ByteTail(OUTPUT_TAIL_BYTES)
|
||||
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.
|
||||
@@ -1386,6 +1311,7 @@ def _supervise(
|
||||
"signal": sig,
|
||||
"stdout_tail": stdout_text,
|
||||
"stderr_tail": stderr_text,
|
||||
"terminal_lines": stdout_tail.terminal_lines(),
|
||||
"summary_source": summary_source,
|
||||
}
|
||||
|
||||
@@ -1408,6 +1334,74 @@ class _ByteTail:
|
||||
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)
|
||||
@@ -1695,20 +1689,6 @@ def _worktree_pointer_snapshot(
|
||||
return snap
|
||||
|
||||
|
||||
def _external_side_effects_unproven(
|
||||
unit: UnitPacket, worker_result: dict[str, Any] | None
|
||||
) -> bool:
|
||||
"""Network or external_cost units cannot be failed-clean without reconciliation.
|
||||
|
||||
A clean exit with no file diff is included. failed-clean still requires exact
|
||||
restore plus affirmative evidence that no authorized external object remains
|
||||
unknown. Worker prose is not that evidence; only an explicit trustworthy
|
||||
reconciliation record would clear the unit, and none is accepted here.
|
||||
"""
|
||||
del worker_result # Worker prose alone never proves external reconciliation.
|
||||
return bool(unit.network or unit.external_cost)
|
||||
|
||||
|
||||
def _is_within_root(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.resolve().relative_to(root.resolve())
|
||||
@@ -1818,20 +1798,6 @@ def _create_transport_commit(
|
||||
return ref
|
||||
|
||||
|
||||
def _restore_worktree(repo: Path, worktree: Path, base_head: str) -> bool:
|
||||
"""Exact-restore the private clone then remove it after verification."""
|
||||
try:
|
||||
_git(worktree, "reset", "--hard", base_head)
|
||||
_git(worktree, "clean", "-fdx")
|
||||
head = _git(worktree, "rev-parse", "HEAD").stdout.strip()
|
||||
dirty = _git(worktree, "status", "--porcelain").stdout.strip()
|
||||
ok = head == base_head and not dirty
|
||||
_safe_remove_worktree(repo, worktree)
|
||||
return ok
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
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.
|
||||
@@ -1840,29 +1806,21 @@ def _safe_remove_worktree(repo: Path, worktree: Path) -> None:
|
||||
|
||||
|
||||
def _load_worker_result(
|
||||
result_path: Path, stdout_tail: str
|
||||
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:
|
||||
data = json.loads(result_path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
# Try last JSON object in stdout.
|
||||
text = stdout_tail.strip()
|
||||
if not text:
|
||||
return None
|
||||
for candidate in (text, text[text.rfind("{") :] if "{" in text else ""):
|
||||
if not candidate.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
data = json.loads(candidate)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
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(
|
||||
@@ -1958,7 +1916,7 @@ def _finish_run(
|
||||
)
|
||||
history_path: Path | None = None
|
||||
try:
|
||||
history_path, attestation = _append_execution_history(
|
||||
history_path, integrity_mac = _append_execution_history(
|
||||
repo, receipt, secret_values
|
||||
)
|
||||
recorded: dict[str, Any] = {
|
||||
@@ -1967,8 +1925,8 @@ def _finish_run(
|
||||
"record_id": receipt.get("run_id"),
|
||||
"run_id": receipt.get("run_id"),
|
||||
}
|
||||
if attestation:
|
||||
recorded["attestation"] = attestation
|
||||
if integrity_mac:
|
||||
recorded["local_integrity_mac"] = integrity_mac
|
||||
receipt["history"] = recorded
|
||||
except Exception as exc:
|
||||
receipt["history"] = {
|
||||
@@ -2001,12 +1959,12 @@ def _append_execution_history(
|
||||
history_path = history_dir / "execution-history.jsonl"
|
||||
if history_path.is_symlink():
|
||||
raise RuntimeError("agent-exec history file must not be a symlink")
|
||||
attestation: str | None = None
|
||||
integrity_mac: str | None = None
|
||||
if receipt.get("terminal_status") == "success":
|
||||
_bind_success_transport_commit(repo, receipt)
|
||||
key = _ensure_attestation_key(repo)
|
||||
key = _ensure_local_integrity_key(repo)
|
||||
record = _execution_history_record(repo, receipt)
|
||||
line, attestation = _signed_history_line(record, key, secret_values)
|
||||
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"
|
||||
@@ -2027,7 +1985,7 @@ def _append_execution_history(
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return history_path, attestation
|
||||
return history_path, integrity_mac
|
||||
|
||||
|
||||
def _execution_history_record(repo: Path, receipt: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -2335,8 +2293,12 @@ def _valid_transport_ref(ref: object) -> str | None:
|
||||
return ref
|
||||
|
||||
|
||||
_ATTESTATION_KEY_NAME = "attestation.key"
|
||||
_ATTESTATION_KEY_BYTES = 32
|
||||
# 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:
|
||||
@@ -2363,27 +2325,27 @@ def _canonical_json(data: dict[str, Any]) -> str:
|
||||
return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _history_attestation(key: bytes, record: dict[str, Any]) -> str:
|
||||
body = {field: value for field, value in record.items() if field != "attestation"}
|
||||
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 _history_attestation_ok(key: bytes, record: dict[str, Any]) -> bool:
|
||||
presented = record.get("attestation")
|
||||
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(_history_attestation(key, record), presented)
|
||||
return hmac.compare_digest(_local_integrity_mac(key, record), presented)
|
||||
|
||||
|
||||
def _signed_history_line(
|
||||
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 = _history_attestation(key, body)
|
||||
body["attestation"] = mac
|
||||
mac = _local_integrity_mac(key, body)
|
||||
body[_LOCAL_INTEGRITY_FIELD] = mac
|
||||
return _canonical_json(body) + "\n", mac
|
||||
|
||||
|
||||
@@ -2456,22 +2418,22 @@ def _locked_agent_exec_dir(repo: Path, *, create: bool) -> Iterator[int | None]:
|
||||
|
||||
def _read_key_at(dir_fd: int) -> bytes:
|
||||
fd = os.open(
|
||||
_ATTESTATION_KEY_NAME,
|
||||
_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("attestation key must be a regular file")
|
||||
raise RuntimeError("local integrity key must be a regular file")
|
||||
if stat.S_IMODE(current.st_mode) != 0o600:
|
||||
raise RuntimeError("attestation key mode must be 0600")
|
||||
if current.st_size != _ATTESTATION_KEY_BYTES:
|
||||
raise RuntimeError("attestation key must be 32 bytes")
|
||||
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, _ATTESTATION_KEY_BYTES)
|
||||
if len(key) != _ATTESTATION_KEY_BYTES:
|
||||
raise RuntimeError("attestation key must be 32 bytes")
|
||||
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)
|
||||
@@ -2479,63 +2441,63 @@ def _read_key_at(dir_fd: int) -> bytes:
|
||||
|
||||
def _key_is_symlink(dir_fd: int) -> bool:
|
||||
try:
|
||||
info = os.stat(_ATTESTATION_KEY_NAME, dir_fd=dir_fd, follow_symlinks=False)
|
||||
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_attestation_key(repo: Path) -> bytes | None:
|
||||
"""Read the local attestation key. Never creates one and never follows links."""
|
||||
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("attestation key must not be a symlink")
|
||||
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, "attestation key must not be a symlink") from exc
|
||||
raise _symlink_oserror(exc, "local integrity key must not be a symlink") from exc
|
||||
|
||||
|
||||
def _ensure_attestation_key(repo: Path) -> bytes:
|
||||
def _ensure_local_integrity_key(repo: Path) -> bytes:
|
||||
"""Create or read the 32-byte key under the Git common dir.
|
||||
|
||||
This binds controller history to later apply/admit checks. It does not
|
||||
protect against the same OS user deliberately reading the key file.
|
||||
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("attestation key must not be a symlink")
|
||||
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(_ATTESTATION_KEY_NAME, flags, 0o600, dir_fd=dir_fd)
|
||||
fd = os.open(_LOCAL_INTEGRITY_KEY_NAME, flags, 0o600, dir_fd=dir_fd)
|
||||
except OSError as exc:
|
||||
raise _symlink_oserror(exc, "attestation key must not be a symlink") from 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("attestation key must be a regular file")
|
||||
raise RuntimeError("local integrity key must be a regular file")
|
||||
if current.st_size == 0:
|
||||
key = os.urandom(_ATTESTATION_KEY_BYTES)
|
||||
key = os.urandom(_LOCAL_INTEGRITY_KEY_BYTES)
|
||||
written = os.write(fd, key)
|
||||
if written != _ATTESTATION_KEY_BYTES:
|
||||
raise RuntimeError("attestation key write was short")
|
||||
if written != _LOCAL_INTEGRITY_KEY_BYTES:
|
||||
raise RuntimeError("local integrity key write was short")
|
||||
os.fsync(fd)
|
||||
return key
|
||||
if current.st_size != _ATTESTATION_KEY_BYTES:
|
||||
raise RuntimeError("attestation key must be 32 bytes")
|
||||
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, _ATTESTATION_KEY_BYTES)
|
||||
if len(key) != _ATTESTATION_KEY_BYTES:
|
||||
raise RuntimeError("attestation key must be 32 bytes")
|
||||
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("attestation key mode must be 0600")
|
||||
raise RuntimeError("local integrity key mode must be 0600")
|
||||
return key
|
||||
finally:
|
||||
os.close(fd)
|
||||
@@ -2574,7 +2536,7 @@ def _transport_covers(
|
||||
required: set[str],
|
||||
key: bytes | None,
|
||||
) -> bool:
|
||||
if key is None or not _history_attestation_ok(key, record):
|
||||
if key is None or not _local_integrity_ok(key, record):
|
||||
return False
|
||||
if record.get("schema") != HISTORY_SCHEMA:
|
||||
return False
|
||||
@@ -2587,32 +2549,32 @@ def _transport_covers(
|
||||
return False
|
||||
ref = _valid_transport_ref(transport.get("ref"))
|
||||
parent = transport.get("parent")
|
||||
signed_commit = transport.get("commit")
|
||||
if ref is None or not isinstance(parent, str) or not isinstance(signed_commit, str):
|
||||
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) != signed_commit:
|
||||
if _resolve_commit(repo, ref) != recorded_commit:
|
||||
return False
|
||||
if _resolve_commit(repo, parent) != head:
|
||||
return False
|
||||
if _commit_parents(repo, signed_commit) != [head]:
|
||||
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, signed_commit)
|
||||
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, signed_commit, required)
|
||||
return _index_matches_commit(repo, recorded_commit, required)
|
||||
|
||||
|
||||
def _apply_signed_history_ok(
|
||||
def _apply_local_integrity_ok(
|
||||
repo: Path,
|
||||
receipt: dict[str, Any],
|
||||
*,
|
||||
commit: str,
|
||||
base_head: str,
|
||||
) -> bool:
|
||||
key = _read_attestation_key(repo)
|
||||
key = _read_local_integrity_key(repo)
|
||||
if key is None:
|
||||
return False
|
||||
run_id = receipt.get("run_id")
|
||||
@@ -2648,7 +2610,7 @@ def _apply_signed_history_ok(
|
||||
continue
|
||||
if rec_transport.get("commit") != commit:
|
||||
continue
|
||||
if _history_attestation_ok(key, record):
|
||||
if _local_integrity_ok(key, record):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -2717,13 +2679,13 @@ def cmd_apply(repo: Path, receipt_path: Path) -> int:
|
||||
return _reject("apply: mismatched transport", secrets)
|
||||
|
||||
try:
|
||||
authorized = _apply_signed_history_ok(
|
||||
authorized = _apply_local_integrity_ok(
|
||||
repo, receipt, commit=commit, base_head=resolved_base
|
||||
)
|
||||
except Exception as exc:
|
||||
return _reject(f"apply: no signed history: {exc}", secrets)
|
||||
return _reject(f"apply: no local integrity record: {exc}", secrets)
|
||||
if not authorized:
|
||||
return _reject("apply: no signed history", secrets)
|
||||
return _reject("apply: no local integrity record", secrets)
|
||||
|
||||
merge = _git(repo, "merge", "--ff-only", "--quiet", commit, check=False)
|
||||
if merge.returncode != 0:
|
||||
@@ -2736,7 +2698,11 @@ def cmd_apply(repo: Path, receipt_path: Path) -> int:
|
||||
|
||||
|
||||
def cmd_admit(repo: Path) -> int:
|
||||
"""Accept staged host-managed paths, or paths exactly covered by one transport."""
|
||||
"""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:
|
||||
@@ -2747,13 +2713,13 @@ def cmd_admit(repo: Path) -> int:
|
||||
return EXIT_SUCCESS
|
||||
head = _resolve_commit(repo, "HEAD")
|
||||
if head is None:
|
||||
return _reject("admit: no provenance", secrets)
|
||||
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_attestation_key(repo)
|
||||
key = _read_local_integrity_key(repo)
|
||||
for record in _load_history_records(repo):
|
||||
if _transport_covers(
|
||||
repo, record, head=head, required=required, key=key
|
||||
@@ -2761,9 +2727,9 @@ def cmd_admit(repo: Path) -> int:
|
||||
print("admit: ok")
|
||||
return EXIT_SUCCESS
|
||||
except Exception as exc:
|
||||
return _reject(f"admit: no provenance: {exc}", secrets)
|
||||
return _reject(f"admit: no local integrity record: {exc}", secrets)
|
||||
listed = ", ".join(sorted(required))
|
||||
return _reject(f"admit: no provenance for {listed}", secrets)
|
||||
return _reject(f"admit: no local integrity record for {listed}", secrets)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,11 +5,14 @@ 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,
|
||||
@@ -23,6 +26,34 @@ _PROBE_TIMEOUT_SECONDS = 5
|
||||
MAX_UNIT_PACKET_BYTES = 262_144
|
||||
UNIT_PACKET_DISPLAY_MARKER = "<unit-packet>"
|
||||
|
||||
TERMINAL_STATUSES = (
|
||||
"completed",
|
||||
"failed",
|
||||
"blocked",
|
||||
"cancelled",
|
||||
"failed-unknown",
|
||||
"scope-expansion",
|
||||
)
|
||||
TERMINAL_RECORD_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"terminal_status",
|
||||
"summary",
|
||||
"changed_files",
|
||||
"evidence",
|
||||
"scope_expansion",
|
||||
],
|
||||
"properties": {
|
||||
"terminal_status": {"enum": list(TERMINAL_STATUSES)},
|
||||
"summary": {"type": "string"},
|
||||
"changed_files": {"type": "array", "items": {"type": "string"}},
|
||||
"evidence": {"type": "array", "items": {"type": "string"}},
|
||||
"scope_expansion": {"type": ["string", "null"]},
|
||||
},
|
||||
}
|
||||
# Bounds the backwards brace scan over one final message.
|
||||
_MAX_TERMINAL_TEXT_CHARS = 262_144
|
||||
|
||||
_PLACEHOLDER_MAP = {
|
||||
"${workspace}": "workspace",
|
||||
"${unit_packet}": "unit_packet",
|
||||
@@ -378,17 +409,177 @@ def _read_unit_packet_text(path: Path) -> str:
|
||||
|
||||
|
||||
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 last valid terminal record embedded in one final message."""
|
||||
if not isinstance(text, str) or not text:
|
||||
return None
|
||||
text = text[-_MAX_TERMINAL_TEXT_CHARS:]
|
||||
decoder = json.JSONDecoder()
|
||||
pos = text.rfind("{")
|
||||
while pos != -1:
|
||||
try:
|
||||
candidate, _ = decoder.raw_decode(text, pos)
|
||||
except json.JSONDecodeError:
|
||||
candidate = None
|
||||
record = _valid_terminal_record(candidate)
|
||||
if record is not None:
|
||||
return record
|
||||
pos = text.rfind("{", 0, pos)
|
||||
return None
|
||||
|
||||
|
||||
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],
|
||||
@@ -480,11 +671,15 @@ def _sanitize_display(
|
||||
|
||||
__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",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地硬闸(pre-commit):集成分支直提拒绝 + 分支命名。权威准入在 PR(baseline.md:26)。
|
||||
# 本地检查(pre-commit):集成分支直提拒绝 + 分支命名 + 本地完整性核对。权威准入在 PR(baseline.md:26)。
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
case "$branch" in
|
||||
main|master)
|
||||
@@ -11,4 +11,6 @@ echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || {
|
||||
exit 1
|
||||
}
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
# admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子,
|
||||
# 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 "$root/scripts/agent_exec.py" admit --repo "$root"
|
||||
|
||||
+405
-108
@@ -212,6 +212,84 @@ def _antigravity_help() -> str:
|
||||
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-")
|
||||
@@ -308,19 +386,6 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
def test_run_uses_fresh_process_and_worktree_per_attempt(self) -> None:
|
||||
meta = self.root / "meta"
|
||||
meta.mkdir()
|
||||
_write_fake_worker(
|
||||
self.bindir,
|
||||
"worker-a",
|
||||
body=textwrap.dedent(
|
||||
f"""\
|
||||
workspace="$1"
|
||||
echo "$$" > "{meta}/a.pid"
|
||||
echo "$workspace" > "{meta}/a.wt"
|
||||
# fail clean after no writes
|
||||
exit 7
|
||||
"""
|
||||
),
|
||||
)
|
||||
_write_fake_worker(
|
||||
self.bindir,
|
||||
"worker-b",
|
||||
@@ -328,8 +393,8 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
f"""\
|
||||
workspace="$1"
|
||||
result="$3"
|
||||
echo "$$" > "{meta}/b.pid"
|
||||
echo "$workspace" > "{meta}/b.wt"
|
||||
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"
|
||||
@@ -338,38 +403,36 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
self._configure(
|
||||
profiles={
|
||||
"a": {"adapter": "custom", "executable": "worker-a"},
|
||||
"b": {"adapter": "custom", "executable": "worker-b"},
|
||||
},
|
||||
role_order=["a", "b"],
|
||||
profiles={"b": {"adapter": "custom", "executable": "worker-b"}},
|
||||
role_order=["b"],
|
||||
)
|
||||
self.base_snap = _canonical_snapshot(self.repo)
|
||||
unit = _write_unit(self.root / "unit.json")
|
||||
receipt = self.receipts / "fresh.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")
|
||||
a_pid = (meta / "a.pid").read_text(encoding="utf-8").strip()
|
||||
b_pid = (meta / "b.pid").read_text(encoding="utf-8").strip()
|
||||
a_wt = (meta / "a.wt").read_text(encoding="utf-8").strip()
|
||||
b_wt = (meta / "b.wt").read_text(encoding="utf-8").strip()
|
||||
self.assertNotEqual(a_pid, b_pid)
|
||||
self.assertNotEqual(a_wt, b_wt)
|
||||
self.assertTrue(a_wt.startswith(f"/tmp/agent-exec-{os.getuid()}/"))
|
||||
self.assertTrue(b_wt.startswith(f"/tmp/agent-exec-{os.getuid()}/"))
|
||||
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:
|
||||
@@ -499,7 +562,10 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
self.assertFalse((self.repo / "src" / "marker.txt").exists())
|
||||
self._assert_canonical_untouched()
|
||||
|
||||
def test_failed_clean_can_continue_after_exact_restore(self) -> None:
|
||||
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",
|
||||
@@ -516,12 +582,8 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
self.bindir,
|
||||
"ok",
|
||||
body=textwrap.dedent(
|
||||
"""\
|
||||
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"
|
||||
f"""\
|
||||
echo next >> "{launched}"
|
||||
exit 0
|
||||
"""
|
||||
),
|
||||
@@ -534,7 +596,10 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
role_order=["dirty", "ok"],
|
||||
)
|
||||
unit = _write_unit(self.root / "unit.json")
|
||||
receipt = self.receipts / "clean.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",
|
||||
@@ -546,29 +611,26 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
"--receipt",
|
||||
str(receipt),
|
||||
)
|
||||
self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout)
|
||||
self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout)
|
||||
data = json.loads(receipt.read_text(encoding="utf-8"))
|
||||
self.assertEqual(data["attempts"][0]["terminal_status"], "failed-clean")
|
||||
self.assertTrue(data["attempts"][0]["cleanup"]["exact_restore"])
|
||||
self.assertEqual(data["terminal_status"], "success")
|
||||
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_failed_clean_retries_profile_before_next_success(self) -> None:
|
||||
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,
|
||||
"retryme",
|
||||
body=textwrap.dedent(
|
||||
f"""\
|
||||
printf 'retryme\\n' >> "{launches}"
|
||||
workspace="$1"
|
||||
mkdir -p "$workspace/src"
|
||||
printf 'tmp\\n' > "$workspace/src/marker.txt"
|
||||
exit 9
|
||||
"""
|
||||
),
|
||||
)
|
||||
_write_fake_worker(
|
||||
self.bindir,
|
||||
"thenok",
|
||||
@@ -586,7 +648,7 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
)
|
||||
self._configure(
|
||||
profiles={
|
||||
"retryme": {"adapter": "custom", "executable": "retryme"},
|
||||
"retryme": {"adapter": "custom", "executable": "retryme-missing"},
|
||||
"thenok": {"adapter": "custom", "executable": "thenok"},
|
||||
},
|
||||
role_order=["retryme", "thenok"],
|
||||
@@ -611,7 +673,7 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
observed = [
|
||||
line for line in launches.read_text(encoding="utf-8").splitlines() if line
|
||||
]
|
||||
self.assertEqual(observed, ["retryme", "retryme", "retryme", "thenok"])
|
||||
self.assertEqual(observed, ["thenok"])
|
||||
data = json.loads(receipt.read_text(encoding="utf-8"))
|
||||
self.assertEqual(
|
||||
[
|
||||
@@ -623,9 +685,9 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
for attempt in data["attempts"]
|
||||
],
|
||||
[
|
||||
("retryme", 1, "failed-clean"),
|
||||
("retryme", 2, "failed-clean"),
|
||||
("retryme", 3, "failed-clean"),
|
||||
("retryme", 1, "unavailable"),
|
||||
("retryme", 2, "unavailable"),
|
||||
("retryme", 3, "unavailable"),
|
||||
("thenok", 1, "success"),
|
||||
],
|
||||
)
|
||||
@@ -634,13 +696,15 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
)
|
||||
self._assert_canonical_untouched()
|
||||
|
||||
def test_emergency_profile_runs_once_after_regular_failed_clean(self) -> None:
|
||||
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", _cursor_help()),
|
||||
("antigravity", "agy-regular", _antigravity_help()),
|
||||
("pi", "pi-regular", _pi_help()),
|
||||
("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(
|
||||
@@ -650,9 +714,6 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
body=textwrap.dedent(
|
||||
f"""\
|
||||
printf '{adapter}\\n' >> "{launches}"
|
||||
mkdir -p src
|
||||
printf 'tmp\\n' > src/marker.txt
|
||||
printf '%s\\n' '{{"terminal_status":"failed","summary":"{adapter} failed clean","changed_files":["src/marker.txt"]}}'
|
||||
exit 9
|
||||
"""
|
||||
),
|
||||
@@ -736,7 +797,7 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
recorded,
|
||||
[
|
||||
(adapter, ordinal, "failed-clean", False)
|
||||
(adapter, ordinal, "unavailable", False)
|
||||
for adapter in order
|
||||
for ordinal in (1, 2, 3)
|
||||
]
|
||||
@@ -748,10 +809,7 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
observed = [
|
||||
line for line in launches.read_text(encoding="utf-8").splitlines() if line
|
||||
]
|
||||
self.assertEqual(
|
||||
observed,
|
||||
["cursor"] * 3 + ["antigravity"] * 3 + ["pi"] * 3 + ["codex"],
|
||||
)
|
||||
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()
|
||||
@@ -2068,8 +2126,8 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
self.assertEqual(data.get("host_fallback_authorizations", 0), 0)
|
||||
self._assert_canonical_untouched()
|
||||
|
||||
def test_unproven_restore_is_failed_unknown_and_stops(self) -> None:
|
||||
"""failed-clean is allowed only after _restore_worktree succeeds."""
|
||||
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",
|
||||
@@ -2104,15 +2162,12 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
)
|
||||
unit = _write_unit(self.root / f"unit-restore-{label}.json")
|
||||
receipt = self.receipts / f"restore-{label}.json"
|
||||
with mock.patch.object(
|
||||
agent_exec, "_restore_worktree", return_value=False
|
||||
):
|
||||
code = agent_exec.cmd_run(
|
||||
self.repo,
|
||||
role="implementation",
|
||||
unit_path=unit,
|
||||
receipt_path=receipt,
|
||||
)
|
||||
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")
|
||||
@@ -2251,6 +2306,218 @@ class AgentExecControllerTests(unittest.TestCase):
|
||||
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```json\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,
|
||||
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() + "\nThanks.")
|
||||
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_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:
|
||||
@@ -2493,7 +2760,8 @@ class AgentExecHistoryTests(unittest.TestCase):
|
||||
self.assertEqual(identity["status"], "recorded")
|
||||
self.assertEqual(identity["run_id"], receipt["run_id"])
|
||||
self.assertEqual(identity["record_id"], record["record_id"])
|
||||
self.assertEqual(identity["attestation"], record["attestation"])
|
||||
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:
|
||||
@@ -2517,7 +2785,7 @@ class AgentExecHistoryTests(unittest.TestCase):
|
||||
def load() -> None:
|
||||
try:
|
||||
barrier.wait(timeout=5)
|
||||
found.append(agent_exec._ensure_attestation_key(self.repo))
|
||||
found.append(agent_exec._ensure_local_integrity_key(self.repo))
|
||||
except BaseException as exc: # noqa: BLE001 - race failures are the assertion
|
||||
errors.append(exc)
|
||||
|
||||
@@ -2563,8 +2831,9 @@ _PROBE_NORMAL_HOLDER = "piholder"
|
||||
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)"'
|
||||
r'\\?"model_receipt_status\\?"\s*:\s*\\?"(verified|not-observable|mismatch|unavailable)\\?"'
|
||||
)
|
||||
|
||||
|
||||
@@ -2671,18 +2940,46 @@ def _marker_run_body(*, body: str, model_receipt: str | None) -> str:
|
||||
}
|
||||
if model_receipt is not None:
|
||||
payload["model_receipt_status"] = model_receipt
|
||||
encoded = json.dumps(payload, separators=(",", ":"))
|
||||
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')"
|
||||
)
|
||||
return (
|
||||
f"python3 -c {shlex.quote(writer)}\n"
|
||||
f"printf '%s\\n' {shlex.quote(encoded)}\n"
|
||||
"exit 0\n"
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -409,7 +409,7 @@ class AgentExecAdmissionTests(unittest.TestCase):
|
||||
)
|
||||
combined = proc.stdout + proc.stderr
|
||||
self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, combined)
|
||||
self.assertIn("apply: no signed history", proc.stderr)
|
||||
self.assertIn("apply: no local integrity record", proc.stderr)
|
||||
self.assertNotIn("apply: fast-forwarded", proc.stdout)
|
||||
self.assertEqual(self._state(), before, label)
|
||||
|
||||
@@ -471,7 +471,7 @@ class AgentExecAdmissionTests(unittest.TestCase):
|
||||
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 provenance", proc.stderr)
|
||||
self.assertIn("admit: no local integrity record", proc.stderr)
|
||||
self.assertEqual(self._state(), before)
|
||||
|
||||
def test_admit_accepts_exact_transport_paths(self) -> None:
|
||||
@@ -496,7 +496,7 @@ class AgentExecAdmissionTests(unittest.TestCase):
|
||||
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 provenance", 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"})
|
||||
@@ -515,7 +515,7 @@ class AgentExecAdmissionTests(unittest.TestCase):
|
||||
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 provenance", proc.stderr)
|
||||
self.assertIn("admit: no local integrity record", proc.stderr)
|
||||
self.assertNotIn("admit: ok", proc.stdout)
|
||||
self.assertEqual(self._state(), before)
|
||||
|
||||
@@ -556,7 +556,7 @@ class AgentExecAdmissionTests(unittest.TestCase):
|
||||
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 provenance", moved_proc.stderr)
|
||||
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)
|
||||
@@ -568,7 +568,7 @@ class AgentExecAdmissionTests(unittest.TestCase):
|
||||
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 provenance", proc.stderr)
|
||||
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:
|
||||
@@ -610,7 +610,7 @@ class AgentExecAdmissionTests(unittest.TestCase):
|
||||
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 provenance", rejected.stderr)
|
||||
self.assertIn("admit: no local integrity record", rejected.stderr)
|
||||
|
||||
def test_precommit_rejects_unproven_paths_after_branch_checks(self) -> None:
|
||||
self._install_project_hook()
|
||||
@@ -618,7 +618,7 @@ class AgentExecAdmissionTests(unittest.TestCase):
|
||||
proc = self._hook()
|
||||
combined = proc.stdout + proc.stderr
|
||||
self.assertNotEqual(proc.returncode, 0, combined)
|
||||
self.assertIn("admit: no provenance", combined)
|
||||
self.assertIn("admit: no local integrity record", combined)
|
||||
|
||||
_git(self.repo, "checkout", "-B", "main", self.head)
|
||||
self._stage("src/extra.txt", "unproven\n")
|
||||
|
||||
Reference in New Issue
Block a user