Files
owui-tools/tests/test_vm_second_run.py
2026-09-15 15:24:03 -04:00

144 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""A second run in the same chat is watched, and git stays on the host.
python3 tests/test_vm_second_run.py
THE BUG THIS EXISTS FOR
A conversation asked for an app, got it, then asked for a fix. The second
vm_agent call ran for 45 minutes and finished successfully - and the
person watching saw an empty message and a toast saying the machine had
been destroyed, and reasonably concluded the tool had given up.
The live-progress file is named after the CHAT, not the run. The first
run left it with `done: true`. The second run's watcher started, polled
once, read the previous run's ending, announced it and exited FOUR
SECONDS IN. Its whole status history was:
Calling vm_agent
VM · Starting a machine...
VM · Done
The machine has been destroyed.
while the run it was supposed to be watching carried on to sequence 1035.
Two guards, because a race with one guard is a race: the api deletes the
file before the job is queued, and the watcher ignores anything stamped
earlier than itself.
AND THE OTHER THING THAT RUN DID
It spent its last dozen turns fighting `git` inside the machine - exit
128, "dubious ownership", `safe.directory`, `HOME` - having said "let me
use the git_commit tool" at turn 99 and then gone back to raw git. A
commit made in the guest also skips everything gitea.py does on the host:
the hook and filter hardening, the right author, and leaving scratch out
of what gets published. So it is refused, in tool output.
"""
import ast
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, "..")
FAILS: list[str] = []
def check(label: str, condition: bool, detail: str = "") -> None:
if condition:
print(f"ok {label}")
else:
FAILS.append(f"{label}: {detail}")
print(f"FAIL {label}\n {detail}")
def read(*parts: str) -> str:
return open(os.path.join(ROOT, *parts), encoding="utf-8").read()
# --- guard one: the api clears the last run's state before queueing -------
api = read("app", "owuitools", "routers", "vm.py")
submit = api[api.index("async def _submit"):]
check("the api deletes the previous run's progress file",
'os.path.join(JOBS_DIR, "progress"' in submit and "os.unlink(stale)" in submit,
"otherwise the new watcher reads the old run's `done` and stops")
check("and the answer to the previous run's confirmation",
"answer.json" in submit,
"a stale answer would decide a question the person was never asked")
check("it happens before the job is queued, not after",
submit.index("os.unlink(stale)") < submit.index('f"{job_id}.ready"'),
"after the job is queued there is a window for the watcher to read it")
# --- guard two: the watcher ignores anything older than itself ------------
flt = read("owui-functions", "vm_agent_progress.py")
check("the watcher stamps when it started, on the wall clock",
"began_at = time.time()" in flt,
"the loop clock is monotonic and is not comparable with the file's stamp")
check("and discards progress written before then",
'float(data.get("updated") or 0) < began_at' in flt,
"this is what a previous run's file looks like")
check("the file it compares against is stamped on every flush",
'"updated": round(time.time(), 2)' in read("vmagent", "app", "events.py"))
# --- git belongs to the host ---------------------------------------------
vmd = read("vm", "vmd", "vmd.py")
# Lifted out of vmd.py and run, rather than matched as text: the point
# is what it decides, not what it looks like.
tree = ast.parse(vmd)
wanted = ("_GIT_WRITES", "_SEPARATORS", "_PREFIXES", "_git_write_in")
body = []
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in wanted:
body.append(node)
elif isinstance(node, ast.Assign) and any(
isinstance(t, ast.Name) and t.id in wanted for t in node.targets):
body.append(node)
check("the detector and everything it needs was found in vmd.py",
len(body) == len(wanted), f"found {len(body)} of {len(wanted)}")
namespace: dict = {"os": os}
exec(compile(ast.Module(body=body, type_ignores=[]), "vmd", "exec"), namespace)
detect = namespace["_git_write_in"]
# Every one of these is a real command line from the session that failed.
for command, want in [
("cd /mnt/project && git add -A && git status --short", "add"),
("cd /mnt/project && git config --global --add safe.directory /mnt/project "
"&& git add -A && git status --short", "config"),
("cd /mnt/project && export HOME=/root && git -c safe.directory=/mnt/project add -A "
"&& git -c safe.directory=/mnt/project commit -m 'x'", "add"),
("git -C /mnt/project commit -m x", "commit"),
("/usr/bin/git push origin main", "push"),
]:
check(f"refused: {command[:52]}...", detect(command) == want, f"got {detect(command)!r}")
# And the things that must NOT be caught, or the refusal becomes the problem.
for command in [
"git log --oneline -1",
"git status --short",
"python3 -c 'print(\"git add is a string\")'",
"grep -n 'git commit' notes.txt",
"ls -la /mnt/project",
"echo git commit > /tmp/note.txt",
]:
check(f"allowed: {command[:52]}", detect(command) is None, f"got {detect(command)!r}")
run_body = vmd[vmd.index("def v_run("):vmd.index("def v_write_file(")]
check("v_run refuses before it reaches the machine",
"_git_write_in(script)" in run_body and "raise VerbError" in run_body)
check("and the refusal names the tool that does work",
"git_commit" in run_body,
"a refusal that does not say what to do instead is a wall")
check("it explains the exit 128 rather than leaving it to be rediscovered",
"dubious ownership" in run_body)
print()
if FAILS:
print(f"{len(FAILS)} failed")
for line in FAILS:
print(" -", line)
sys.exit(1)
print("a second run is watched, and git stays where it works")