144 lines
5.8 KiB
Python
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")
|