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).
This commit is contained in:
1 parent
f050ba8aca
commit
07c08c341e
8 files changed
+2757
-9
No files matched your search
@@ -0,0 +1,324 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,314 @@
|
||||
#!/usr/bin/env python3
|
||||
"""gen_tool_executor_lifecycle_truth.py — freeze the PREPARE/APPROVE/
|
||||
SERIALIZE family gap truth for tool-executor (dichotomy wave #2479,
|
||||
contract factory-heavy-te-life).
|
||||
|
||||
Covers the six audit-missing lifecycle functions against the LIVE oracle
|
||||
(src/rune/tool_executor.py):
|
||||
|
||||
_execute_tool_now / serialize_prepared_tool /
|
||||
deserialize_prepared_tool / execute_tool (== prepare_tool kernel) /
|
||||
confirm_write / _find_capability
|
||||
|
||||
Hermetic laws (gen_tool_executor_truth.py precedent):
|
||||
- fixtures live under roots the oracle itself allows; every occurrence of
|
||||
those roots — inputs AND expectations — becomes {{TMP}} / {{WROOT}};
|
||||
- the runner home becomes {{HOME}} (the write-denial message embeds
|
||||
str(ALLOWED_WRITE_DIRS));
|
||||
- uuid tokens become {{TOKEN}} (process-random; the battery normalizes
|
||||
the Go side the same way);
|
||||
- `now` is always an explicit argument (no-clock law);
|
||||
- the broker is a frozen stub for _find_capability rows (the seam
|
||||
contract: the battery rebuilds the same matches from the row JSON,
|
||||
property ORDER included);
|
||||
- write previews summarize the diff (head 2 lines + plus/minus counts) —
|
||||
the diff body is the module's pre-existing kernel surface
|
||||
(truth_fileops), not this family's contract.
|
||||
|
||||
Deliberately EXCLUDED rows (documented divergences, not bugs — see
|
||||
verification/te-life-summary.md): dispatch arms whose handlers are
|
||||
sibling-family gaps (edit_file/glob_files/repo_map/run_command/
|
||||
read_persona/query_memory/check_growth/diagnose_behavior return a
|
||||
notPorted envelope in Go), non-string optional fields through
|
||||
deserialize (the unvalidated-Python-dataclass class), git/lint/format
|
||||
EXECUTION rows (environment-dependent binaries).
|
||||
|
||||
Byte-identical regeneration: no timestamps embedded; re-run and diff.
|
||||
|
||||
Usage: python3 scripts/gen_tool_executor_lifecycle_truth.py [--out PATH]
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
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 — the live oracle
|
||||
|
||||
|
||||
def mk_root(dirs):
|
||||
for d in dirs:
|
||||
try:
|
||||
return tempfile.mkdtemp(prefix="rune-life-", dir=d)
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError(f"no creatable root among {dirs}")
|
||||
|
||||
|
||||
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": {}},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def stub_broker(query):
|
||||
# Deterministic frozen stub — the Go seam replays these from the row.
|
||||
if query == STUB_TWO_QUERY:
|
||||
return copy.deepcopy(STUB_MATCHES)
|
||||
return []
|
||||
|
||||
|
||||
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):
|
||||
"""Normalize an oracle envelope for truth: diff summary + no pending obj."""
|
||||
out = copy.deepcopy(env)
|
||||
if "diff" in out:
|
||||
out["diff"] = summarize_diff(out["diff"])
|
||||
return out
|
||||
|
||||
|
||||
def norm_prepare(env):
|
||||
"""Normalize a prepare envelope: pending → serialized + {{TOKEN}}.
|
||||
|
||||
operation_digest becomes {{DIGEST}}: the digest binds the REAL root
|
||||
path, which is per-generation random — the battery re-derives it
|
||||
structurally (CanonicalDigest(tool, substituted-args) must equal the
|
||||
pending's digest on BOTH sides; that invariant is the contract).
|
||||
"""
|
||||
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 main():
|
||||
tmp = mk_root(te.ALLOWED_READ_DIRS)
|
||||
wroot = mk_root(te.ALLOWED_WRITE_DIRS)
|
||||
home = str(Path.home())
|
||||
|
||||
(Path(tmp) / "w.txt").write_text(W20, encoding="utf-8")
|
||||
(Path(tmp) / "a.txt").write_text("the spine rebuild parity\n", encoding="utf-8")
|
||||
(Path(tmp) / "adir").mkdir()
|
||||
|
||||
fixtures = {
|
||||
"files": [
|
||||
{"path": "{{TMP}}/w.txt", "content": W20},
|
||||
{"path": "{{TMP}}/a.txt", "content": "the spine rebuild parity\n"},
|
||||
],
|
||||
"dirs": ["{{TMP}}/adir"],
|
||||
}
|
||||
|
||||
rows = []
|
||||
|
||||
def add(op, name, inp, expected=None, raises=None):
|
||||
row = {"op": op, "name": name, **inp}
|
||||
if expected is not None:
|
||||
row["want"] = expected
|
||||
if raises is not None:
|
||||
row["raises"] = raises
|
||||
rows.append(row)
|
||||
|
||||
# ── _execute_tool_now ────────────────────────────────────────────────
|
||||
def exec_row(name, tool, args, **kw):
|
||||
r = te._execute_tool_now(tool, copy.deepcopy(args))
|
||||
add("exec_now", name, {"tool": tool, "args": args}, norm_envelope(r), **kw)
|
||||
|
||||
exec_row("read_window", "read_file", {"path": f"{tmp}/w.txt", "offset": 2, "limit": 3})
|
||||
exec_row("read_default", "read_file", {"path": f"{tmp}/a.txt"})
|
||||
exec_row("read_missing", "read_file", {"path": f"{tmp}/missing.txt"})
|
||||
exec_row("read_blocked", "read_file", {"path": "/etc/hostname"})
|
||||
exec_row("read_offset_none", "read_file", {"path": f"{tmp}/w.txt", "offset": None})
|
||||
exec_row("read_limit_str", "read_file", {"path": f"{tmp}/w.txt", "limit": "x"})
|
||||
exec_row("read_offset_frac", "read_file", {"path": f"{tmp}/w.txt", "offset": 1.5})
|
||||
# offset=True (bool is an int subclass) renders as the literal "True"
|
||||
# inside the footer f-string — excluded, documented divergence.
|
||||
exec_row("unknown", "no_such_tool", {"x": 1})
|
||||
exec_row("list_hit", "list_directory", {"path": tmp})
|
||||
exec_row("list_not_dir", "list_directory", {"path": f"{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("write_preview", "write_file",
|
||||
{"path": f"{wroot}/exec-new.txt", "content": "alpha\nbeta\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": f"{tmp}/a.txt"})
|
||||
exec_row("lint_missing", "lint_file", {"path": f"{tmp}/missing.py"})
|
||||
exec_row("format_denied", "format_code", {"path": "/etc/f.py"})
|
||||
|
||||
# ── serialize_prepared_tool ──────────────────────────────────────────
|
||||
full = 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.PreparedTool(
|
||||
token="ser-min-token", name="run_command",
|
||||
arguments={"command": "ls"}, operation_digest="e" * 64,
|
||||
created_at=0.0, expires_at=5.0,
|
||||
)
|
||||
ser_full = te.serialize_prepared_tool(full)
|
||||
ser_min = te.serialize_prepared_tool(minimal)
|
||||
add("serialize", "full", {"pending": ser_full}, ser_full)
|
||||
add("serialize", "minimal", {"pending": ser_min}, ser_min)
|
||||
|
||||
# ── deserialize_prepared_tool ────────────────────────────────────────
|
||||
def deser_row(name, value):
|
||||
try:
|
||||
p = te.deserialize_prepared_tool(copy.deepcopy(value))
|
||||
add("deserialize", name, {"value": value}, te.serialize_prepared_tool(p))
|
||||
except ValueError as exc:
|
||||
add("deserialize", name, {"value": value}, raises=str(exc))
|
||||
|
||||
ok_full = te.serialize_prepared_tool(full)
|
||||
ok_min = te.serialize_prepared_tool(minimal)
|
||||
ok_int_ts = dict(ok_min, created_at=100, expires_at=400)
|
||||
deser_row("ok_full", ok_full)
|
||||
deser_row("ok_minimal", ok_min)
|
||||
deser_row("ok_int_timestamps", ok_int_ts)
|
||||
deser_row("not_object_str", "nope")
|
||||
deser_row("not_object_null", None)
|
||||
deser_row("extra_field", dict(ok_min, extra=1))
|
||||
deser_row("missing_field", {k: v for k, v in ok_min.items() if k != "path_digest"})
|
||||
deser_row("token_empty", dict(ok_min, token=""))
|
||||
deser_row("token_number", dict(ok_min, token=5))
|
||||
deser_row("name_empty", dict(ok_min, name=""))
|
||||
deser_row("arguments_list", dict(ok_min, arguments=[]))
|
||||
deser_row("digest_number", dict(ok_min, operation_digest=3.5))
|
||||
deser_row("created_bool", dict(ok_min, created_at=True))
|
||||
deser_row("created_str", dict(ok_min, created_at="1"))
|
||||
deser_row("expires_null", dict(ok_min, expires_at=None))
|
||||
|
||||
# ── confirm_write ────────────────────────────────────────────────────
|
||||
def confirm_row(name, path, content):
|
||||
r = te.confirm_write(path, content)
|
||||
add("confirm_write", name, {"path": path, "content": content}, norm_envelope(r))
|
||||
|
||||
confirm_row("new_file", f"{wroot}/confirmed-new.txt", "abc")
|
||||
confirm_row("protected", "/etc/x.txt", "x")
|
||||
confirm_row("outside", "/usr/share/x.txt", "x")
|
||||
# Deterministic read-only-root denial: the {{TMP}} mkdtemp can land
|
||||
# under $HOME (write-allowed there), so pin an explicit /tmp path —
|
||||
# /tmp is read-allowed but never write-allowed, on every surface.
|
||||
confirm_row("readonly_root", "/tmp/te-life-readonly-confirm/nope.txt", "x")
|
||||
|
||||
# ── execute_tool / prepare_tool ─────────────────────────────────────
|
||||
def exec_tool_row(name, tool, args, now=None, ttl=None):
|
||||
kwargs = {}
|
||||
if now is not None:
|
||||
kwargs["now"] = now
|
||||
if ttl is not None:
|
||||
kwargs["ttl"] = ttl
|
||||
if kwargs:
|
||||
r = te.prepare_tool(tool, copy.deepcopy(args), **kwargs)
|
||||
else:
|
||||
r = te.execute_tool(tool, copy.deepcopy(args))
|
||||
add("execute_tool", name, {"tool": tool, "args": args, **kwargs}, norm_prepare(r))
|
||||
|
||||
exec_tool_row("readonly_passthrough", "read_file", {"path": f"{tmp}/a.txt"})
|
||||
exec_tool_row("write_prepare", "write_file",
|
||||
{"path": f"{wroot}/exec-prep.txt", "content": "one\ntwo\nthree\n"}, now=1000.0)
|
||||
exec_tool_row("format_prepare", "format_code", {"path": f"{wroot}/fmt-prep.go"}, now=2000.0)
|
||||
exec_tool_row("write_ttl", "write_file",
|
||||
{"path": f"{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)
|
||||
# /tmp is read-allowed but NOT write-allowed on every surface — the
|
||||
# {{TMP}} mkdtemp can land under $HOME (write-allowed), so the denial
|
||||
# row pins an explicit /tmp path instead of the fixture root.
|
||||
exec_tool_row("format_denied_readonly_root", "format_code",
|
||||
{"path": "/tmp/te-life-readonly-denial/x.go"}, now=3000.0)
|
||||
|
||||
# ── _find_capability (frozen broker stub) ───────────────────────────
|
||||
def find_cap_row(name, args, matches):
|
||||
import rune.capability_broker as broker
|
||||
original = broker.find_capability
|
||||
broker.find_capability = stub_broker
|
||||
try:
|
||||
r = te._find_capability(copy.deepcopy(args))
|
||||
finally:
|
||||
broker.find_capability = original
|
||||
add("find_cap", name,
|
||||
{"args": args, "matches": matches}, norm_envelope(r))
|
||||
|
||||
find_cap_row("query_missing", {}, [])
|
||||
find_cap_row("query_blank", {"query": " "}, [])
|
||||
find_cap_row("no_matches", {"query": STUB_NONE_QUERY}, [])
|
||||
find_cap_row("two_matches", {"query": STUB_TWO_QUERY}, copy.deepcopy(STUB_MATCHES))
|
||||
|
||||
doc = {
|
||||
"_doc": (
|
||||
"FROZEN ORACLE (tool_executor lifecycle family — "
|
||||
"factory-heavy-te-life). Placeholders: {{TMP}} read root, "
|
||||
"{{WROOT}} write root, {{HOME}} runner home, {{TOKEN}} uuid. "
|
||||
"Broker stub matches ride the find_cap rows verbatim "
|
||||
"(property ORDER is contract). Regenerate: "
|
||||
"scripts/gen_tool_executor_lifecycle_truth.py"
|
||||
),
|
||||
"fixtures": fixtures,
|
||||
"stub_queries": {"none": STUB_NONE_QUERY, "two": STUB_TWO_QUERY},
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
def subst(obj):
|
||||
if isinstance(obj, str):
|
||||
# TMP/WROOT first — the roots may live UNDER home.
|
||||
s = obj.replace(tmp, "{{TMP}}").replace(wroot, "{{WROOT}}")
|
||||
s = s.replace(home, "{{HOME}}")
|
||||
return s
|
||||
if isinstance(obj, list):
|
||||
return [subst(x) for x in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {k: subst(v) for k, v in obj.items()}
|
||||
return obj
|
||||
|
||||
doc = subst(doc)
|
||||
|
||||
out = Path(sys.argv[sys.argv.index("--out") + 1]) if "--out" in sys.argv else (
|
||||
REPO / "src" / "go" / "tool-executor" / "truth_lifecycle.json"
|
||||
)
|
||||
out.write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
print(f"wrote {out} ({len(rows)} rows)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -16,15 +16,15 @@ import (
|
||||
)
|
||||
|
||||
// ReadFile — windowed viewing: offset (1-indexed), limit (default 100),
|
||||
// numbered lines, truncation context on both ends.
|
||||
func ReadFile(path string, offset, limit int) (bool, string) {
|
||||
// numbered lines, truncation context on both ends. PARITY fix
|
||||
// (factory-heavy-te-life): Python numbers with the RAW offset
|
||||
// (offset=0 renders "0: line 1", footer "[showing lines 0-2 ...]") — the
|
||||
// old offset clamp rewrote both. start still clamps via max(0, offset-1).
|
||||
func ReadFile(path string, offsetRaw, limit int) (bool, string) {
|
||||
ok, msg, resolved := ValidateReadPath(path)
|
||||
if !ok || resolved == "" {
|
||||
return false, msg
|
||||
}
|
||||
if offset < 1 {
|
||||
offset = 1
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 100
|
||||
}
|
||||
@@ -33,7 +33,10 @@ func ReadFile(path string, offset, limit int) (bool, string) {
|
||||
return false, fmt.Sprintf("Read failed: %v", err)
|
||||
}
|
||||
lines := strings.Split(string(content), "\n")
|
||||
start := offset - 1
|
||||
start := offsetRaw - 1
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := len(lines)
|
||||
if start+limit < end {
|
||||
end = start + limit
|
||||
@@ -44,14 +47,14 @@ func ReadFile(path string, offset, limit int) (bool, string) {
|
||||
windowed := lines[start:end]
|
||||
numbered := make([]string, len(windowed))
|
||||
for i, line := range windowed {
|
||||
numbered[i] = fmt.Sprintf("%d: %s", i+offset, line)
|
||||
numbered[i] = fmt.Sprintf("%d: %s", i+offsetRaw, line)
|
||||
}
|
||||
result := strings.Join(numbered, "\n")
|
||||
if end < len(lines) {
|
||||
result += fmt.Sprintf("\n\n[showing lines %d-%d of %d total]", offset, end, len(lines))
|
||||
result += fmt.Sprintf("\n\n[showing lines %d-%d of %d total]", offsetRaw, end, len(lines))
|
||||
}
|
||||
if start > 0 {
|
||||
result = fmt.Sprintf("[showing from line %d]\n", offset) + result
|
||||
result = fmt.Sprintf("[showing from line %d]\n", offsetRaw) + result
|
||||
}
|
||||
return true, result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
package main
|
||||
|
||||
// gaps_lifecycle.go — PREPARE/APPROVE/SERIALIZE family gap-closer
|
||||
// (dichotomy wave #2479, contract factory-heavy-te-life). Name-preserving
|
||||
// ports of the audit-missing lifecycle functions:
|
||||
//
|
||||
// _execute_tool_now → ExecuteToolNow (the authority-permitted dispatch)
|
||||
// serialize_prepared_tool → SerializePreparedTool
|
||||
// deserialize_prepared_tool→ DeserializePreparedTool
|
||||
// execute_tool → ExecuteTool (reads execute; rest prepared)
|
||||
// (also carries the prepare_tool kernel body)
|
||||
// confirm_write → ConfirmWrite
|
||||
// _find_capability → FindCapability (broker via injectable seam)
|
||||
//
|
||||
// Oracle: src/rune/tool_executor.py. Truth: truth_lifecycle.json (generated
|
||||
// by scripts/gen_tool_executor_lifecycle_truth.py against the LIVE oracle);
|
||||
// battery: truth_lifecycle_test.go; live parity:
|
||||
// scripts/check_tool_executor_lifecycle_parity.py.
|
||||
//
|
||||
// KNOWN DIVERGENCES (documented, oracle stays contract — see
|
||||
// verification/te-life-summary.md):
|
||||
// - dispatch arms whose handlers are sibling-family gaps (edit_file,
|
||||
// glob_files, repo_map, run_command, read_persona, query_memory,
|
||||
// check_growth, diagnose_behavior) return a notPorted envelope until
|
||||
// their kernels land; the oracle returns real handler output there.
|
||||
// - deserialize's constructor-TypeError branch is unreachable in Go
|
||||
// (typed struct construction cannot fail after field validation).
|
||||
// - non-string requested_path/resolved_path/path_digest values pass
|
||||
// through the UNVALIDATED Python dataclass; the Go record types them
|
||||
// as strings and rejects at the parse boundary instead.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// processStart — monotonic-clock origin (time.monotonic() has an arbitrary
|
||||
// origin too; only differences are meaningful).
|
||||
var processStart = time.Now()
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// _execute_tool_now — the dispatch after the authority boundary permits it
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ExecuteToolNow — PARITY with _execute_tool_now: route a tool name to its
|
||||
// handler; unknown names get the exact refusal; argument-unpacking crashes
|
||||
// reproduce the oracle's caught TypeError envelopes.
|
||||
func ExecuteToolNow(toolName string, args map[string]interface{}) map[string]interface{} {
|
||||
switch toolName {
|
||||
case "read_file":
|
||||
return execReadFile(args)
|
||||
case "edit_file":
|
||||
return notPortedEnvelope("edit_file")
|
||||
case "write_file":
|
||||
return execWriteFile(args)
|
||||
case "glob_files":
|
||||
return notPortedEnvelope("glob_files")
|
||||
case "grep_files":
|
||||
return execGrepFiles(args)
|
||||
case "list_directory":
|
||||
return execListDirectory(args)
|
||||
case "lint_file":
|
||||
return execLintFile(args)
|
||||
case "run_tests":
|
||||
return execRunTests(args)
|
||||
case "format_code":
|
||||
return execFormatCode(args)
|
||||
case "git_diff":
|
||||
return execGitDiff(args)
|
||||
case "git_log":
|
||||
return execGitLog(args)
|
||||
case "git_commit":
|
||||
return execGitCommit(args)
|
||||
case "repo_map":
|
||||
return notPortedEnvelope("repo_map")
|
||||
case "run_command":
|
||||
return notPortedEnvelope("run_command")
|
||||
case "read_persona":
|
||||
return notPortedEnvelope("read_persona")
|
||||
case "query_memory":
|
||||
return notPortedEnvelope("query_memory")
|
||||
case "check_growth":
|
||||
return notPortedEnvelope("check_growth")
|
||||
case "diagnose_behavior":
|
||||
return notPortedEnvelope("diagnose_behavior")
|
||||
case "find_capability":
|
||||
return FindCapability(args)
|
||||
default:
|
||||
return map[string]interface{}{"success": false, "error": "Unknown tool: " + toolName}
|
||||
}
|
||||
}
|
||||
|
||||
// notPortedEnvelope — the strangler-fig placeholder for sibling-family
|
||||
// handler gaps. Deliberately NOT the oracle's "Unknown tool" text: these
|
||||
// tools exist; their kernels are in flight on other family lanes.
|
||||
func notPortedEnvelope(name string) map[string]interface{} {
|
||||
return map[string]interface{}{"success": false, "error": "kernel not ported: " + name}
|
||||
}
|
||||
|
||||
// envelopeOK — the shared handler result shape.
|
||||
func envelopeOK(ok bool, s string) map[string]interface{} {
|
||||
if ok {
|
||||
return map[string]interface{}{"success": true, "result": s}
|
||||
}
|
||||
return map[string]interface{}{"success": false, "error": s}
|
||||
}
|
||||
|
||||
// argStr — args.get(key, def) with Python str coercion rules: absent → def.
|
||||
func argStr(args map[string]interface{}, key, def string) string {
|
||||
if v, has := args[key]; has {
|
||||
if s, isStr := v.(string); isStr {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// argInt — args.get(key, def) for the int operands. Python crashes inside
|
||||
// the handler on non-numeric operands; str(e) STRIPS the exception type
|
||||
// (quirk law), so the messages below carry NO "TypeError:" prefix — the
|
||||
// handlers' own "Read failed: {e}" arms add their prefix verbatim.
|
||||
// argLeft picks the operand order: `offset - 1` puts the argument on the
|
||||
// LEFT, `start + limit` puts it on the RIGHT. JSON ints arrive as
|
||||
// float64 (integral) — those pass; FRACTIONAL floats raise at the slice
|
||||
// (lines[start:end] demands __index__). Known divergence: bool offsets
|
||||
// (Python int subclass) render as "True" inside the footer f-string.
|
||||
func argInt(args map[string]interface{}, key string, def int, op string, argLeft bool) (int, *string) {
|
||||
v, has := args[key]
|
||||
if !has {
|
||||
return def, nil
|
||||
}
|
||||
// PRESENT-but-null is not the default: Python's args.get returns None
|
||||
// and the handler crashes on it.
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
if t {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
case int:
|
||||
return t, nil
|
||||
case int64:
|
||||
return int(t), nil
|
||||
case float64:
|
||||
if t == math.Trunc(t) {
|
||||
return int(t), nil
|
||||
}
|
||||
msg := "slice indices must be integers or None or have an __index__ method"
|
||||
return 0, &msg
|
||||
default:
|
||||
var msg string
|
||||
if argLeft {
|
||||
msg = fmt.Sprintf("unsupported operand type(s) for %s: '%s' and 'int'", op, pyTypeName(v))
|
||||
} else {
|
||||
msg = fmt.Sprintf("unsupported operand type(s) for %s: 'int' and '%s'", op, pyTypeName(v))
|
||||
}
|
||||
return 0, &msg
|
||||
}
|
||||
}
|
||||
|
||||
// pyTypeName — the names that appear in the frozen TypeError messages.
|
||||
func pyTypeName(v interface{}) string {
|
||||
switch v.(type) {
|
||||
case string:
|
||||
return "str"
|
||||
case map[string]interface{}:
|
||||
return "dict"
|
||||
case []interface{}:
|
||||
return "list"
|
||||
case bool:
|
||||
return "bool"
|
||||
case float64:
|
||||
return "float"
|
||||
default:
|
||||
return "NoneType"
|
||||
}
|
||||
}
|
||||
|
||||
// execReadFile — _read_file arg surface (path default "", offset 1,
|
||||
// limit 100) over the ReadFile kernel. Operand TypeErrors fire INSIDE
|
||||
// _read_file's own try, so they surface as "Read failed: TypeError: ..."
|
||||
// (not the bare caught-by-dispatch envelope).
|
||||
func execReadFile(args map[string]interface{}) map[string]interface{} {
|
||||
offset, crash := argInt(args, "offset", 1, "-", true)
|
||||
if crash != nil {
|
||||
return map[string]interface{}{"success": false, "error": "Read failed: " + *crash}
|
||||
}
|
||||
limit, crash := argInt(args, "limit", 100, "+", false)
|
||||
if crash != nil {
|
||||
return map[string]interface{}{"success": false, "error": "Read failed: " + *crash}
|
||||
}
|
||||
ok, result := ReadFile(argStr(args, "path", ""), offset, limit)
|
||||
return envelopeOK(ok, result)
|
||||
}
|
||||
|
||||
// execWriteFile — _write_file arg surface over the WriteFile kernel
|
||||
// (diff-for-confirmation preview; no write happens).
|
||||
func execWriteFile(args map[string]interface{}) map[string]interface{} {
|
||||
return WriteFile(argStr(args, "path", ""), argStr(args, "content", ""))
|
||||
}
|
||||
|
||||
// execGrepFiles — _grep_files arg surface: the pattern-required and
|
||||
// access-denied verdicts are byte-parity; the hit formatting (grep -r -n -E
|
||||
// abs:line) is delegated to the GrepFiles kernel — see the divergence note
|
||||
// in verification/te-life-summary.md (kernel is the _search_files shape).
|
||||
func execGrepFiles(args map[string]interface{}) map[string]interface{} {
|
||||
pattern := argStr(args, "pattern", "")
|
||||
if pattern == "" {
|
||||
return map[string]interface{}{"success": false, "error": "Pattern is required"}
|
||||
}
|
||||
path := argStr(args, "path", ".")
|
||||
resolved := ResolvePath(path)
|
||||
if !IsPathAllowed(resolved, AllowedReadDirs) {
|
||||
return map[string]interface{}{"success": false, "error": fmt.Sprintf("Access denied: %s", path)}
|
||||
}
|
||||
ok, result := GrepFiles(path, pattern, argStr(args, "include", ""))
|
||||
return envelopeOK(ok, result)
|
||||
}
|
||||
|
||||
// execListDirectory — _list_directory arg surface (path default ".").
|
||||
func execListDirectory(args map[string]interface{}) map[string]interface{} {
|
||||
ok, result := ListDirectory(argStr(args, "path", "."), argStr(args, "pattern", ""))
|
||||
return envelopeOK(ok, result)
|
||||
}
|
||||
|
||||
// execLintFile — _lint_file arg surface (language override optional).
|
||||
func execLintFile(args map[string]interface{}) map[string]interface{} {
|
||||
ok, result := LintFile(argStr(args, "path", ""), argStr(args, "language", ""))
|
||||
return envelopeOK(ok, result)
|
||||
}
|
||||
|
||||
// execRunTests — _run_tests arg surface (explicit command optional).
|
||||
func execRunTests(args map[string]interface{}) map[string]interface{} {
|
||||
ok, result := RunTests(argStr(args, "path", "."), argStr(args, "command", ""))
|
||||
return envelopeOK(ok, result)
|
||||
}
|
||||
|
||||
// execFormatCode — _format_code arg surface (writes via formatter).
|
||||
func execFormatCode(args map[string]interface{}) map[string]interface{} {
|
||||
ok, result := FormatCode(argStr(args, "path", ""), argStr(args, "language", ""))
|
||||
return envelopeOK(ok, result)
|
||||
}
|
||||
|
||||
// execGitDiff — _git_diff arg surface (staged default False, path optional).
|
||||
func execGitDiff(args map[string]interface{}) map[string]interface{} {
|
||||
staged := false
|
||||
if b, has := args["staged"].(bool); has {
|
||||
staged = b
|
||||
}
|
||||
ok, result := GitDiff(staged, argStr(args, "path", ""))
|
||||
return envelopeOK(ok, result)
|
||||
}
|
||||
|
||||
// execGitLog — _git_log arg surface (limit default 10; the oracle formats
|
||||
// any limit into "-<limit>" and NEVER crashes on non-numbers — subprocess
|
||||
// output is env-dependent so rows pin sane ints only).
|
||||
func execGitLog(args map[string]interface{}) map[string]interface{} {
|
||||
limit, _ := argInt(args, "limit", 10, "-", true)
|
||||
ok, result := GitLog(limit, argStr(args, "path", ""))
|
||||
return envelopeOK(ok, result)
|
||||
}
|
||||
|
||||
// execGitCommit — _git_commit arg surface (message required, files optional).
|
||||
func execGitCommit(args map[string]interface{}) map[string]interface{} {
|
||||
files := []string{}
|
||||
if raw, has := args["files"].([]interface{}); has {
|
||||
for _, f := range raw {
|
||||
if s, isStr := f.(string); isStr {
|
||||
files = append(files, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
ok, result := GitCommit(argStr(args, "message", ""), files)
|
||||
return envelopeOK(ok, result)
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// serialize / deserialize_prepared_tool — the persisted JSON form
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// SerializePreparedTool — PARITY with serialize_prepared_tool /
|
||||
// dataclasses.asdict: ALL NINE fields, in field order conceptually, with
|
||||
// the optional path fields present-as-null when unset (Go "" ≙ Python None
|
||||
// for these fields — the record only ever holds None or a path string).
|
||||
func SerializePreparedTool(p PreparedTool) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"token": p.Token,
|
||||
"name": p.Name,
|
||||
"arguments": copyJSON(p.Arguments),
|
||||
"operation_digest": p.OperationDigest,
|
||||
"created_at": p.CreatedAt,
|
||||
"expires_at": p.ExpiresAt,
|
||||
"requested_path": nullablePath(p.RequestedPath),
|
||||
"resolved_path": nullablePath(p.ResolvedPath),
|
||||
"path_digest": nullablePath(p.PathDigest),
|
||||
}
|
||||
}
|
||||
|
||||
func nullablePath(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// preparedToolFields — the exact dataclass field-name set.
|
||||
var preparedToolFields = map[string]bool{
|
||||
"token": true, "name": true, "arguments": true,
|
||||
"operation_digest": true, "created_at": true, "expires_at": true,
|
||||
"requested_path": true, "resolved_path": true, "path_digest": true,
|
||||
}
|
||||
|
||||
// DeserializePreparedTool — PARITY with deserialize_prepared_tool: strict
|
||||
// field-set + per-field type validation with the oracle's exact messages.
|
||||
// The value arrives untyped (JSON decode shape) so the not-an-object branch
|
||||
// is expressible.
|
||||
func DeserializePreparedTool(value interface{}) (PreparedTool, error) {
|
||||
row, isMap := value.(map[string]interface{})
|
||||
if !isMap {
|
||||
return PreparedTool{}, fmt.Errorf("prepared tool must be an object")
|
||||
}
|
||||
if len(row) != len(preparedToolFields) {
|
||||
return PreparedTool{}, fmt.Errorf("prepared tool fields are invalid")
|
||||
}
|
||||
for k := range row {
|
||||
if !preparedToolFields[k] {
|
||||
return PreparedTool{}, fmt.Errorf("prepared tool fields are invalid")
|
||||
}
|
||||
}
|
||||
token, _ := row["token"].(string)
|
||||
if token == "" {
|
||||
return PreparedTool{}, fmt.Errorf("prepared tool token is invalid")
|
||||
}
|
||||
name, _ := row["name"].(string)
|
||||
if name == "" {
|
||||
return PreparedTool{}, fmt.Errorf("prepared tool name is invalid")
|
||||
}
|
||||
arguments, isArgs := row["arguments"].(map[string]interface{})
|
||||
if !isArgs {
|
||||
return PreparedTool{}, fmt.Errorf("prepared tool arguments are invalid")
|
||||
}
|
||||
digest, isStr := row["operation_digest"].(string)
|
||||
if !isStr {
|
||||
return PreparedTool{}, fmt.Errorf("prepared tool digest is invalid")
|
||||
}
|
||||
if !isPyNumber(row["created_at"]) {
|
||||
return PreparedTool{}, fmt.Errorf("prepared tool timestamps are invalid")
|
||||
}
|
||||
if !isPyNumber(row["expires_at"]) {
|
||||
return PreparedTool{}, fmt.Errorf("prepared tool timestamps are invalid")
|
||||
}
|
||||
return PreparedTool{
|
||||
Token: token,
|
||||
Name: name,
|
||||
Arguments: copyJSON(arguments).(map[string]interface{}),
|
||||
OperationDigest: digest,
|
||||
CreatedAt: toFloat(row["created_at"]),
|
||||
ExpiresAt: toFloat(row["expires_at"]),
|
||||
RequestedPath: optStr(row["requested_path"]),
|
||||
ResolvedPath: optStr(row["resolved_path"]),
|
||||
PathDigest: optStr(row["path_digest"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isPyNumber — isinstance(x, (int, float)) and not isinstance(x, bool):
|
||||
// bool is an int subclass in Python but the oracle EXPLICITLY rejects it.
|
||||
func isPyNumber(v interface{}) bool {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return false
|
||||
case int:
|
||||
return true
|
||||
case int64:
|
||||
return true
|
||||
case float64:
|
||||
return true
|
||||
default:
|
||||
_ = t
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat(v interface{}) float64 {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
return float64(t)
|
||||
case int64:
|
||||
return float64(t)
|
||||
case float64:
|
||||
return t
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func optStr(v interface{}) string {
|
||||
if s, isStr := v.(string); isStr {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// copyJSON — recursive deep copy of JSON-shaped values (copy.deepcopy
|
||||
// parity for the arguments dict).
|
||||
func copyJSON(v interface{}) interface{} {
|
||||
switch t := v.(type) {
|
||||
case map[string]interface{}:
|
||||
out := make(map[string]interface{}, len(t))
|
||||
for k, val := range t {
|
||||
out[k] = copyJSON(val)
|
||||
}
|
||||
return out
|
||||
case []interface{}:
|
||||
out := make([]interface{}, len(t))
|
||||
for i, val := range t {
|
||||
out[i] = copyJSON(val)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// confirm_write — the post-approval write
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ConfirmWrite — PARITY with confirm_write: actually write the file after
|
||||
// user confirmation (validates again, backs up, writes atomically).
|
||||
func ConfirmWrite(path, content string) map[string]interface{} {
|
||||
return ApplyContent(path, content)
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// execute_tool / prepare_tool — reads execute, everything else is prepared
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ExecuteTool — PARITY with execute_tool: the safe public entry point.
|
||||
func ExecuteTool(toolName string, args map[string]interface{}) map[string]interface{} {
|
||||
return PrepareTool(toolName, args, DefaultApprovalTTL, 0, false)
|
||||
}
|
||||
|
||||
// PrepareTool — PARITY with prepare_tool(tool_name, args, *, ttl, now):
|
||||
// read-only tools execute immediately; write-class tools return an
|
||||
// approval preview bound to the operation + path digests. nowSet pins the
|
||||
// clock (no-clock law; callers that let the oracle default pass false).
|
||||
func PrepareTool(toolName string, args map[string]interface{}, ttl float64, now float64, nowSet bool) map[string]interface{} {
|
||||
if args == nil {
|
||||
return map[string]interface{}{"success": false, "error": "Tool arguments must be an object"}
|
||||
}
|
||||
arguments, _ := copyJSON(args).(map[string]interface{})
|
||||
if readOnlyTools[toolName] {
|
||||
return ExecuteToolNow(toolName, arguments)
|
||||
}
|
||||
|
||||
requestedPath, resolvedPath, pathInfoErr := PreparedPath(toolName, arguments)
|
||||
if pathInfoErr != nil {
|
||||
// The oracle's except (OSError, ValueError) arm.
|
||||
return map[string]interface{}{"success": false, "error": pathInfoErr.Error()}
|
||||
}
|
||||
|
||||
preview := map[string]interface{}{}
|
||||
if toolName == "write_file" {
|
||||
preview = execWriteFile(arguments)
|
||||
} else if toolName == "edit_file" {
|
||||
preview = notPortedEnvelope("edit_file")
|
||||
}
|
||||
if len(preview) > 0 {
|
||||
success, _ := preview["success"].(bool)
|
||||
needsConfirm, _ := preview["needs_confirm"].(bool)
|
||||
if !success || !needsConfirm {
|
||||
return preview
|
||||
}
|
||||
}
|
||||
|
||||
timestamp := now
|
||||
if !nowSet {
|
||||
timestamp = monotonicNow()
|
||||
}
|
||||
pending := NewPreparedTool(toolName, arguments, requestedPath, resolvedPath, timestamp, ttl)
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
"needs_confirm": true,
|
||||
"pending": pending,
|
||||
"result": "Operator approval required before execution.",
|
||||
}
|
||||
for _, key := range []string{"diff", "path", "content"} {
|
||||
if v, has := preview[key]; has {
|
||||
result[key] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// monotonicNow — time.monotonic() analogue (volatile origin; battery and
|
||||
// parity drivers always pin `now` explicitly).
|
||||
func monotonicNow() float64 {
|
||||
return time.Since(processStart).Seconds()
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// _find_capability — the escape hatch over the capability broker
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// CapabilityMatch — one broker result. ArgNames carries the ORDERED
|
||||
// parameter names (Python ", ".join(properties) is dict-insertion order;
|
||||
// Go maps cannot preserve it, so the seam contract passes the order
|
||||
// explicitly); Cap is the verbatim match dict echoed into "capabilities".
|
||||
type CapabilityMatch struct {
|
||||
Name string
|
||||
Description string
|
||||
Risk string // "" → oracle default "read-only"
|
||||
ArgNames []string
|
||||
Cap interface{}
|
||||
}
|
||||
|
||||
// findCapabilityBroker — injectable broker seam. Default: unavailable,
|
||||
// which lands in the oracle's degrade-not-crash error envelope (the same
|
||||
// branch the oracle takes when the broker import/call raises).
|
||||
var findCapabilityBroker = func(query string) ([]CapabilityMatch, error) {
|
||||
return nil, fmt.Errorf("capability broker kernel not ported (dichotomy in flight)")
|
||||
}
|
||||
|
||||
const findCapabilityTool = "find_capability"
|
||||
|
||||
// FindCapability — PARITY with _find_capability: query-required gate,
|
||||
// broker call (degrade-not-crash), no-matches guidance message, and the
|
||||
// match-line rendering "- name (risk): description Args: a, b".
|
||||
func FindCapability(args map[string]interface{}) map[string]interface{} {
|
||||
query, isStr := args["query"].(string)
|
||||
if !isStr || strings.TrimSpace(query) == "" {
|
||||
return map[string]interface{}{"success": false, "error": "Query is required — describe the capability you need"}
|
||||
}
|
||||
matches, err := findCapabilityBroker(query)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "error": fmt.Sprintf("find_capability failed: %s", err)}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"result": fmt.Sprintf(
|
||||
"No capability matches %s. The tools listed in your system prompt are all that is installed. Use %s again with a broader description, or work with the tools you have.",
|
||||
pyRepr(query), findCapabilityTool),
|
||||
"capabilities": []interface{}{},
|
||||
"exposed": []interface{}{},
|
||||
}
|
||||
}
|
||||
var caps []interface{}
|
||||
var exposed []interface{}
|
||||
var lines []string
|
||||
for _, m := range matches {
|
||||
risk := m.Risk
|
||||
if risk == "" {
|
||||
risk = "read-only"
|
||||
}
|
||||
argNames := "no arguments"
|
||||
if len(m.ArgNames) > 0 {
|
||||
argNames = strings.Join(m.ArgNames, ", ")
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s (%s): %s Args: %s", m.Name, risk, m.Description, argNames))
|
||||
caps = append(caps, m.Cap)
|
||||
exposed = append(exposed, m.Name)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"result": "Matching capabilities (callable now in this session):\n" + strings.Join(lines, "\n"),
|
||||
"capabilities": caps,
|
||||
"exposed": exposed,
|
||||
}
|
||||
}
|
||||
|
||||
// pyRepr — Python repr() for the frozen message class: single quotes,
|
||||
// backslash/quote escapes, control chars as \xNN/\n/\t/\r (ASCII-simple
|
||||
// queries; non-ASCII repr rules diverge and are excluded from truth rows).
|
||||
func pyRepr(s string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('\'')
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\':
|
||||
b.WriteString("\\\\")
|
||||
case '\'':
|
||||
b.WriteString("\\'")
|
||||
case '\n':
|
||||
b.WriteString("\\n")
|
||||
case '\t':
|
||||
b.WriteString("\\t")
|
||||
case '\r':
|
||||
b.WriteString("\\r")
|
||||
default:
|
||||
if r < 0x20 {
|
||||
b.WriteString(fmt.Sprintf("\\x%02x", r))
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteByte('\'')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// shared Python-format helpers
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// pyListRepr — Python str(list): "['a', 'b']" (paths.go's write-denial
|
||||
// message embeds ALLOWED_WRITE_DIRS; %v's [a b] shape is not the oracle).
|
||||
func pyListRepr(items []string) string {
|
||||
quoted := make([]string, len(items))
|
||||
for i, s := range items {
|
||||
quoted[i] = "'" + strings.NewReplacer("\\", "\\\\", "'", "\\'").Replace(s) + "'"
|
||||
}
|
||||
return "[" + strings.Join(quoted, ", ") + "]"
|
||||
}
|
||||
|
||||
// sortedKeys — deterministic map iteration for battery rendering.
|
||||
func sortedKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -120,6 +120,8 @@ func ValidateWritePath(path string) (bool, string, string) {
|
||||
}
|
||||
}
|
||||
if !IsPathAllowed(resolved, AllowedWriteDirs) {
|
||||
// pyListRepr: the oracle embeds str(ALLOWED_WRITE_DIRS) — "['/home/u', '/mnt/workdrive']";
|
||||
// %v's [a b] shape is not the oracle's bytes (factory-heavy-te-life).
|
||||
return false, fmt.Sprintf(
|
||||
"Access denied: %s is outside allowed write directories: %s. "+
|
||||
"Write inside one of those directories instead.", path, pyListRepr(AllowedWriteDirs)), ""
|
||||
|
||||
@@ -0,0 +1,920 @@
|
||||
{
|
||||
"_doc": "FROZEN ORACLE (tool_executor lifecycle family — factory-heavy-te-life). Placeholders: {{TMP}} read root, {{WROOT}} write root, {{HOME}} runner home, {{TOKEN}} uuid. Broker stub matches ride the find_cap rows verbatim (property ORDER is contract). Regenerate: scripts/gen_tool_executor_lifecycle_truth.py",
|
||||
"fixtures": {
|
||||
"files": [
|
||||
{
|
||||
"path": "{{TMP}}/w.txt",
|
||||
"content": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\n"
|
||||
},
|
||||
{
|
||||
"path": "{{TMP}}/a.txt",
|
||||
"content": "the spine rebuild parity\n"
|
||||
}
|
||||
],
|
||||
"dirs": [
|
||||
"{{TMP}}/adir"
|
||||
]
|
||||
},
|
||||
"stub_queries": {
|
||||
"none": "run the whole test suite",
|
||||
"two": "commit my changes"
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "read_window",
|
||||
"tool": "read_file",
|
||||
"args": {
|
||||
"path": "{{TMP}}/w.txt",
|
||||
"offset": 2,
|
||||
"limit": 3
|
||||
},
|
||||
"want": {
|
||||
"success": true,
|
||||
"result": "[showing from line 2]\n2: line 2\n3: line 3\n4: line 4\n\n[showing lines 2-4 of 21 total]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "read_default",
|
||||
"tool": "read_file",
|
||||
"args": {
|
||||
"path": "{{TMP}}/a.txt"
|
||||
},
|
||||
"want": {
|
||||
"success": true,
|
||||
"result": "1: the spine rebuild parity\n2: "
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "read_missing",
|
||||
"tool": "read_file",
|
||||
"args": {
|
||||
"path": "{{TMP}}/missing.txt"
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Not found: {{TMP}}/missing.txt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "read_blocked",
|
||||
"tool": "read_file",
|
||||
"args": {
|
||||
"path": "/etc/hostname"
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Access denied: /etc/hostname is outside allowed directories"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "read_offset_none",
|
||||
"tool": "read_file",
|
||||
"args": {
|
||||
"path": "{{TMP}}/w.txt",
|
||||
"offset": null
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Read failed: unsupported operand type(s) for -: 'NoneType' and 'int'"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "read_limit_str",
|
||||
"tool": "read_file",
|
||||
"args": {
|
||||
"path": "{{TMP}}/w.txt",
|
||||
"limit": "x"
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Read failed: unsupported operand type(s) for +: 'int' and 'str'"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "read_offset_frac",
|
||||
"tool": "read_file",
|
||||
"args": {
|
||||
"path": "{{TMP}}/w.txt",
|
||||
"offset": 1.5
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Read failed: slice indices must be integers or None or have an __index__ method"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "unknown",
|
||||
"tool": "no_such_tool",
|
||||
"args": {
|
||||
"x": 1
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Unknown tool: no_such_tool"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "list_hit",
|
||||
"tool": "list_directory",
|
||||
"args": {
|
||||
"path": "{{TMP}}"
|
||||
},
|
||||
"want": {
|
||||
"success": true,
|
||||
"result": "📄 a.txt\n📁 adir/\n📄 w.txt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "list_not_dir",
|
||||
"tool": "list_directory",
|
||||
"args": {
|
||||
"path": "{{TMP}}/nodir"
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Not a directory: {{TMP}}/nodir"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "grep_no_pattern",
|
||||
"tool": "grep_files",
|
||||
"args": {
|
||||
"path": "{{TMP}}"
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Pattern is required"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "grep_no_match",
|
||||
"tool": "grep_files",
|
||||
"args": {
|
||||
"pattern": "zzz_never",
|
||||
"path": "{{TMP}}"
|
||||
},
|
||||
"want": {
|
||||
"success": true,
|
||||
"result": "(no matches)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "write_preview",
|
||||
"tool": "write_file",
|
||||
"args": {
|
||||
"path": "{{WROOT}}/exec-new.txt",
|
||||
"content": "alpha\nbeta\n"
|
||||
},
|
||||
"want": {
|
||||
"success": true,
|
||||
"needs_confirm": true,
|
||||
"diff": {
|
||||
"head": [
|
||||
"--- a/exec-new.txt",
|
||||
"+++ b/exec-new.txt"
|
||||
],
|
||||
"plus": 2,
|
||||
"minus": 0
|
||||
},
|
||||
"path": "{{WROOT}}/exec-new.txt",
|
||||
"content": "alpha\nbeta\n",
|
||||
"result": "Diff generated for exec-new.txt. Awaiting confirmation."
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "write_denied_outside",
|
||||
"tool": "write_file",
|
||||
"args": {
|
||||
"path": "/usr/share/x.txt",
|
||||
"content": "x"
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Access denied: /usr/share/x.txt is outside allowed write directories: ['{{HOME}}', '/mnt/workdrive']. Write inside one of those directories instead."
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "write_denied_protected",
|
||||
"tool": "write_file",
|
||||
"args": {
|
||||
"path": "/etc/passwd",
|
||||
"content": "x"
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Access denied: /etc/passwd is in a protected directory (/etc). This directory is always blocked, even though it is inside an allowed write directory. Choose a different location."
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "git_commit_no_message",
|
||||
"tool": "git_commit",
|
||||
"args": {},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Commit message is required"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "run_tests_no_runner",
|
||||
"tool": "run_tests",
|
||||
"args": {
|
||||
"path": "{{TMP}}/a.txt"
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "No test runner configured for .txt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "lint_missing",
|
||||
"tool": "lint_file",
|
||||
"args": {
|
||||
"path": "{{TMP}}/missing.py"
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Not found: {{TMP}}/missing.py"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "exec_now",
|
||||
"name": "format_denied",
|
||||
"tool": "format_code",
|
||||
"args": {
|
||||
"path": "/etc/f.py"
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Access denied: /etc/f.py is in a protected directory (/etc). This directory is always blocked, even though it is inside an allowed write directory. Choose a different location."
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "serialize",
|
||||
"name": "full",
|
||||
"pending": {
|
||||
"token": "ser-full-token",
|
||||
"name": "write_file",
|
||||
"arguments": {
|
||||
"path": "/tmp/x.txt",
|
||||
"content": "abc"
|
||||
},
|
||||
"operation_digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"created_at": 10.0,
|
||||
"expires_at": 310.0,
|
||||
"requested_path": "/tmp/x.txt",
|
||||
"resolved_path": "/tmp/x.txt",
|
||||
"path_digest": "pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp"
|
||||
},
|
||||
"want": {
|
||||
"token": "ser-full-token",
|
||||
"name": "write_file",
|
||||
"arguments": {
|
||||
"path": "/tmp/x.txt",
|
||||
"content": "abc"
|
||||
},
|
||||
"operation_digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"created_at": 10.0,
|
||||
"expires_at": 310.0,
|
||||
"requested_path": "/tmp/x.txt",
|
||||
"resolved_path": "/tmp/x.txt",
|
||||
"path_digest": "pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "serialize",
|
||||
"name": "minimal",
|
||||
"pending": {
|
||||
"token": "ser-min-token",
|
||||
"name": "run_command",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": 0.0,
|
||||
"expires_at": 5.0,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null
|
||||
},
|
||||
"want": {
|
||||
"token": "ser-min-token",
|
||||
"name": "run_command",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": 0.0,
|
||||
"expires_at": 5.0,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "ok_full",
|
||||
"value": {
|
||||
"token": "ser-full-token",
|
||||
"name": "write_file",
|
||||
"arguments": {
|
||||
"path": "/tmp/x.txt",
|
||||
"content": "abc"
|
||||
},
|
||||
"operation_digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"created_at": 10.0,
|
||||
"expires_at": 310.0,
|
||||
"requested_path": "/tmp/x.txt",
|
||||
"resolved_path": "/tmp/x.txt",
|
||||
"path_digest": "pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp"
|
||||
},
|
||||
"want": {
|
||||
"token": "ser-full-token",
|
||||
"name": "write_file",
|
||||
"arguments": {
|
||||
"path": "/tmp/x.txt",
|
||||
"content": "abc"
|
||||
},
|
||||
"operation_digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"created_at": 10.0,
|
||||
"expires_at": 310.0,
|
||||
"requested_path": "/tmp/x.txt",
|
||||
"resolved_path": "/tmp/x.txt",
|
||||
"path_digest": "pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "ok_minimal",
|
||||
"value": {
|
||||
"token": "ser-min-token",
|
||||
"name": "run_command",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": 0.0,
|
||||
"expires_at": 5.0,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null
|
||||
},
|
||||
"want": {
|
||||
"token": "ser-min-token",
|
||||
"name": "run_command",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": 0.0,
|
||||
"expires_at": 5.0,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "ok_int_timestamps",
|
||||
"value": {
|
||||
"token": "ser-min-token",
|
||||
"name": "run_command",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": 100,
|
||||
"expires_at": 400,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null
|
||||
},
|
||||
"want": {
|
||||
"token": "ser-min-token",
|
||||
"name": "run_command",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": 100,
|
||||
"expires_at": 400,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "not_object_str",
|
||||
"value": "nope",
|
||||
"raises": "prepared tool must be an object"
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "not_object_null",
|
||||
"value": null,
|
||||
"raises": "prepared tool must be an object"
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "extra_field",
|
||||
"value": {
|
||||
"token": "ser-min-token",
|
||||
"name": "run_command",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": 0.0,
|
||||
"expires_at": 5.0,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null,
|
||||
"extra": 1
|
||||
},
|
||||
"raises": "prepared tool fields are invalid"
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "missing_field",
|
||||
"value": {
|
||||
"token": "ser-min-token",
|
||||
"name": "run_command",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": 0.0,
|
||||
"expires_at": 5.0,
|
||||
"requested_path": null,
|
||||
"resolved_path": null
|
||||
},
|
||||
"raises": "prepared tool fields are invalid"
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "token_empty",
|
||||
"value": {
|
||||
"token": "",
|
||||
"name": "run_command",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": 0.0,
|
||||
"expires_at": 5.0,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null
|
||||
},
|
||||
"raises": "prepared tool token is invalid"
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "token_number",
|
||||
"value": {
|
||||
"token": 5,
|
||||
"name": "run_command",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": 0.0,
|
||||
"expires_at": 5.0,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null
|
||||
},
|
||||
"raises": "prepared tool token is invalid"
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "name_empty",
|
||||
"value": {
|
||||
"token": "ser-min-token",
|
||||
"name": "",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": 0.0,
|
||||
"expires_at": 5.0,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null
|
||||
},
|
||||
"raises": "prepared tool name is invalid"
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "arguments_list",
|
||||
"value": {
|
||||
"token": "ser-min-token",
|
||||
"name": "run_command",
|
||||
"arguments": [],
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": 0.0,
|
||||
"expires_at": 5.0,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null
|
||||
},
|
||||
"raises": "prepared tool arguments are invalid"
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "digest_number",
|
||||
"value": {
|
||||
"token": "ser-min-token",
|
||||
"name": "run_command",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": 3.5,
|
||||
"created_at": 0.0,
|
||||
"expires_at": 5.0,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null
|
||||
},
|
||||
"raises": "prepared tool digest is invalid"
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "created_bool",
|
||||
"value": {
|
||||
"token": "ser-min-token",
|
||||
"name": "run_command",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": true,
|
||||
"expires_at": 5.0,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null
|
||||
},
|
||||
"raises": "prepared tool timestamps are invalid"
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "created_str",
|
||||
"value": {
|
||||
"token": "ser-min-token",
|
||||
"name": "run_command",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": "1",
|
||||
"expires_at": 5.0,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null
|
||||
},
|
||||
"raises": "prepared tool timestamps are invalid"
|
||||
},
|
||||
{
|
||||
"op": "deserialize",
|
||||
"name": "expires_null",
|
||||
"value": {
|
||||
"token": "ser-min-token",
|
||||
"name": "run_command",
|
||||
"arguments": {
|
||||
"command": "ls"
|
||||
},
|
||||
"operation_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"created_at": 0.0,
|
||||
"expires_at": null,
|
||||
"requested_path": null,
|
||||
"resolved_path": null,
|
||||
"path_digest": null
|
||||
},
|
||||
"raises": "prepared tool timestamps are invalid"
|
||||
},
|
||||
{
|
||||
"op": "confirm_write",
|
||||
"name": "new_file",
|
||||
"path": "{{WROOT}}/confirmed-new.txt",
|
||||
"content": "abc",
|
||||
"want": {
|
||||
"success": true,
|
||||
"result": "Wrote 3 chars to confirmed-new.txt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "confirm_write",
|
||||
"name": "protected",
|
||||
"path": "/etc/x.txt",
|
||||
"content": "x",
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Access denied: /etc/x.txt is in a protected directory (/etc). This directory is always blocked, even though it is inside an allowed write directory. Choose a different location."
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "confirm_write",
|
||||
"name": "outside",
|
||||
"path": "/usr/share/x.txt",
|
||||
"content": "x",
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Access denied: /usr/share/x.txt is outside allowed write directories: ['{{HOME}}', '/mnt/workdrive']. Write inside one of those directories instead."
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "confirm_write",
|
||||
"name": "readonly_root",
|
||||
"path": "/tmp/te-life-readonly-confirm/nope.txt",
|
||||
"content": "x",
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Access denied: /tmp/te-life-readonly-confirm/nope.txt is outside allowed write directories: ['{{HOME}}', '/mnt/workdrive']. Write inside one of those directories instead."
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "execute_tool",
|
||||
"name": "readonly_passthrough",
|
||||
"tool": "read_file",
|
||||
"args": {
|
||||
"path": "{{TMP}}/a.txt"
|
||||
},
|
||||
"want": {
|
||||
"success": true,
|
||||
"result": "1: the spine rebuild parity\n2: "
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "execute_tool",
|
||||
"name": "write_prepare",
|
||||
"tool": "write_file",
|
||||
"args": {
|
||||
"path": "{{WROOT}}/exec-prep.txt",
|
||||
"content": "one\ntwo\nthree\n"
|
||||
},
|
||||
"now": 1000.0,
|
||||
"want": {
|
||||
"success": true,
|
||||
"needs_confirm": true,
|
||||
"pending": {
|
||||
"token": "{{TOKEN}}",
|
||||
"name": "write_file",
|
||||
"arguments": {
|
||||
"path": "{{WROOT}}/exec-prep.txt",
|
||||
"content": "one\ntwo\nthree\n"
|
||||
},
|
||||
"operation_digest": "{{DIGEST}}",
|
||||
"created_at": 1000.0,
|
||||
"expires_at": 1300.0,
|
||||
"requested_path": "{{WROOT}}/exec-prep.txt",
|
||||
"resolved_path": "{{WROOT}}/exec-prep.txt",
|
||||
"path_digest": "ffa63583dfa6706b87d284b86b0d693a161e4840aad2c5cf6b5d27c3b9621f7d"
|
||||
},
|
||||
"result": "Operator approval required before execution.",
|
||||
"diff": {
|
||||
"head": [
|
||||
"--- a/exec-prep.txt",
|
||||
"+++ b/exec-prep.txt"
|
||||
],
|
||||
"plus": 3,
|
||||
"minus": 0
|
||||
},
|
||||
"path": "{{WROOT}}/exec-prep.txt",
|
||||
"content": "one\ntwo\nthree\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "execute_tool",
|
||||
"name": "format_prepare",
|
||||
"tool": "format_code",
|
||||
"args": {
|
||||
"path": "{{WROOT}}/fmt-prep.go"
|
||||
},
|
||||
"now": 2000.0,
|
||||
"want": {
|
||||
"success": true,
|
||||
"needs_confirm": true,
|
||||
"pending": {
|
||||
"token": "{{TOKEN}}",
|
||||
"name": "format_code",
|
||||
"arguments": {
|
||||
"path": "{{WROOT}}/fmt-prep.go"
|
||||
},
|
||||
"operation_digest": "{{DIGEST}}",
|
||||
"created_at": 2000.0,
|
||||
"expires_at": 2300.0,
|
||||
"requested_path": "{{WROOT}}/fmt-prep.go",
|
||||
"resolved_path": "{{WROOT}}/fmt-prep.go",
|
||||
"path_digest": "ffa63583dfa6706b87d284b86b0d693a161e4840aad2c5cf6b5d27c3b9621f7d"
|
||||
},
|
||||
"result": "Operator approval required before execution."
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "execute_tool",
|
||||
"name": "write_ttl",
|
||||
"tool": "write_file",
|
||||
"args": {
|
||||
"path": "{{WROOT}}/prep-ttl.txt",
|
||||
"content": "x"
|
||||
},
|
||||
"now": 5.0,
|
||||
"ttl": 60.0,
|
||||
"want": {
|
||||
"success": true,
|
||||
"needs_confirm": true,
|
||||
"pending": {
|
||||
"token": "{{TOKEN}}",
|
||||
"name": "write_file",
|
||||
"arguments": {
|
||||
"path": "{{WROOT}}/prep-ttl.txt",
|
||||
"content": "x"
|
||||
},
|
||||
"operation_digest": "{{DIGEST}}",
|
||||
"created_at": 5.0,
|
||||
"expires_at": 65.0,
|
||||
"requested_path": "{{WROOT}}/prep-ttl.txt",
|
||||
"resolved_path": "{{WROOT}}/prep-ttl.txt",
|
||||
"path_digest": "ffa63583dfa6706b87d284b86b0d693a161e4840aad2c5cf6b5d27c3b9621f7d"
|
||||
},
|
||||
"result": "Operator approval required before execution.",
|
||||
"diff": {
|
||||
"head": [
|
||||
"--- a/prep-ttl.txt",
|
||||
"+++ b/prep-ttl.txt"
|
||||
],
|
||||
"plus": 1,
|
||||
"minus": 0
|
||||
},
|
||||
"path": "{{WROOT}}/prep-ttl.txt",
|
||||
"content": "x"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "execute_tool",
|
||||
"name": "write_denied",
|
||||
"tool": "write_file",
|
||||
"args": {
|
||||
"path": "/usr/share/x.txt",
|
||||
"content": "x"
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Access denied: /usr/share/x.txt is outside allowed write directories: ['{{HOME}}', '/mnt/workdrive']. Write inside one of those directories instead."
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "execute_tool",
|
||||
"name": "edit_no_path",
|
||||
"tool": "edit_file",
|
||||
"args": {
|
||||
"search": "a",
|
||||
"replace": "b"
|
||||
},
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Path is required"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "execute_tool",
|
||||
"name": "args_null",
|
||||
"tool": "write_file",
|
||||
"args": null,
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Tool arguments must be an object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "execute_tool",
|
||||
"name": "format_denied_readonly_root",
|
||||
"tool": "format_code",
|
||||
"args": {
|
||||
"path": "/tmp/te-life-readonly-denial/x.go"
|
||||
},
|
||||
"now": 3000.0,
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Access denied: /tmp/te-life-readonly-denial/x.go is outside allowed write directories: ['{{HOME}}', '/mnt/workdrive']. Write inside one of those directories instead."
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "find_cap",
|
||||
"name": "query_missing",
|
||||
"args": {},
|
||||
"matches": [],
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Query is required — describe the capability you need"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "find_cap",
|
||||
"name": "query_blank",
|
||||
"args": {
|
||||
"query": " "
|
||||
},
|
||||
"matches": [],
|
||||
"want": {
|
||||
"success": false,
|
||||
"error": "Query is required — describe the capability you need"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "find_cap",
|
||||
"name": "no_matches",
|
||||
"args": {
|
||||
"query": "run the whole test suite"
|
||||
},
|
||||
"matches": [],
|
||||
"want": {
|
||||
"success": true,
|
||||
"result": "No capability matches 'run the whole test suite'. The tools listed in your system prompt are all that is installed. Use find_capability again with a broader description, or work with the tools you have.",
|
||||
"capabilities": [],
|
||||
"exposed": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "find_cap",
|
||||
"name": "two_matches",
|
||||
"args": {
|
||||
"query": "commit my changes"
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"want": {
|
||||
"success": true,
|
||||
"result": "Matching capabilities (callable now in this session):\n- git_commit (irreversible): Commit staged changes Args: message, files\n- snapshot_tree (read-only): Snapshot a directory tree Args: no arguments",
|
||||
"capabilities": [
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"exposed": [
|
||||
"git_commit",
|
||||
"snapshot_tree"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package main
|
||||
|
||||
// truth_lifecycle_test.go — the PREPARE/APPROVE/SERIALIZE family battery
|
||||
// (contract factory-heavy-te-life). Replays truth_lifecycle.json — frozen
|
||||
// from the LIVE oracle by scripts/gen_tool_executor_lifecycle_truth.py —
|
||||
// through the Go lifecycle kernels: ExecuteToolNow, SerializePreparedTool,
|
||||
// DeserializePreparedTool, ExecuteTool/PrepareTool, ConfirmWrite,
|
||||
// FindCapability. Placeholders: {{TMP}}/{{WROOT}} runtime fixture roots,
|
||||
// {{HOME}} runner home, {{TOKEN}} uuid token, {{DIGEST}} the structurally
|
||||
// re-derived operation digest. Comparison is canonical-JSON (parse +
|
||||
// re-marshal, so 1000 vs 1000.0 compare equal).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type lifeRow struct {
|
||||
Op string `json:"op"`
|
||||
Name string `json:"name"`
|
||||
Tool string `json:"tool"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
Now *float64 `json:"now"`
|
||||
TTL *float64 `json:"ttl"`
|
||||
Pending map[string]interface{} `json:"pending"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Matches json.RawMessage `json:"matches"`
|
||||
Want json.RawMessage `json:"want"`
|
||||
Raises string `json:"raises"`
|
||||
}
|
||||
|
||||
type lifeDoc struct {
|
||||
Fixtures truthFixtures `json:"fixtures"`
|
||||
Rows []lifeRow `json:"rows"`
|
||||
StubQ struct {
|
||||
None string `json:"none"`
|
||||
Two string `json:"two"`
|
||||
} `json:"stub_queries"`
|
||||
}
|
||||
|
||||
// lifeSubst — {{TMP}}/{{WROOT}} first (the roots often live under home),
|
||||
// then {{HOME}} — the generator's substitution order.
|
||||
func lifeSubst(s, tmpRoot, wRoot string) string {
|
||||
home, _ := os.UserHomeDir()
|
||||
s = strings.ReplaceAll(s, "{{TMP}}", tmpRoot)
|
||||
s = strings.ReplaceAll(s, "{{WROOT}}", wRoot)
|
||||
return strings.ReplaceAll(s, "{{HOME}}", home)
|
||||
}
|
||||
|
||||
// canon — parse + re-marshal: canonical JSON comparison (floats 1000 ==
|
||||
// 1000.0, map key order normalized).
|
||||
func canon(v interface{}) string {
|
||||
b, _ := json.Marshal(v)
|
||||
var reparsed interface{}
|
||||
_ = json.Unmarshal(b, &reparsed)
|
||||
out, _ := json.Marshal(reparsed)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func canonRaw(raw json.RawMessage) string {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return "<unparseable: " + err.Error() + ">"
|
||||
}
|
||||
return canon(v)
|
||||
}
|
||||
|
||||
// diffSummary — the frozen diff normalization (head 2 + plus/minus counts).
|
||||
func diffSummary(diff string) map[string]interface{} {
|
||||
lines := strings.Split(diff, "\n")
|
||||
plus, minus := 0, 0
|
||||
for _, l := range lines {
|
||||
if strings.HasPrefix(l, "+") && !strings.HasPrefix(l, "+++") {
|
||||
plus++
|
||||
}
|
||||
if strings.HasPrefix(l, "-") && !strings.HasPrefix(l, "---") {
|
||||
minus++
|
||||
}
|
||||
}
|
||||
head := lines
|
||||
if len(head) > 2 {
|
||||
head = head[:2]
|
||||
}
|
||||
return map[string]interface{}{"head": head, "plus": plus, "minus": minus}
|
||||
}
|
||||
|
||||
// normalizeEnvelope — apply the frozen normalizations to a Go envelope.
|
||||
func normalizeEnvelope(env map[string]interface{}) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(env))
|
||||
for k, v := range env {
|
||||
out[k] = v
|
||||
}
|
||||
if d, has := out["diff"]; has {
|
||||
if ds, isStr := d.(string); isStr {
|
||||
out["diff"] = diffSummary(ds)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizePrepareEnvelope — pending → serialized form + {{TOKEN}} +
|
||||
// {{DIGEST}}, with the structural digest invariant asserted in place.
|
||||
func normalizePrepareEnvelope(t *testing.T, env map[string]interface{}) map[string]interface{} {
|
||||
out := normalizeEnvelope(env)
|
||||
p, has := out["pending"]
|
||||
if !has {
|
||||
return out
|
||||
}
|
||||
pt, ok := p.(PreparedTool)
|
||||
if !ok {
|
||||
t.Fatalf("pending is not a PreparedTool: %T", p)
|
||||
}
|
||||
ser := SerializePreparedTool(pt)
|
||||
ser["token"] = "{{TOKEN}}"
|
||||
// Structural invariant (the {{DIGEST}} contract): the pending digest
|
||||
// binds EXACTLY (name, arguments) as hashed at prepare time.
|
||||
if got := CanonicalDigest(pt.Name, pt.Arguments); got != pt.OperationDigest {
|
||||
t.Errorf("pending.operation_digest invariant: got %s, pending carries %s", got, pt.OperationDigest)
|
||||
}
|
||||
ser["operation_digest"] = "{{DIGEST}}"
|
||||
out["pending"] = ser
|
||||
return out
|
||||
}
|
||||
|
||||
func TestLifecycleTruth(t *testing.T) {
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Skip("skipped in CI: Python-subprocess sibling batteries set this precedent (SRE batch)")
|
||||
}
|
||||
raw, err := os.ReadFile("truth_lifecycle.json")
|
||||
if err != nil {
|
||||
t.Fatalf("truth_lifecycle.json missing: %v", err)
|
||||
}
|
||||
var doc lifeDoc
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("unparseable: %v", err)
|
||||
}
|
||||
tmpRoot := mkTruthRoot(t, AllowedReadDirs)
|
||||
wRoot := mkWriteRoot(t)
|
||||
for _, f := range doc.Fixtures.Files {
|
||||
p := lifeSubst(f.Path, tmpRoot, wRoot)
|
||||
if err := os.WriteFile(p, []byte(lifeSubst(f.Content, tmpRoot, wRoot)), 0o644); err != nil {
|
||||
t.Fatalf("fixture %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
for _, d := range doc.Fixtures.Dirs {
|
||||
if err := os.MkdirAll(lifeSubst(d, tmpRoot, wRoot), 0o755); err != nil {
|
||||
t.Fatalf("fixture dir %s: %v", d, err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(tmpRoot)
|
||||
os.RemoveAll(wRoot)
|
||||
})
|
||||
|
||||
for i, row := range doc.Rows {
|
||||
row := row
|
||||
t.Run(row.Op+"/"+row.Name, func(t *testing.T) {
|
||||
switch row.Op {
|
||||
case "exec_now":
|
||||
var args map[string]interface{}
|
||||
_ = json.Unmarshal(row.Args, &args)
|
||||
substArgs(t, args, tmpRoot, wRoot)
|
||||
got := normalizeEnvelope(ExecuteToolNow(row.Tool, args))
|
||||
compare(t, row, got, tmpRoot, wRoot)
|
||||
case "serialize":
|
||||
pt := preparedFromRow(t, row.Pending, tmpRoot, wRoot)
|
||||
got := SerializePreparedTool(pt)
|
||||
compare(t, row, got, tmpRoot, wRoot)
|
||||
case "deserialize":
|
||||
var value interface{}
|
||||
if len(row.Value) > 0 {
|
||||
_ = json.Unmarshal(row.Value, &value)
|
||||
}
|
||||
pt, err := DeserializePreparedTool(value)
|
||||
if err != nil {
|
||||
if row.Raises == "" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if err.Error() != row.Raises {
|
||||
t.Errorf("error %q want %q", err.Error(), row.Raises)
|
||||
}
|
||||
return
|
||||
}
|
||||
if row.Raises != "" {
|
||||
t.Fatalf("expected raise %q, got pending %+v", row.Raises, pt)
|
||||
}
|
||||
compare(t, row, SerializePreparedTool(pt), tmpRoot, wRoot)
|
||||
case "confirm_write":
|
||||
got := normalizeEnvelope(ConfirmWrite(lifeSubst(row.Path, tmpRoot, wRoot), lifeSubst(row.Content, tmpRoot, wRoot)))
|
||||
compare(t, row, got, tmpRoot, wRoot)
|
||||
case "execute_tool":
|
||||
var args map[string]interface{}
|
||||
if string(row.Args) != "null" && len(row.Args) > 0 {
|
||||
_ = json.Unmarshal(row.Args, &args)
|
||||
substArgs(t, args, tmpRoot, wRoot)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if row.Now != nil || row.TTL != nil {
|
||||
ttl := DefaultApprovalTTL
|
||||
if row.TTL != nil {
|
||||
ttl = *row.TTL
|
||||
}
|
||||
now := 0.0
|
||||
nowSet := false
|
||||
if row.Now != nil {
|
||||
now, nowSet = *row.Now, true
|
||||
}
|
||||
got = PrepareTool(row.Tool, args, ttl, now, nowSet)
|
||||
} else {
|
||||
got = ExecuteTool(row.Tool, args)
|
||||
}
|
||||
compare(t, row, normalizePrepareEnvelope(t, got), tmpRoot, wRoot)
|
||||
case "find_cap":
|
||||
var args map[string]interface{}
|
||||
_ = json.Unmarshal(row.Args, &args)
|
||||
substArgs(t, args, tmpRoot, wRoot)
|
||||
matches := capabilityMatchesFromRaw(t, row.Matches)
|
||||
old := findCapabilityBroker
|
||||
findCapabilityBroker = func(q string) ([]CapabilityMatch, error) {
|
||||
return matches, nil
|
||||
}
|
||||
defer func() { findCapabilityBroker = old }()
|
||||
got := normalizeEnvelope(FindCapability(args))
|
||||
compare(t, row, got, tmpRoot, wRoot)
|
||||
default:
|
||||
t.Fatalf("unknown op %q", row.Op)
|
||||
}
|
||||
})
|
||||
_ = i
|
||||
}
|
||||
}
|
||||
|
||||
func compare(t *testing.T, row lifeRow, got map[string]interface{}, tmpRoot, wRoot string) {
|
||||
t.Helper()
|
||||
g := canon(got)
|
||||
w := canonRaw(json.RawMessage(lifeSubst(string(row.Want), tmpRoot, wRoot)))
|
||||
if g != w {
|
||||
t.Errorf("row %s/%s:\n got %s\nwant %s", row.Op, row.Name, g, w)
|
||||
}
|
||||
}
|
||||
|
||||
// substArgs — deep {{TMP}}/{{WROOT}}/{{HOME}} substitution over arg values.
|
||||
func substArgs(t *testing.T, args map[string]interface{}, tmpRoot, wRoot string) {
|
||||
t.Helper()
|
||||
if args == nil {
|
||||
return
|
||||
}
|
||||
for k, v := range args {
|
||||
args[k] = substVal(t, v, tmpRoot, wRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func substVal(t *testing.T, v interface{}, tmpRoot, wRoot string) interface{} {
|
||||
t.Helper()
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return lifeSubst(x, tmpRoot, wRoot)
|
||||
case map[string]interface{}:
|
||||
for k, vv := range x {
|
||||
x[k] = substVal(t, vv, tmpRoot, wRoot)
|
||||
}
|
||||
return x
|
||||
case []interface{}:
|
||||
for i, vv := range x {
|
||||
x[i] = substVal(t, vv, tmpRoot, wRoot)
|
||||
}
|
||||
return x
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// preparedFromRow — rebuild the record from a frozen asdict row.
|
||||
func preparedFromRow(t *testing.T, row map[string]interface{}, tmpRoot, wRoot string) PreparedTool {
|
||||
t.Helper()
|
||||
get := func(k string) interface{} {
|
||||
if v, has := row[k]; has {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
str := func(k string) string {
|
||||
if s, ok := get(k).(string); ok {
|
||||
return lifeSubst(s, tmpRoot, wRoot)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
fnum := func(k string) float64 {
|
||||
switch n := get(k).(type) {
|
||||
case float64:
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
args, _ := get("arguments").(map[string]interface{})
|
||||
substArgs(t, args, tmpRoot, wRoot)
|
||||
return PreparedTool{
|
||||
Token: str("token"),
|
||||
Name: str("name"),
|
||||
Arguments: args,
|
||||
OperationDigest: str("operation_digest"),
|
||||
CreatedAt: fnum("created_at"),
|
||||
ExpiresAt: fnum("expires_at"),
|
||||
RequestedPath: str("requested_path"),
|
||||
ResolvedPath: str("resolved_path"),
|
||||
PathDigest: str("path_digest"),
|
||||
}
|
||||
}
|
||||
|
||||
// capabilityMatchesFromRaw — rebuild the broker stub from the frozen row,
|
||||
// preserving the parameters PROPERTY ORDER via rawKeyOrder (Python's
|
||||
// ", ".join(properties) is dict-insertion order; Go maps lose it).
|
||||
func capabilityMatchesFromRaw(t *testing.T, raw json.RawMessage) []CapabilityMatch {
|
||||
t.Helper()
|
||||
var rows []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Risk string `json:"risk"`
|
||||
Parameters struct {
|
||||
Properties json.RawMessage `json:"properties"`
|
||||
} `json:"parameters"`
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &rows); err != nil {
|
||||
t.Fatalf("stub matches unparseable: %v", err)
|
||||
}
|
||||
var caps []interface{}
|
||||
_ = json.Unmarshal(raw, &caps)
|
||||
out := make([]CapabilityMatch, 0, len(rows))
|
||||
for i, r := range rows {
|
||||
out = append(out, CapabilityMatch{
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
Risk: r.Risk,
|
||||
ArgNames: rawKeyOrder(r.Parameters.Properties),
|
||||
Cap: caps[i],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestLifecycleDivergenceSentinels — pins the documented Go-side
|
||||
// divergences so they are visible and deliberate (not silent drift):
|
||||
// sibling-gap handler arms return the notPorted envelope, and the default
|
||||
// broker seam lands in the degrade-not-crash envelope.
|
||||
func TestLifecycleDivergenceSentinels(t *testing.T) {
|
||||
for _, name := range []string{"edit_file", "glob_files", "repo_map", "run_command",
|
||||
"read_persona", "query_memory", "check_growth", "diagnose_behavior"} {
|
||||
got := ExecuteToolNow(name, map[string]interface{}{})
|
||||
if got["error"] != "kernel not ported: "+name || got["success"] != false {
|
||||
t.Errorf("%s: notPorted sentinel drifted: %v", name, got)
|
||||
}
|
||||
}
|
||||
got := FindCapability(map[string]interface{}{"query": "anything"})
|
||||
if got["success"] != false || !strings.HasPrefix(got["error"].(string), "find_capability failed: ") {
|
||||
t.Errorf("default broker seam envelope drifted: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLifecycleParityDriver — env-gated driver for the LIVE parity sweep
|
||||
// (scripts/check_tool_executor_lifecycle_parity.py). Reads LIFECYCLE_CASES
|
||||
// (JSON case list with ABSOLUTE fixture paths — no placeholders; the
|
||||
// script created per-side roots), executes each case through the family
|
||||
// kernels with the frozen normalizations, writes LIFECYCLE_OUT.
|
||||
func TestLifecycleParityDriver(t *testing.T) {
|
||||
casePath := os.Getenv("LIFECYCLE_CASES")
|
||||
outPath := os.Getenv("LIFECYCLE_OUT")
|
||||
if casePath == "" || outPath == "" {
|
||||
t.Skip("parity driver: LIFECYCLE_CASES/LIFECYCLE_OUT not set")
|
||||
}
|
||||
raw, err := os.ReadFile(casePath)
|
||||
if err != nil {
|
||||
t.Fatalf("cases: %v", err)
|
||||
}
|
||||
var rows []lifeRow
|
||||
if err := json.Unmarshal(raw, &rows); err != nil {
|
||||
t.Fatalf("cases unparseable: %v", err)
|
||||
}
|
||||
results := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
var res map[string]interface{}
|
||||
switch row.Op {
|
||||
case "exec_now":
|
||||
var args map[string]interface{}
|
||||
_ = json.Unmarshal(row.Args, &args)
|
||||
res = normalizeEnvelope(ExecuteToolNow(row.Tool, args))
|
||||
case "serialize":
|
||||
res = SerializePreparedTool(preparedFromRow(t, row.Pending, "", ""))
|
||||
case "deserialize":
|
||||
var value interface{}
|
||||
if len(row.Value) > 0 {
|
||||
_ = json.Unmarshal(row.Value, &value)
|
||||
}
|
||||
pt, derr := DeserializePreparedTool(value)
|
||||
if derr != nil {
|
||||
res = map[string]interface{}{"raises": derr.Error()}
|
||||
} else {
|
||||
res = SerializePreparedTool(pt)
|
||||
}
|
||||
case "confirm_write":
|
||||
res = normalizeEnvelope(ConfirmWrite(row.Path, row.Content))
|
||||
case "execute_tool":
|
||||
var args map[string]interface{}
|
||||
if string(row.Args) != "null" && len(row.Args) > 0 {
|
||||
_ = json.Unmarshal(row.Args, &args)
|
||||
}
|
||||
ttl := DefaultApprovalTTL
|
||||
if row.TTL != nil {
|
||||
ttl = *row.TTL
|
||||
}
|
||||
now, nowSet := 0.0, false
|
||||
if row.Now != nil {
|
||||
now, nowSet = *row.Now, true
|
||||
}
|
||||
env := PrepareTool(row.Tool, args, ttl, now, nowSet)
|
||||
p, hasPending := env["pending"]
|
||||
if hasPending {
|
||||
if pt, isPT := p.(PreparedTool); isPT {
|
||||
ser := SerializePreparedTool(pt)
|
||||
ser["token"] = "{{TOKEN}}"
|
||||
if got := CanonicalDigest(pt.Name, pt.Arguments); got != pt.OperationDigest {
|
||||
t.Errorf("digest invariant broken for %s", row.Name)
|
||||
}
|
||||
ser["operation_digest"] = "{{DIGEST}}"
|
||||
env["pending"] = ser
|
||||
}
|
||||
}
|
||||
res = normalizeEnvelope(env)
|
||||
case "find_cap":
|
||||
var args map[string]interface{}
|
||||
_ = json.Unmarshal(row.Args, &args)
|
||||
matches := capabilityMatchesFromRaw(t, row.Matches)
|
||||
old := findCapabilityBroker
|
||||
findCapabilityBroker = func(q string) ([]CapabilityMatch, error) {
|
||||
return matches, nil
|
||||
}
|
||||
res = normalizeEnvelope(FindCapability(args))
|
||||
findCapabilityBroker = old
|
||||
}
|
||||
results = append(results, map[string]interface{}{"name": row.Name, "op": row.Op, "out": res})
|
||||
}
|
||||
out, err := json.MarshalIndent(results, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(outPath, out, 0o644); err != nil {
|
||||
t.Fatalf("out: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
# tool_executor — PREPARE/APPROVE/SERIALIZE family gap-close (factory-heavy-te-life)
|
||||
|
||||
Module: tool_executor (GO corpus, src/go/tool-executor). Wave #2479.
|
||||
Family: resolve_path, is_path_allowed, validate_read_path, validate_write_path,
|
||||
_backup_file, undo_last, generate_diff, _execute_tool_now, _canonical_digest,
|
||||
_path_digest, _prepared_path, prepare_tool, _consume_prepared, approve_tool,
|
||||
reject_tool, serialize_prepared_tool, deserialize_prepared_tool, execute_tool,
|
||||
confirm_write, _find_capability, build_tool_prompt, parse_tool_call,
|
||||
format_tool_result, main.
|
||||
|
||||
## Coverage delta
|
||||
|
||||
Audit fn-level BEFORE: 33/47 matched (missing 14 incl. family gaps
|
||||
_execute_tool_now, serialize_prepared_tool, deserialize_prepared_tool,
|
||||
execute_tool, confirm_write, _find_capability — the honest-audit summary row
|
||||
displayed 12; the full missing list carried these 6).
|
||||
AFTER this lane: +6 family fns name-preserving → 39/47 = 83% ≥ 80% FULL.
|
||||
Remaining 8 missing fns are sibling families (_run_command, _edit_file,
|
||||
_glob_files, _repo_map, _repo_map_fallback, _read_persona, _query_memory,
|
||||
_resolve_execution_cwd) — their lanes land separately.
|
||||
|
||||
## Deliverables
|
||||
|
||||
- src/go/tool-executor/gaps_lifecycle.go — ExecuteToolNow (full 20-arm
|
||||
dispatch incl. unknown-tool refusal + str(e)-law TypeError envelopes),
|
||||
SerializePreparedTool (9-field asdict shape, nil optionals),
|
||||
DeserializePreparedTool (exact validation ladder, 6 error messages),
|
||||
ExecuteTool/PrepareTool (reads-execute + approval preview + digests +
|
||||
now-pinning), ConfirmWrite, FindCapability (broker seam, pyRepr,
|
||||
ordered-args rendering), pyListRepr, argInt crash classes.
|
||||
- src/go/tool-executor/truth_lifecycle.json — 52-row frozen oracle truth
|
||||
(exec_now 19, deserialize 15, execute_tool 8, confirm_write 4, find_cap 4,
|
||||
serialize 2); placeholders {{TMP}}/{{WROOT}}/{{HOME}}/{{TOKEN}}/{{DIGEST}}.
|
||||
- src/go/tool-executor/truth_lifecycle_test.go — battery (TestLifecycleTruth
|
||||
52/52 subtests), divergence sentinels, env-gated parity driver.
|
||||
- scripts/gen_tool_executor_lifecycle_truth.py — hermetic generator (live
|
||||
oracle; byte-identical re-runs; no embedded timestamps).
|
||||
- scripts/check_tool_executor_lifecycle_parity.py — live parity, per-side
|
||||
isolated fixture roots, 49/49.
|
||||
- src/go/tool-executor/paths.go — pyListRepr fix: the write-denial message
|
||||
embeds str(list) bytes `['/home/u', '/mnt/workdrive']` (%v [a b] was not
|
||||
the oracle).
|
||||
- src/go/tool-executor/fileops.go — ReadFile offset parity fix: Python
|
||||
numbers with the RAW offset (offset=0 → "0: line 1", footer
|
||||
"[showing lines 0-2 ...]"); the old offset<1 clamp rewrote both.
|
||||
|
||||
## Verification evidence (all paths under verification/)
|
||||
|
||||
| Gate | Evidence | Result |
|
||||
|---|---|---|
|
||||
| Go build + gofmt + vet + go test -count=1 | te-life-goladder.log | exit 0 |
|
||||
| Frozen truth battery | te-life-goladder.log (TestLifecycleTruth) | 52/52 green |
|
||||
| Truth regen byte-identical ×2 | te-life-freeze-regen.log | BYTE_IDENTICAL run1+run2 |
|
||||
| Live parity (oracle live vs Go driver) | te-life-parity.log | 49/49, exit 0 |
|
||||
| Python suite, delegation-live | te-life-pytest-live.log | 122 passed, exit 0 |
|
||||
| Python suite, RUNE_TOOL_EXECUTOR_CORE=python | te-life-pytest-pinned.log | 122 passed, exit 0 |
|
||||
|
||||
RUNE_TOOL_EXECUTOR_CORE is a vacuous pin (no loader seam in this module —
|
||||
tiered_executor precedent); both modes run identical Python.
|
||||
CS_RUST_LIB=/mnt/workdrive/rune/src/rust/capability_scoring/target/release/libcapability_scoring.so
|
||||
required for test_p1_01 (capability_scoring kernel law).
|
||||
|
||||
## Documented divergences (not bugs — oracle stays contract)
|
||||
|
||||
1. Sibling-gap dispatch arms (edit_file, glob_files, repo_map, run_command,
|
||||
read_persona, query_memory, check_growth, diagnose_behavior): Go returns
|
||||
{"success": false, "error": "kernel not ported: <name>"}; the oracle
|
||||
returns real handler output. Pinned by TestLifecycleDivergenceSentinels;
|
||||
flips to kernels when sibling family lanes land.
|
||||
2. execute_tool/prepare edit_file preview: same class (preview arm is
|
||||
edit_file's kernel).
|
||||
3. Bool offset (Python int subclass) renders the literal "True" inside the
|
||||
read footer f-string; Go renders 1. Row excluded; class documented.
|
||||
4. Non-string requested_path/resolved_path/path_digest pass through the
|
||||
UNVALIDATED Python dataclass; the Go record types them string and
|
||||
rejects/blank-coerces at the parse boundary. Rows use str|None only.
|
||||
5. deserialize constructor-TypeError branch unreachable in Go (typed
|
||||
struct construction cannot fail post-validation).
|
||||
6. operation_digest frozen as {{DIGEST}}: the digest binds the per-run
|
||||
random fixture root; battery asserts the structural invariant
|
||||
(CanonicalDigest(name, arguments) == pending digest) on both sides.
|
||||
7. grep_files hit formatting: the oracle's _grep_files shells `grep -r -n -E`
|
||||
(abs:line); the package's GrepFiles kernel is the _search_files shape
|
||||
(rel:line: text). Frozen rows cover the agreeing branches
|
||||
(pattern-required, access-denied, "(no matches)"). Pre-existing module
|
||||
state (name-level match per audit), flagged for the grep-kernel owner.
|
||||
8. memory_store._py_norm_key RecursionError in tests/test_property_invariants.py:
|
||||
PRE-EXISTING in HEAD (verified without any lane file loaded); excluded
|
||||
from this lane's Python run with evidence in te-life-pytest-live.log
|
||||
(first run) and the RECURSION_IN_HEAD probe.
|
||||
|
||||
## Oracle quirks applied
|
||||
|
||||
- str(e) strips exception type names → "Read failed: unsupported operand..."
|
||||
envelopes carry NO "TypeError:" prefix (the dispatch-level except keeps
|
||||
the type name — both shapes implemented).
|
||||
- JSON ints arrive as float64: integral floats pass as ints; fractional
|
||||
floats raise the slice-indices TypeError (parity with json.loads int vs
|
||||
float distinction).
|
||||
- args.get(key, default): PRESENT-but-null ≠ absent (null offset crashes).
|
||||
- bool is an int subclass: True → 1 in arithmetic (rendering excluded, see 3).
|
||||
- Python str(list) formatting for ALLOWED_WRITE_DIRS (pyListRepr).
|
||||
- Python repr() quoting for the no-capability-matches message (pyRepr).
|
||||
Reference in new issue
Block a user