Files
rune/scripts/check_tool_executor_lifecycle_parity.py
Nick 07c08c341e feat(tool-executor): lifecycle family gap-close — _execute_tool_now/serialize/deserialize/execute_tool/confirm_write/_find_capability to Go
Dichotomy wave #2479, contract factory-heavy-te-life. Name-preserving
ports of the six audit-missing lifecycle fns into gaps_lifecycle.go with
a 52-row frozen oracle battery (truth_lifecycle.json, hermetic generator,
byte-identical regen) and 49/49 live parity (per-side isolated roots).
Repairs en route: write-denial message now carries Python str(list) bytes
(pyListRepr); ReadFile numbers with the RAW offset (offset=0 renders
'0: line 1' — old clamp rewrote the footer). str(e)-law TypeError
envelopes, present-but-null vs absent args, broker seam for
_find_capability. Sibling-gap arms pinned as notPorted sentinels
(documented divergences, verification/te-life-summary.md).
2026-10-15 00:39:03 +01:00

325 lines
13 KiB
Python

#!/usr/bin/env python3
"""check_tool_executor_lifecycle_parity.py — LIVE parity for the
tool_executor lifecycle family gap ports (dichotomy wave #2479, contract
factory-heavy-te-life).
Oracle: src/rune/tool_executor.py imported LIVE (in-process).
Go side: the tool-executor package via `go test -run TestLifecycleParityDriver`
with LIFECYCLE_CASES / LIFECYCLE_OUT (the driver normalizes identically).
Design:
- cases are authored with {{TMP}}/{{WROOT}} placeholder paths, then
materialized into TWO isolated root pairs (oracle side A, Go side B) —
neither side inherits the other's side effects (a confirm_write by the
oracle must not make the Go run back the file up);
- each side's own roots are re-anonymized to {{TMP}}/{{WROOT}} in its
outputs before comparison, so identical envelopes compare equal;
- normalizations identical on both sides: uuid token → {{TOKEN}},
operation_digest → {{DIGEST}} (structural invariant), diff → summary
(head 2 + plus/minus), float-canon via canonical JSON dump;
- the broker stub rides the find_cap cases verbatim.
Excluded (documented divergences, verification/te-life-summary.md):
sibling-gap dispatch arms (notPorted), bool-offset footer rendering,
git/lint/format/test EXECUTION rows (environment binaries).
Run: python3 scripts/check_tool_executor_lifecycle_parity.py
Exit 0 only at 100%.
"""
import copy
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "src"))
from rune import tool_executor as te # noqa: E402
W20 = "".join(f"line {i}\n" for i in range(1, 21))
STUB_NONE_QUERY = "run the whole test suite"
STUB_TWO_QUERY = "commit my changes"
STUB_MATCHES = [
{
"name": "git_commit",
"description": "Commit staged changes",
"risk": "irreversible",
"parameters": {"properties": {"message": {"type": "string"}, "files": {"type": "array"}}},
},
{"name": "snapshot_tree", "description": "Snapshot a directory tree",
"parameters": {"properties": {}}},
]
FIXTURE_FILES = {
"{{TMP}}/w.txt": W20,
"{{TMP}}/a.txt": "the spine rebuild parity\n",
"{{WROOT}}/exists.txt": "already here\n",
}
def summarize_diff(diff):
lines = diff.split("\n")
return {
"head": lines[:2],
"plus": sum(1 for l in lines if l.startswith("+") and not l.startswith("+++")),
"minus": sum(1 for l in lines if l.startswith("-") and not l.startswith("---")),
}
def norm_envelope(env):
out = copy.deepcopy(env)
if "diff" in out:
out["diff"] = summarize_diff(out["diff"])
return out
def norm_prepare(env):
out = norm_envelope(env)
if isinstance(out.get("pending"), te.PreparedTool):
ser = te.serialize_prepared_tool(out["pending"])
ser["token"] = "{{TOKEN}}"
ser["operation_digest"] = "{{DIGEST}}"
out["pending"] = ser
return out
def build_cases():
cases = []
def add(op, name, **kw):
cases.append({"op": op, "name": name, **kw})
def exec_row(name, tool, args):
cases.append({"op": "exec_now", "name": name, "tool": tool, "args": args})
def exec_tool_row(name, tool, args, now=None, ttl=None):
kw = {}
if now is not None:
kw["now"] = now
if ttl is not None:
kw["ttl"] = ttl
cases.append({"op": "execute_tool", "name": name, "tool": tool,
"args": args, **kw})
# _execute_tool_now
exec_row("read_window", "read_file", {"path": "{{TMP}}/w.txt", "offset": 2, "limit": 3})
exec_row("read_default", "read_file", {"path": "{{TMP}}/a.txt"})
exec_row("read_missing", "read_file", {"path": "{{TMP}}/missing.txt"})
exec_row("read_blocked", "read_file", {"path": "/etc/hostname"})
exec_row("read_offset_none", "read_file", {"path": "{{TMP}}/w.txt", "offset": None})
exec_row("read_limit_str", "read_file", {"path": "{{TMP}}/w.txt", "limit": "x"})
exec_row("read_offset_frac", "read_file", {"path": "{{TMP}}/w.txt", "offset": 1.5})
exec_row("read_offset_zero", "read_file", {"path": "{{TMP}}/w.txt", "offset": 0, "limit": 2})
exec_row("unknown", "no_such_tool", {"x": 1})
exec_row("list_hit", "list_directory", {"path": "{{TMP}}"})
exec_row("list_pattern", "list_directory", {"path": "{{TMP}}", "pattern": "w*"})
exec_row("list_not_dir", "list_directory", {"path": "{{TMP}}/nodir"})
exec_row("grep_no_pattern", "grep_files", {"path": "{{TMP}}"})
exec_row("grep_no_match", "grep_files", {"pattern": "zzz_never", "path": "{{TMP}}"})
exec_row("grep_denied", "grep_files", {"pattern": "x", "path": "/etc/hostname"})
exec_row("write_preview", "write_file",
{"path": "{{WROOT}}/exec-new.txt", "content": "alpha\nbeta\n"})
exec_row("write_no_change", "write_file",
{"path": "{{WROOT}}/exists.txt", "content": "already here\n"})
exec_row("write_denied_outside", "write_file", {"path": "/usr/share/x.txt", "content": "x"})
exec_row("write_denied_protected", "write_file", {"path": "/etc/passwd", "content": "x"})
exec_row("git_commit_no_message", "git_commit", {})
exec_row("run_tests_no_runner", "run_tests", {"path": "{{TMP}}/a.txt"})
exec_row("lint_missing", "lint_file", {"path": "{{TMP}}/missing.py"})
exec_row("format_denied", "format_code", {"path": "/etc/f.py"})
# serialize / deserialize
full = te.serialize_prepared_tool(te.PreparedTool(
token="ser-full-token", name="write_file",
arguments={"path": "/tmp/x.txt", "content": "abc"},
operation_digest="d" * 64, created_at=10.0, expires_at=310.0,
requested_path="/tmp/x.txt", resolved_path="/tmp/x.txt", path_digest="p" * 64,
))
minimal = te.serialize_prepared_tool(te.PreparedTool(
token="ser-min-token", name="run_command", arguments={"command": "ls"},
operation_digest="e" * 64, created_at=0.0, expires_at=5.0,
))
add("serialize", "full", pending=full)
add("serialize", "minimal", pending=minimal)
def deser_row(name, value):
add("deserialize", name, value=value)
deser_row("ok_full", full)
deser_row("ok_minimal", minimal)
deser_row("not_object_str", "nope")
deser_row("not_object_null", None)
deser_row("extra_field", dict(minimal, extra=1))
deser_row("token_empty", dict(minimal, token=""))
deser_row("token_number", dict(minimal, token=5))
deser_row("arguments_list", dict(minimal, arguments=[]))
deser_row("created_bool", dict(minimal, created_at=True))
deser_row("expires_null", dict(minimal, expires_at=None))
# confirm_write
add("confirm_write", "new_file", path="{{WROOT}}/confirmed-new.txt", content="abc")
add("confirm_write", "protected", path="/etc/x.txt", content="x")
add("confirm_write", "outside", path="/usr/share/x.txt", content="x")
# execute_tool / prepare_tool
exec_tool_row("readonly_passthrough", "read_file", {"path": "{{TMP}}/a.txt"})
exec_tool_row("write_prepare", "write_file",
{"path": "{{WROOT}}/exec-prep.txt", "content": "one\ntwo\nthree\n"}, now=1000.0)
exec_tool_row("format_prepare", "format_code", {"path": "{{WROOT}}/fmt-prep.go"}, now=2000.0)
exec_tool_row("write_ttl", "write_file",
{"path": "{{WROOT}}/prep-ttl.txt", "content": "x"}, now=5.0, ttl=60.0)
exec_tool_row("write_denied", "write_file", {"path": "/usr/share/x.txt", "content": "x"})
exec_tool_row("edit_no_path", "edit_file", {"search": "a", "replace": "b"})
exec_tool_row("args_null", "write_file", None)
# find_capability (frozen stub)
add("find_cap", "query_missing", args={}, matches=[])
add("find_cap", "query_blank", args={"query": " "}, matches=[])
add("find_cap", "no_matches", args={"query": STUB_NONE_QUERY}, matches=[])
add("find_cap", "two_matches", args={"query": STUB_TWO_QUERY},
matches=copy.deepcopy(STUB_MATCHES))
return cases
def subst(obj, tmp_root, w_root):
if isinstance(obj, str):
return obj.replace("{{TMP}}", tmp_root).replace("{{WROOT}}", w_root)
if isinstance(obj, list):
return [subst(x, tmp_root, w_root) for x in obj]
if isinstance(obj, dict):
return {k: subst(v, tmp_root, w_root) for k, v in obj.items()}
return obj
def run_oracle(cases, tmp_root, w_root):
out = {}
for c in cases:
c = subst(c, tmp_root, w_root)
op, name = c["op"], c["name"]
if op == "exec_now":
r = te._execute_tool_now(c["tool"], copy.deepcopy(c["args"]))
out[(op, name)] = norm_envelope(r)
elif op == "serialize":
p = te.PreparedTool(
token=c["pending"]["token"], name=c["pending"]["name"],
arguments=c["pending"]["arguments"],
operation_digest=c["pending"]["operation_digest"],
created_at=c["pending"]["created_at"],
expires_at=c["pending"]["expires_at"],
requested_path=c["pending"].get("requested_path"),
resolved_path=c["pending"].get("resolved_path"),
path_digest=c["pending"].get("path_digest"),
)
out[(op, name)] = te.serialize_prepared_tool(p)
elif op == "deserialize":
try:
p = te.deserialize_prepared_tool(copy.deepcopy(c["value"]))
out[(op, name)] = te.serialize_prepared_tool(p)
except ValueError as exc:
out[(op, name)] = {"raises": str(exc)}
elif op == "confirm_write":
out[(op, name)] = norm_envelope(te.confirm_write(c["path"], c["content"]))
elif op == "execute_tool":
kwargs = {}
if "now" in c:
kwargs["now"] = c["now"]
if "ttl" in c:
kwargs["ttl"] = c["ttl"]
if kwargs:
r = te.prepare_tool(c["tool"], copy.deepcopy(c["args"]), **kwargs)
else:
r = te.execute_tool(c["tool"], copy.deepcopy(c["args"]))
out[(op, name)] = norm_prepare(r)
elif op == "find_cap":
import rune.capability_broker as broker
original = broker.find_capability
broker.find_capability = lambda q: copy.deepcopy(c["matches"])
try:
r = te._find_capability(copy.deepcopy(c["args"]))
finally:
broker.find_capability = original
out[(op, name)] = norm_envelope(r)
return out
def main():
cases = build_cases()
roots = {}
for side in ("py", "go"):
tmp_root = tempfile.mkdtemp(prefix=f"rune-life-parity-{side}-", dir="/tmp")
w_root = tempfile.mkdtemp(prefix=f"rune-life-parity-{side}w-",
dir=os.path.expanduser("~"))
for rel, content in FIXTURE_FILES.items():
p = Path(subst(rel, tmp_root, w_root))
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
roots[side] = (tmp_root, w_root)
py_out = run_oracle(cases, *roots["py"])
workdir = Path(tempfile.mkdtemp(prefix="rune-life-driver-"))
cases_file = workdir / "cases.json"
out_file = workdir / "go_out.json"
cases_file.write_text(json.dumps(subst(cases, *roots["go"])))
env = dict(os.environ,
LIFECYCLE_CASES=str(cases_file),
LIFECYCLE_OUT=str(out_file))
r = subprocess.run(
["go", "test", "-run", "TestLifecycleParityDriver", "./..."],
cwd=REPO / "src" / "go" / "tool-executor",
capture_output=True, text=True, env=env, timeout=300,
)
if r.returncode != 0:
print(r.stdout[-3000:])
print(r.stderr[-3000:])
sys.exit("Go parity driver failed")
go_rows = json.loads(out_file.read_text())
go_out = {(row["op"], row["name"]): row["out"] for row in go_rows}
def anonymize(obj, tmp_root, w_root):
if isinstance(obj, str):
return obj.replace(tmp_root, "{{TMP}}").replace(w_root, "{{WROOT}}")
if isinstance(obj, list):
return [anonymize(x, tmp_root, w_root) for x in obj]
if isinstance(obj, dict):
return {k: anonymize(v, tmp_root, w_root) for k, v in obj.items()}
return obj
def canon(v):
# int→float pre-pass: Go json renders 10.0 as 10; Python as 10.0 —
# numerically identical envelopes must compare equal.
def lift(x):
if isinstance(x, bool) or isinstance(x, int):
if isinstance(x, bool):
return x
return float(x)
if isinstance(x, list):
return [lift(i) for i in x]
if isinstance(x, dict):
return {k: lift(i) for k, i in x.items()}
return x
return json.dumps(lift(v), sort_keys=True, default=str)
fails = 0
checked = 0
for c in cases:
key = (c["op"], c["name"])
want = anonymize(py_out[key], *roots["py"])
got = anonymize(go_out[key], *roots["go"])
checked += 1
if canon(want) != canon(got):
fails += 1
print(f"FAIL {key}:\n want {canon(want)[:500]}\n got {canon(got)[:500]}")
print(f"lifecycle live parity: {checked - fails}/{checked}")
sys.exit(1 if fails else 0)
if __name__ == "__main__":
main()