1585 lines
66 KiB
Python
1585 lines
66 KiB
Python
#!/usr/bin/env python3
|
|
"""vmd - the privileged side of the VM sandbox.
|
|
|
|
WHAT IT IS FOR
|
|
|
|
Everything that must not be reachable from the agent lives on this side
|
|
of a unix socket: /dev/kvm, the frozen base images, the ZFS root, the
|
|
Gitea token, and the ability to unfreeze a disk. The agent gets a fixed
|
|
list of verbs. It cannot unfreeze a base image because there is no verb
|
|
for it and no socket it can see that has one - that is a filesystem
|
|
permission and a missing code path, not a policy check it could argue
|
|
with.
|
|
|
|
WHAT IT IS NOT
|
|
|
|
Not a shell. Every verb builds its own command from constants; the one
|
|
caller-supplied value that reaches QEMU is a key name, checked against
|
|
QEMU's own enum first. There is deliberately no verb that takes a QMP
|
|
command, a host path, or a network setting.
|
|
|
|
WHO TALKS TO IT
|
|
|
|
Only owuitools-vmagent, over /run/owuivm/agent/agent.sock, which is mode
|
|
0660 and group owuitools-agent (gid 10001 - the container's own uid,
|
|
because this host runs no userns remapping). The maintenance path that
|
|
CAN unfreeze a disk is a different unit running as a different user,
|
|
reached a different way, and is not served here at all.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import socket
|
|
import socketserver
|
|
import struct
|
|
import sys
|
|
import threading
|
|
import time
|
|
import traceback
|
|
from typing import Any, Callable
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
import gitea
|
|
import keymap
|
|
import project
|
|
import sessionlog
|
|
import session as session_mod
|
|
import tasks as tasks_mod
|
|
import verify as verify_mod
|
|
import browser
|
|
import websearch
|
|
from guest import GuestError, GuestTimeout
|
|
from qmp import QmpError
|
|
|
|
SOCKET_DIR = "/run/owuivm/agent"
|
|
SOCKET_PATH = os.path.join(SOCKET_DIR, "agent.sock")
|
|
SOCKET_GROUP = "owuitools-agent"
|
|
|
|
# A command's output is capped on the way out of here as well as inside
|
|
# the guest agent. The agent trims again for its own context; this bound
|
|
# is about what vmd will hold and hand over, not about readability.
|
|
MAX_OUTPUT_CHARS = 200_000
|
|
MAX_FILE_BYTES = 2_000_000
|
|
# One write is a section, not a codebase. See v_write_file for why this is
|
|
# far smaller than the file limit - it is the forcing function, not a
|
|
# resource limit, and append_file/edit_file are the way past it.
|
|
MAX_WRITE_BYTES = 16_000
|
|
|
|
|
|
class VerbError(RuntimeError):
|
|
"""An error phrased for the agent - and through it, the model - to read."""
|
|
|
|
|
|
class State:
|
|
"""One session's worth of everything. Guarded by a single lock.
|
|
|
|
There is exactly one because there is exactly one VM; the lock is
|
|
what makes two concurrent requests from the agent safe rather than a
|
|
source of half-started machines.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.lock = threading.RLock()
|
|
self.session: session_mod.Session | None = None
|
|
# False until the machine has finished booting. Every verb that
|
|
# touches the guest checks it, so "the agent has no access until
|
|
# it is up" is enforced here rather than only by which tools the
|
|
# loop happens to offer.
|
|
self.ready: bool = False
|
|
self.tasks = tasks_mod.TaskList()
|
|
self.transcript: list[dict[str, Any]] = []
|
|
self.files_created: set[str] = set()
|
|
self.question: str | None = None
|
|
self.unfreeze_request: str | None = None
|
|
self.finished: dict[str, Any] | None = None
|
|
self.owner: str = "" # the OWUI user this session is for
|
|
self.job_text: str = "" # the job, kept to label the repository
|
|
self.commits: list[str] = []
|
|
self.gitea_url: str = ""
|
|
self.releases_url: str = ""
|
|
self.release_error: str = ""
|
|
self.publish_error: str = ""
|
|
# What the session WAS, kept after it is gone. The report is
|
|
# assembled after session_close - it has to be, or the Gitea link
|
|
# the push produced would not be in it - and by then the live
|
|
# session object is None. These three fields are all the report
|
|
# needs from it.
|
|
self.was: dict[str, Any] = {}
|
|
# The durable record of this session, written as it happens.
|
|
# See sessionlog.py: everything below was already being recorded
|
|
# and thrown away at the end.
|
|
self.log: Any = None
|
|
|
|
def reset(self) -> None:
|
|
if self.log is not None:
|
|
self.log.close(outcome="reset")
|
|
self.log = None
|
|
self.session = None
|
|
self.ready = False
|
|
self.desktop_ready = False
|
|
self.tasks = tasks_mod.TaskList()
|
|
self.transcript = []
|
|
self.files_created = set()
|
|
self.question = None
|
|
self.unfreeze_request = None
|
|
self.finished = None
|
|
self.owner = ""
|
|
self.job_text = ""
|
|
self.commits = []
|
|
self.gitea_url = ""
|
|
self.releases_url = ""
|
|
self.release_error = ""
|
|
self.publish_error = ""
|
|
self.was = {}
|
|
|
|
def require_session(self) -> session_mod.Session:
|
|
if self.session is None:
|
|
raise VerbError("there is no machine running. The session has not started, or it has already ended.")
|
|
return self.session
|
|
|
|
|
|
STATE = State()
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# helpers
|
|
# ---------------------------------------------------------------------
|
|
def _png_size(data: bytes) -> tuple[int, int]:
|
|
"""Width and height straight out of the IHDR chunk.
|
|
|
|
Cheaper and more honest than asking QEMU: this is the size of the
|
|
image the agent is actually looking at, which is what its click
|
|
coordinates have to be scaled against.
|
|
"""
|
|
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
|
|
raise VerbError("the screenshot QEMU produced is not a PNG")
|
|
width, height = struct.unpack(">II", data[16:24])
|
|
return int(width), int(height)
|
|
|
|
|
|
def _guest_path(relative: str) -> str:
|
|
"""Confine a path to the project share inside the guest.
|
|
|
|
The guest's own filesystem is disposable, so this is not about
|
|
protecting the VM - it is about the agent not wandering off and
|
|
writing its work somewhere that is wiped at the end of the session
|
|
without anybody noticing.
|
|
"""
|
|
relative = (relative or "").strip()
|
|
if not relative:
|
|
raise VerbError("no path was given.")
|
|
if relative.startswith("/"):
|
|
raise VerbError(
|
|
f"'{relative}' is an absolute path. Give a path relative to the "
|
|
"project folder - that is the only place whose contents survive "
|
|
"the end of this session."
|
|
)
|
|
if ".." in relative.split("/"):
|
|
raise VerbError("a path cannot contain '..'.")
|
|
if "\x00" in relative or "\n" in relative:
|
|
raise VerbError("that path contains characters a filename cannot have.")
|
|
sess = STATE.session
|
|
if sess is not None and sess.os_name == "windows":
|
|
# virtiofs appears as a drive letter on Windows, mounted by the
|
|
# image's own VirtIO-FS service. Backslashes because the thing
|
|
# that reads this path is cmd or PowerShell.
|
|
return "Z:\\" + relative.lstrip("./").replace("/", "\\")
|
|
return "/mnt/project/" + relative.lstrip("./")
|
|
|
|
|
|
def _record(kind: str, **fields: Any) -> None:
|
|
STATE.transcript.append({"t": round(time.time(), 3), "kind": kind, **fields})
|
|
# And to disk, in order, as it happens. The in-memory copy dies with
|
|
# the session; this is the one somebody can read afterwards.
|
|
if STATE.log is not None:
|
|
STATE.log.write(kind, **fields)
|
|
|
|
|
|
def _trim(text: str) -> tuple[str, bool]:
|
|
if len(text) <= MAX_OUTPUT_CHARS:
|
|
return text, False
|
|
return text[:MAX_OUTPUT_CHARS], True
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# orchestration verbs - used by the agent container's harness, not
|
|
# exposed to the model as tools
|
|
# ---------------------------------------------------------------------
|
|
def v_session_open(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
# A SESSION RECORD CAN OUTLIVE THE MACHINE, AND IT DID.
|
|
#
|
|
# Measured: the agent container was rebuilt mid-session, so the
|
|
# loop that would have called session_close died with it. The
|
|
# unit was reaped, `systemctl is-active` said inactive - and vmd
|
|
# still held the record, so every later session was refused with
|
|
# "a session is already running" until the daemon was restarted.
|
|
# One interrupted session would have taken the subsystem out.
|
|
#
|
|
# So the question is not whether a record exists, it is whether
|
|
# the machine does. A record with no machine is cleared, said
|
|
# plainly in the log, and the caller gets their session.
|
|
if STATE.session is not None and not STATE.session.alive():
|
|
stale = STATE.session.id
|
|
# ITS WORK IS STILL ON THE ARRAY, AND NOBODY ELSE WILL PUBLISH IT.
|
|
#
|
|
# A session whose machine died before its loop could call
|
|
# session_close - the agent container rebuilt underneath it,
|
|
# measured - left a 20 KB toolkit and five test scripts
|
|
# uncommitted in `ps-troubleshoot-toolkit`, with no repository
|
|
# and no link. The report that never came would have said
|
|
# "not published"; what the user actually got was a link the
|
|
# model made up. This is the only path that sees the record
|
|
# before it is thrown away, so the backstop lives here.
|
|
# _publish never raises.
|
|
published = _publish(STATE.session)
|
|
if published.get("url"):
|
|
print(f"vmd: published the work of stale session {stale} to "
|
|
f"{published['url']}", file=sys.stderr)
|
|
try:
|
|
session_mod.destroy(STATE.session)
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f"vmd: could not tidy stale session {stale}: {exc}", file=sys.stderr)
|
|
STATE.session = None
|
|
STATE.reset()
|
|
print(f"vmd: cleared stale session {stale} - its machine was already gone",
|
|
file=sys.stderr)
|
|
if STATE.session is not None:
|
|
raise VerbError("a session is already running. Close it before starting another.")
|
|
os_name = str(args.get("os", "")).strip().lower()
|
|
project_name = args.get("project") or None
|
|
if project_name:
|
|
project.validate(str(project_name))
|
|
STATE.reset()
|
|
STATE.owner = str(args.get("owner", ""))[:200]
|
|
# Kept only to label the repository. Never shown to the guest and
|
|
# never re-sent to a model.
|
|
STATE.job_text = str(args.get("task", ""))[:4000]
|
|
# wait=False by default: the caller has twenty-odd seconds of boot
|
|
# to do something useful with, and the agent spends them writing
|
|
# its task list. Nothing that touches the guest will work until
|
|
# session_ready has returned - see _require_ready.
|
|
sess = session_mod.create(os_name, project_name,
|
|
wait=bool(args.get("wait", False)))
|
|
STATE.session = sess
|
|
STATE.ready = bool(args.get("wait", False))
|
|
# THE LOG OPENS HERE, BEFORE ANYTHING IS DONE TO THE MACHINE.
|
|
#
|
|
# `chat` is carried so a chat in Open WebUI can be matched to the
|
|
# session it started - without it, the only way to tell two runs
|
|
# apart afterwards is by guessing from timestamps, which is how a
|
|
# reader ends up describing the wrong run.
|
|
STATE.log = sessionlog.open_log(
|
|
sess.id, chat=str(args.get("chat_id", ""))[:64],
|
|
os=sess.os_name, project=sess.project_name or "",
|
|
owner=STATE.owner, task=STATE.job_text[:1000])
|
|
if project_name and gitea.configured():
|
|
try:
|
|
gitea.init(sess.share or "", str(project_name))
|
|
except Exception as exc: # noqa: BLE001
|
|
_record("git_init_failed", error=str(exc))
|
|
return {
|
|
"session": sess.id,
|
|
"os": sess.os_name,
|
|
"project": sess.project_name,
|
|
"share": sess.share,
|
|
"storage": project.describe_usage(str(project_name)) if project_name else "",
|
|
}
|
|
|
|
|
|
def v_session_ready(args: dict[str, Any]) -> dict[str, Any]:
|
|
"""Wait for the machine to finish booting, and unlock the guest verbs.
|
|
|
|
Deliberately NOT something the sub-agent can call. The harness calls
|
|
it once, between the planning turn and the working one; the agent is
|
|
simply told the machine is up. An agent that could ask "is it ready
|
|
yet?" would ask, repeatedly, and each of those is a model turn spent
|
|
on nothing.
|
|
"""
|
|
with STATE.lock:
|
|
sess = STATE.require_session()
|
|
if STATE.ready:
|
|
return {"ready": True, "seconds": 0.0, "desktop": True}
|
|
timeout = float(args.get("timeout", session_mod.BOOT_TIMEOUT_S))
|
|
desktop = bool(args.get("desktop", False))
|
|
try:
|
|
result = session_mod.await_ready(sess, timeout, desktop=desktop)
|
|
except session_mod.SessionError as exc:
|
|
# A WARM START THAT FAILS BECOMES A COLD BOOT, NOT A FAILURE.
|
|
#
|
|
# Resuming a saved machine has more ways to go wrong than
|
|
# booting one: the state can be stale, the firmware can
|
|
# disagree, a device can refuse to load. None of those are
|
|
# the caller's problem and none of them should cost them a
|
|
# session - the whole point of the saved machine is that it
|
|
# is an optimisation, and an optimisation that can take the
|
|
# feature down is not one.
|
|
if not sess.warm:
|
|
raise VerbError(str(exc)) from exc
|
|
print(f"vmd: warm start failed ({exc}); booting {sess.os_name} cold",
|
|
file=sys.stderr)
|
|
_record("warm_start_failed", error=str(exc))
|
|
was_project = sess.project_name
|
|
session_mod.destroy(sess)
|
|
STATE.session = None
|
|
try:
|
|
cold = session_mod.create(sess.os_name, was_project,
|
|
wait=False, allow_warm=False)
|
|
except session_mod.SessionError as exc2:
|
|
raise VerbError(str(exc2)) from exc2
|
|
STATE.session = cold
|
|
sess = cold
|
|
try:
|
|
result = session_mod.await_ready(cold, timeout, desktop=desktop)
|
|
except session_mod.SessionError as exc2:
|
|
raise VerbError(str(exc2)) from exc2
|
|
result["note"] = ("the saved machine could not be resumed, so this one was "
|
|
"booted from cold. Nothing about the machine differs.")
|
|
STATE.ready = True
|
|
_record("ready", **result)
|
|
return {"ready": True, **result}
|
|
|
|
|
|
def v_session_close(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
sess = STATE.session
|
|
if sess is None:
|
|
return {"closed": False}
|
|
published = _publish(sess)
|
|
if STATE.log is not None:
|
|
STATE.log.close(
|
|
outcome=("finished" if STATE.finished else
|
|
"asked a question" if STATE.question else "stopped early"),
|
|
verified=(STATE.finished or {}).get("verified"),
|
|
commits=len(STATE.commits), url=STATE.gitea_url or "",
|
|
releases=STATE.releases_url or "")
|
|
STATE.log = None
|
|
STATE.was = {"os": sess.os_name, "project": sess.project_name,
|
|
"share": sess.share, "id": sess.id}
|
|
STATE.publish_error = str(published.get("publish_error", ""))
|
|
session_mod.destroy(sess, graceful=bool(args.get("graceful", False)))
|
|
STATE.session = None
|
|
return {"closed": True, **published}
|
|
|
|
|
|
def _publish(sess) -> dict[str, Any]:
|
|
"""Commit anything uncommitted and push, at the end of every session.
|
|
|
|
The agent is told to commit as it goes, and often will. This is the
|
|
backstop for the session that ran out of steps, hit its deadline, or
|
|
was cancelled - the work is on the array either way, and a user who
|
|
asked for a link should get one rather than a folder they have to go
|
|
and find.
|
|
|
|
Deliberately never raises. A push that fails must not turn a session
|
|
that did its work into a session that reports a failure; it becomes a
|
|
line in the report saying the files are there but unpublished.
|
|
"""
|
|
if sess.share is None or not sess.project_name:
|
|
return {}
|
|
result: dict[str, Any] = {}
|
|
try:
|
|
if gitea.has_changes(sess.share):
|
|
sha = gitea.commit(
|
|
sess.share,
|
|
"Work from a VM session (committed automatically at the end)",
|
|
author_note=STATE.owner,
|
|
)
|
|
if sha:
|
|
STATE.commits.append(sha)
|
|
_record("commit", sha=sha, message="automatic end-of-session commit")
|
|
except Exception as exc: # noqa: BLE001
|
|
_record("commit_failed", error=str(exc))
|
|
result["publish_error"] = f"the final commit failed: {exc}"
|
|
|
|
if not gitea.configured():
|
|
result["publish_error"] = ("no Gitea token is configured, so the work is on the array "
|
|
"but was not published.")
|
|
return result
|
|
# NOT `if not STATE.commits`. That asked whether THIS session
|
|
# committed, and a session that was cancelled, timed out or lost its
|
|
# daemon leaves its work committed by the NEXT session's backstop -
|
|
# or by nothing at all, in which case a previous session's commit sits
|
|
# on the array unpushed forever. Measured: primes.py was committed
|
|
# locally and the Gitea repository did not exist, because no single
|
|
# session had both committed and closed cleanly.
|
|
#
|
|
# The right question is whether there is anything to publish at all.
|
|
if not gitea.has_commits(sess.share):
|
|
return result
|
|
try:
|
|
url = gitea.push(sess.share, sess.project_name,
|
|
gitea.short_description(STATE.job_text, sess.project_name))
|
|
STATE.gitea_url = url
|
|
result["url"] = url
|
|
_record("push", url=url)
|
|
except Exception as exc: # noqa: BLE001
|
|
_record("push_failed", error=str(exc))
|
|
result["publish_error"] = f"the push to Gitea failed: {exc}"
|
|
return result
|
|
|
|
# A RELEASE, BECAUSE A LINK INTO THE REPOSITORY DOES NOT DOWNLOAD.
|
|
#
|
|
# Measured: the raw file URL is served `text/plain` with
|
|
# `Content-Disposition: inline`, so a browser shows the source rather
|
|
# than saving it. Release assets are attachments and download. It is
|
|
# also a record of one run: each session that kept anything leaves a
|
|
# numbered entry holding the files exactly as it left them.
|
|
#
|
|
# Separate from the push and separately survivable. By this point the
|
|
# work is committed and on the server; a release that fails is a
|
|
# missing convenience, and saying so is better than turning a session
|
|
# that did its job into one that reports a failure.
|
|
try:
|
|
STATE.releases_url = gitea.publish_release(
|
|
sess.share, sess.project_name, url,
|
|
gitea.short_description(STATE.job_text, sess.project_name))
|
|
result["releases_url"] = STATE.releases_url
|
|
_record("release", url=STATE.releases_url)
|
|
except Exception as exc: # noqa: BLE001
|
|
_record("release_failed", error=str(exc))
|
|
STATE.release_error = str(exc)
|
|
result["release_error"] = STATE.release_error
|
|
return result
|
|
|
|
|
|
def v_session_status(_args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
sess = STATE.session
|
|
return {
|
|
"running": sess is not None and sess.alive(),
|
|
"session": sess.id if sess else None,
|
|
"cancelled": sess.cancelled() if sess else False,
|
|
"tasks": STATE.tasks.render(),
|
|
"planned": STATE.tasks.planned,
|
|
"ready": STATE.ready,
|
|
"status_line": STATE.tasks.status_line(),
|
|
"question": STATE.question,
|
|
"unfreeze_request": STATE.unfreeze_request,
|
|
"finished": STATE.finished,
|
|
"commands": sum(1 for e in STATE.transcript if e["kind"] == "run"),
|
|
}
|
|
|
|
|
|
def v_session_cancel(_args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
sess = STATE.session
|
|
if sess is None:
|
|
return {"cancelled": False, "detail": "no session"}
|
|
sess.cancel()
|
|
return {"cancelled": True}
|
|
|
|
|
|
def v_answer(args: dict[str, Any]) -> dict[str, Any]:
|
|
"""The operator's reply to ask_operator or request_unfreeze."""
|
|
with STATE.lock:
|
|
STATE.question = None
|
|
STATE.unfreeze_request = None
|
|
return {"delivered": True, "answer": str(args.get("answer", ""))[:4000]}
|
|
|
|
|
|
def v_snapshot(args: dict[str, Any]) -> dict[str, Any]:
|
|
"""Save a booted machine so later sessions resume instead of booting.
|
|
|
|
Operator-facing, not agent-facing: it is not in the sub-agent's tool
|
|
list, it takes minutes, and it is the operator's decision when an
|
|
image is worth saving - which is right after they have changed one.
|
|
"""
|
|
with STATE.lock:
|
|
if STATE.session is not None and STATE.session.alive():
|
|
raise VerbError("a session is running. Close it before saving a machine.")
|
|
os_name = str(args.get("os", "")).strip().lower()
|
|
result = session_mod.snapshot(os_name, float(args.get("settle", 45)))
|
|
_record("snapshot", **result)
|
|
return result
|
|
|
|
|
|
def v_snapshot_status(_args: dict[str, Any]) -> dict[str, Any]:
|
|
"""What saved machines exist, and whether they still match their image."""
|
|
out = {}
|
|
for os_name in session_mod.SUPPORTED_OS:
|
|
warm = session_mod.warm_set(os_name)
|
|
path = os.path.join(session_mod.WARM_ROOT, os_name)
|
|
if warm:
|
|
out[os_name] = {"usable": True, "created": warm["meta"].get("created"),
|
|
"state_gb": round(os.path.getsize(warm["state"]) / 1e9, 2)}
|
|
elif os.path.isdir(path):
|
|
# Say which of the two it is. "Does not match" and "is not
|
|
# finished" call for different actions, and guessing wrong
|
|
# sends somebody to re-freeze an image that was fine.
|
|
manifest = os.path.join(path, "manifest.json")
|
|
if not os.path.isfile(manifest):
|
|
why = "it is incomplete - no manifest, so nothing can vouch for it"
|
|
else:
|
|
why = ("it does not match the image that is there now - the image has "
|
|
"been changed since it was saved. Save it again.")
|
|
out[os_name] = {"usable": False, "why": why}
|
|
else:
|
|
out[os_name] = {"usable": False, "why": "none saved"}
|
|
return out
|
|
|
|
|
|
def v_reap(_args: dict[str, Any]) -> dict[str, Any]:
|
|
return {"reaped": session_mod.reap()}
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# task verbs
|
|
# ---------------------------------------------------------------------
|
|
def v_plan_tasks(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
items = args.get("tasks") or []
|
|
if not isinstance(items, list):
|
|
raise VerbError("tasks must be a list of short descriptions.")
|
|
table = STATE.tasks.plan([str(t) for t in items])
|
|
_record("plan", count=len(STATE.tasks.tasks))
|
|
return {"tasks": table, "status_line": STATE.tasks.status_line()}
|
|
|
|
|
|
def v_update_task(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
table = STATE.tasks.update(
|
|
int(args.get("id", 0)), str(args.get("status", "")), str(args.get("note", "")))
|
|
_record("task", id=args.get("id"), status=args.get("status"))
|
|
return {"tasks": table, "status_line": STATE.tasks.status_line()}
|
|
|
|
|
|
def v_add_task(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
after = args.get("after_id")
|
|
table = STATE.tasks.add(str(args.get("text", "")),
|
|
int(after) if after is not None else None)
|
|
_record("task_added", text=str(args.get("text", ""))[:120])
|
|
return {"tasks": table, "status_line": STATE.tasks.status_line()}
|
|
|
|
|
|
def _require_ready() -> None:
|
|
"""Nothing touches the guest until it has booted.
|
|
|
|
Belt and braces with the loop, which does not even offer these tools
|
|
while the machine is coming up. If the two ever disagree, this is the
|
|
one that decides.
|
|
"""
|
|
# Order matters: "no machine at all" and "the machine is still coming
|
|
# up" are different situations and the wrong message sends the agent
|
|
# off waiting for something that is never going to arrive.
|
|
if STATE.session is None:
|
|
raise VerbError(
|
|
"there is no machine running. The session has not started, or it has already ended."
|
|
)
|
|
if not STATE.ready:
|
|
raise VerbError(
|
|
"the machine is still booting. You will be told when it is ready; "
|
|
"there is nothing to do until then."
|
|
)
|
|
|
|
|
|
def _require_plan() -> None:
|
|
"""The first move is always the task list.
|
|
|
|
Not a style preference: it is the stop condition. An agent with no
|
|
written-down standard is an agent that decides for itself when it has
|
|
done enough, which is the failure the previous attempt was retired
|
|
for. Stated as tool output because that is what this model treats as
|
|
fact.
|
|
"""
|
|
if not STATE.tasks.planned:
|
|
raise VerbError(
|
|
"write the task list first. Call plan_tasks with what you intend "
|
|
"to do, then work through it. Nothing else runs until there is a "
|
|
"list to measure the work against."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# guest verbs
|
|
# ---------------------------------------------------------------------
|
|
# GIT IS NOT RUN IN THE MACHINE, AND THE REASON IS NOT TIDINESS.
|
|
#
|
|
# The project folder is a repository managed on the HOST, by gitea.py,
|
|
# which pins every setting that decides whether code runs (hooks, filters,
|
|
# ext:: URLs) on the command line, commits as the owning uid, and leaves
|
|
# scratch and test scripts out of what gets published. A commit made
|
|
# inside the guest has none of that.
|
|
#
|
|
# It also does not work. The guest sees `.git` owned by another uid, so
|
|
# git refuses with "dubious ownership" and exit 128 unless it is fought
|
|
# with `safe.directory` and `HOME` - and a session did exactly that for
|
|
# a dozen turns at the end of its budget, saying "let me use the
|
|
# git_commit tool" at one point and then going back to raw git anyway.
|
|
# Refused here, in tool output, because that is the channel this model
|
|
# treats as fact.
|
|
#
|
|
# Read-only subcommands are refused too, reluctantly: they hit the same
|
|
# ownership wall, so allowing them would just invite the same dance.
|
|
_GIT_WRITES = frozenset({
|
|
"add", "commit", "push", "pull", "fetch", "clone", "init", "rm", "mv",
|
|
"reset", "checkout", "switch", "restore", "merge", "rebase", "revert",
|
|
"cherry-pick", "clean", "stash", "remote", "config", "apply", "am",
|
|
})
|
|
|
|
|
|
# Where one command ends and the next begins, and the words that can sit
|
|
# in front of a command without being one.
|
|
_SEPARATORS = frozenset({"&&", "||", ";", "|", "&", "\n", "(", ")", "{", "}"})
|
|
_PREFIXES = frozenset({"sudo", "env", "nohup", "time", "command", "exec",
|
|
"then", "do", "else", "export"})
|
|
|
|
|
|
def _git_write_in(command: str) -> str | None:
|
|
"""The mutating git subcommand this shell line would run, if any.
|
|
|
|
A SIGNPOST, NOT A SANDBOX. A guest that insists can still write to
|
|
`.git` directly, and nothing here would stop it; what protects the
|
|
published history is that gitea.py does every host-side commit with
|
|
hooks and filters pinned off, whatever the tree says. This exists to
|
|
catch the agent's honest attempts and point them at the tool that
|
|
works, because those attempts cost a session most of its last hour.
|
|
|
|
`git` has to be in COMMAND position - the start of the line or just
|
|
after a separator. Without that, `echo git commit > notes.txt` reads
|
|
as a commit, and a refusal that fires on a string in an echo is worse
|
|
than no refusal at all.
|
|
"""
|
|
import shlex
|
|
try:
|
|
tokens = shlex.split(command)
|
|
except ValueError:
|
|
tokens = command.split()
|
|
index = 0
|
|
at_command = True
|
|
while index < len(tokens):
|
|
token = tokens[index]
|
|
if token in _SEPARATORS:
|
|
at_command = True
|
|
index += 1
|
|
continue
|
|
if not at_command:
|
|
index += 1
|
|
continue
|
|
if os.path.basename(token) == "git":
|
|
index += 1
|
|
# git's own options come before the subcommand, and a few take
|
|
# a value: `git -c safe.directory=... add` is the exact shape
|
|
# that showed up in the session this was written for.
|
|
while index < len(tokens) and tokens[index].startswith("-"):
|
|
takes_value = tokens[index] in ("-c", "-C", "--git-dir", "--work-tree")
|
|
index += 1
|
|
if takes_value and index < len(tokens):
|
|
index += 1
|
|
if index < len(tokens) and tokens[index] in _GIT_WRITES:
|
|
return tokens[index]
|
|
continue
|
|
# Still in front of the command: `sudo`, `env`, a flag, or the
|
|
# VAR=value that `env A=1 cmd` and a bare assignment both use.
|
|
if (token in _PREFIXES or token.startswith("-")
|
|
or ("=" in token and not token.startswith("="))):
|
|
index += 1
|
|
continue
|
|
at_command = False
|
|
index += 1
|
|
return None
|
|
|
|
|
|
def v_run(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
sess = STATE.require_session()
|
|
script = str(args.get("command", "")).strip()
|
|
if not script:
|
|
raise VerbError("no command was given.")
|
|
offending = _git_write_in(script)
|
|
if offending:
|
|
raise VerbError(
|
|
f"`git {offending}` is not run inside the machine. The project folder is a "
|
|
"repository kept on the host: the guest sees its .git owned by another user, "
|
|
"so git answers with 'dubious ownership' and exit 128 unless you fight it "
|
|
"with safe.directory and HOME. A session spent a dozen turns doing exactly "
|
|
"that and committed nothing.\n\n"
|
|
"Use `git_commit` with a message instead. It runs on the host as the owner, "
|
|
"commits the work, leaves test scripts and scratch out of what gets "
|
|
"published, tells you what it held back, and the push to Gitea happens for "
|
|
"you when the session ends."
|
|
)
|
|
timeout = max(1.0, min(float(args.get("timeout_s", 120)), 900.0))
|
|
try:
|
|
with sess.guest(timeout=30.0) as agent:
|
|
# Every command starts in the project folder - the same
|
|
# place the supervisor re-runs the verification from. They
|
|
# used to differ, and a Windows session wrote its script
|
|
# into C:\Windows\system32, checked it there perfectly
|
|
# well, and then failed verification on a file that
|
|
# existed one directory away. See verify.py.
|
|
result = agent.shell(
|
|
verify_mod.working_directory_prelude(sess) + script,
|
|
timeout_s=timeout)
|
|
except GuestTimeout as exc:
|
|
_record("run", command=script[:500], exit_code=None, timed_out=True)
|
|
raise VerbError(str(exc)) from exc
|
|
except (GuestError, OSError) as exc:
|
|
raise VerbError(f"the machine could not be reached: {exc}") from exc
|
|
|
|
out, out_cut = _trim(result["stdout"])
|
|
err, err_cut = _trim(result["stderr"])
|
|
_record("run", command=script[:500], exit_code=result["exit_code"],
|
|
seconds=result["seconds"])
|
|
return {
|
|
"exit_code": result["exit_code"],
|
|
"stdout": out, "stderr": err,
|
|
"truncated": bool(result["truncated"] or out_cut or err_cut),
|
|
"seconds": result["seconds"],
|
|
}
|
|
|
|
|
|
def _make_parent(sess, agent, path: str) -> None:
|
|
"""Create the directory a file is about to be written into.
|
|
|
|
THE PATH IS SPLIT HERE, NOT IN THE GUEST. Two goes at this failed for
|
|
the same underlying reason - the shell is not the one you think:
|
|
|
|
1. It sent `mkdir -p "$(dirname ...)"` to every machine. On Windows
|
|
that reaches PowerShell, where `mkdir` takes no `-p` and
|
|
`dirname` is not a command.
|
|
2. Rewritten in PowerShell, it used `Split-Path -Parent -LiteralPath`
|
|
- which **5.1 rejects as an ambiguous parameter set**. `$p` came
|
|
back null, `Test-Path` and `New-Item` both bound null and did
|
|
nothing, and because those are NON-TERMINATING errors PowerShell
|
|
still exited 0. Measured: `PARENT=[]`, `EXISTS_AFTER=`, exit 0,
|
|
no directory. A check on the exit code could not see it.
|
|
|
|
vmd built this path in `_guest_path`, so it knows the separator and
|
|
can take the parent itself. Nothing is parsed in the guest, and the
|
|
result is CONFIRMED rather than inferred from a status code.
|
|
"""
|
|
if sess.os_name == "windows":
|
|
parent = path.rsplit("\\", 1)[0]
|
|
if len(parent) <= 2: # "Z:" - the share root always exists
|
|
return
|
|
quoted = parent.replace("'", "''")
|
|
probe = agent.shell(
|
|
"$ErrorActionPreference = 'Stop'; "
|
|
f"New-Item -ItemType Directory -Force -Path '{quoted}' | Out-Null; "
|
|
f"if (Test-Path -LiteralPath '{quoted}') {{ Write-Output 'MADE' }}",
|
|
timeout_s=30)
|
|
else:
|
|
parent = path.rsplit("/", 1)[0]
|
|
if not parent:
|
|
return
|
|
probe = agent.shell(
|
|
f"mkdir -p {_sh(parent)} && test -d {_sh(parent)} && echo MADE", timeout_s=30)
|
|
|
|
# CONFIRMED, NOT ASSUMED. PowerShell exits 0 on a non-terminating
|
|
# error, so "it returned zero" proves nothing; the directory saying it
|
|
# exists is the only evidence worth having.
|
|
if "MADE" not in (probe.get("stdout") or ""):
|
|
detail = (probe.get("stderr") or "").strip()
|
|
raise VerbError(
|
|
f"could not create the folder {parent} for that file: "
|
|
f"{detail[:300] or 'the machine gave no reason'}")
|
|
|
|
|
|
def v_write_file(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
sess = STATE.require_session()
|
|
path = _guest_path(str(args.get("path", "")))
|
|
content = str(args.get("content", ""))
|
|
if len(content.encode()) > MAX_WRITE_BYTES:
|
|
raise VerbError(
|
|
f"that is {len(content.encode()) // 1000} KB, and one write takes at most "
|
|
f"{MAX_WRITE_BYTES // 1000} KB. This is deliberate: a file you have to "
|
|
"produce in a single call is a file you have to design completely before "
|
|
"you can write any of it, and that is where sessions disappear.\n\n"
|
|
"Write the skeleton now - the structure, with the parts named and empty - "
|
|
"then fill it in with append_file, or revise it with edit_file. Check it "
|
|
"parses between sections rather than at the end. If it is generated data "
|
|
"rather than code, produce it with a command in the machine instead."
|
|
)
|
|
try:
|
|
with sess.guest(timeout=60.0) as agent:
|
|
_make_parent(sess, agent, path)
|
|
written = agent.write_file(path, content.encode())
|
|
except (GuestError, OSError) as exc:
|
|
raise VerbError(f"could not write that file: {exc}") from exc
|
|
STATE.files_created.add(path)
|
|
_record("write_file", path=path, bytes=written)
|
|
return {"path": path, "bytes": written}
|
|
|
|
|
|
def v_read_file(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
sess = STATE.require_session()
|
|
path = _guest_path(str(args.get("path", "")))
|
|
offset = max(0, int(args.get("offset", 0)))
|
|
size = max(1, min(int(args.get("size", 40000)), 200_000))
|
|
try:
|
|
with sess.guest(timeout=60.0) as agent:
|
|
data = agent.read_file(path, max_bytes=offset + size)
|
|
except (GuestError, OSError) as exc:
|
|
raise VerbError(f"could not read that file: {exc}") from exc
|
|
window = data[offset:offset + size]
|
|
_record("read_file", path=path, bytes=len(window))
|
|
return {
|
|
"path": path,
|
|
"content": window.decode("utf-8", "replace"),
|
|
"offset": offset,
|
|
"returned": len(window),
|
|
"more": len(data) > offset + len(window),
|
|
}
|
|
|
|
|
|
def v_append_file(args: dict[str, Any]) -> dict[str, Any]:
|
|
"""Add to the end of a file, creating it if it is not there.
|
|
|
|
Building forward, one section per turn, instead of holding the whole
|
|
thing in your head until it can be emitted in one call.
|
|
"""
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
sess = STATE.require_session()
|
|
path = _guest_path(str(args.get("path", "")))
|
|
content = str(args.get("content", ""))
|
|
if len(content.encode()) > MAX_WRITE_BYTES:
|
|
raise VerbError(
|
|
f"that is {len(content.encode()) // 1000} KB and one append takes at most "
|
|
f"{MAX_WRITE_BYTES // 1000} KB. Add it in smaller pieces."
|
|
)
|
|
try:
|
|
with sess.guest(timeout=60.0) as agent:
|
|
_make_parent(sess, agent, path)
|
|
try:
|
|
existing = agent.read_file(path, max_bytes=MAX_FILE_BYTES)
|
|
except (GuestError, OSError):
|
|
existing = b""
|
|
written = agent.write_file(path, existing + content.encode())
|
|
except (GuestError, OSError) as exc:
|
|
raise VerbError(f"could not append to that file: {exc}") from exc
|
|
STATE.files_created.add(path)
|
|
_record("append_file", path=path, bytes=len(content.encode()))
|
|
return {"path": path, "bytes": written, "added": len(content.encode())}
|
|
|
|
|
|
def v_edit_file(args: dict[str, Any]) -> dict[str, Any]:
|
|
"""Replace one exact piece of a file with another.
|
|
|
|
THE TOOL THAT WAS MISSING, AND THE REASON SESSIONS DISAPPEARED.
|
|
|
|
The agent had `write_file` and nothing else, so every change to a file
|
|
meant reproducing the whole file - and a file you must reproduce whole
|
|
is a file you must design whole before you can write any of it.
|
|
Measured: a session spent twenty-two minutes and roughly fifty
|
|
thousand tokens in a single turn, designing an entire application in
|
|
its head, still on task one of twelve, because there was no way to
|
|
emit the first part of it and come back for the rest.
|
|
|
|
It fails LOUDLY, which is the whole point. Text that is not there, or
|
|
that is there more than once, is a mistaken belief about the file, and
|
|
finding out now is far cheaper than a silent half-edit that is
|
|
discovered three sections later.
|
|
"""
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
sess = STATE.require_session()
|
|
path = _guest_path(str(args.get("path", "")))
|
|
old = str(args.get("old", ""))
|
|
new = str(args.get("new", ""))
|
|
every = bool(args.get("replace_all", False))
|
|
if not old:
|
|
raise VerbError("'old' is required: it is the exact text to replace.")
|
|
if old == new:
|
|
raise VerbError("'old' and 'new' are identical, so that would change nothing.")
|
|
try:
|
|
with sess.guest(timeout=60.0) as agent:
|
|
raw = agent.read_file(path, max_bytes=MAX_FILE_BYTES)
|
|
except (GuestError, OSError) as exc:
|
|
raise VerbError(f"could not read that file to edit it: {exc}") from exc
|
|
|
|
text = raw.decode("utf-8", "replace")
|
|
found = text.count(old)
|
|
if found == 0:
|
|
raise VerbError(
|
|
"that text is not in the file. Read it again rather than guessing at the "
|
|
"wording - whitespace and indentation have to match exactly."
|
|
)
|
|
if found > 1 and not every:
|
|
raise VerbError(
|
|
f"that text appears {found} times, so replacing it is ambiguous. Include "
|
|
"enough of the surrounding lines to name one of them, or pass "
|
|
"replace_all if you mean all of them."
|
|
)
|
|
updated = text.replace(old, new) if every else text.replace(old, new, 1)
|
|
try:
|
|
with sess.guest(timeout=60.0) as agent:
|
|
written = agent.write_file(path, updated.encode())
|
|
except (GuestError, OSError) as exc:
|
|
raise VerbError(f"could not write that file back: {exc}") from exc
|
|
STATE.files_created.add(path)
|
|
_record("edit_file", path=path, replaced=found if every else 1)
|
|
return {"path": path, "bytes": written,
|
|
"replaced": found if every else 1,
|
|
"note": "the file was changed; check it still parses"}
|
|
|
|
|
|
def v_list_dir(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
sess = STATE.require_session()
|
|
path = _guest_path(str(args.get("path", ".")) or ".")
|
|
try:
|
|
with sess.guest(timeout=30.0) as agent:
|
|
result = agent.shell(f"ls -la --time-style=long-iso {_sh(path)}", timeout_s=30)
|
|
except (GuestError, OSError) as exc:
|
|
raise VerbError(f"could not list that directory: {exc}") from exc
|
|
_record("list_dir", path=path, exit_code=result["exit_code"])
|
|
return {"path": path, "exit_code": result["exit_code"],
|
|
"listing": _trim(result["stdout"])[0], "stderr": result["stderr"]}
|
|
|
|
|
|
def v_check_internet(_args: dict[str, Any]) -> dict[str, Any]:
|
|
"""Because `ping` is permanently broken here and looks like no internet.
|
|
|
|
net.ipv4.ping_group_range on this host is "1 0", i.e. no group may
|
|
open an unprivileged ICMP socket, so libslirp cannot forward one. The
|
|
guest's `ping` therefore fails every single time while HTTPS works
|
|
perfectly. An agent that tests connectivity the obvious way concludes
|
|
it is offline and spends its budget on the wrong problem, so the fact
|
|
is stated here, in tool output, rather than in a system prompt.
|
|
"""
|
|
with STATE.lock:
|
|
_require_ready()
|
|
sess = STATE.require_session()
|
|
try:
|
|
probe = (
|
|
"curl -sS -o /dev/null -w '%{http_code}' --max-time 15 https://1.1.1.1/ "
|
|
"&& echo && getent hosts archive.ubuntu.com | head -1"
|
|
)
|
|
if sess.os_name == "windows":
|
|
probe = (
|
|
"$r = Invoke-WebRequest -Uri 'https://1.1.1.1/' -UseBasicParsing "
|
|
"-TimeoutSec 15; Write-Output $r.StatusCode; "
|
|
"(Resolve-DnsName -Name microsoft.com -Type A -ErrorAction Stop | "
|
|
"Select-Object -First 1).IPAddress"
|
|
)
|
|
with sess.guest(timeout=40.0) as agent:
|
|
result = agent.shell(probe, timeout_s=40)
|
|
except (GuestError, OSError) as exc:
|
|
raise VerbError(f"the machine could not be reached: {exc}") from exc
|
|
reachable = result["exit_code"] == 0
|
|
_record("check_internet")
|
|
return {
|
|
"internet": reachable,
|
|
"detail": _trim(result["stdout"])[0] or result["stderr"][:400],
|
|
"note": (
|
|
"ping does not work on this machine and never will: the host "
|
|
"does not permit unprivileged ICMP sockets, so libslirp cannot "
|
|
"forward them. Use curl or getent to test connectivity. The LAN "
|
|
"and this host are deliberately unreachable; the internet is not."
|
|
),
|
|
}
|
|
|
|
|
|
def v_web_search(args: dict[str, Any]) -> dict[str, Any]:
|
|
"""The same search the main model uses, run on the host.
|
|
|
|
Not in the machine: SearXNG sits inside another container's network
|
|
namespace behind a default-deny filter, which the sandbox cannot
|
|
reach and should not be able to. Running it here also means the
|
|
agent gets the formatter, the cache and the rate limit that the api
|
|
container already has, rather than a second search client that
|
|
behaves slightly differently.
|
|
|
|
No `_require_ready()`: this needs the host, not the machine, so it
|
|
still answers while the guest is wedged - which is exactly when
|
|
looking something up is most useful.
|
|
"""
|
|
with STATE.lock:
|
|
_require_plan()
|
|
try:
|
|
text = websearch.search(
|
|
str(args.get("query", "")),
|
|
int(args.get("max_results", 6) or 6),
|
|
str(args.get("site", "") or ""),
|
|
str(args.get("time_range", "") or ""),
|
|
)
|
|
except websearch.SearchError as exc:
|
|
raise VerbError(str(exc)) from exc
|
|
_record("web_search", query=str(args.get("query", ""))[:200])
|
|
return {"results": text}
|
|
|
|
|
|
def v_read_page(args: dict[str, Any]) -> dict[str, Any]:
|
|
"""Fetch a page as text, on the host, without involving the machine.
|
|
|
|
Reading a page as text costs one call and a few hundred tokens where
|
|
reading it off the screen costs a screenshot per scroll. `open_page`
|
|
is for when the agent needs to SEE a page - a download button, a
|
|
rendered diagram; this is for when it needs to KNOW what it says.
|
|
"""
|
|
with STATE.lock:
|
|
_require_plan()
|
|
try:
|
|
text = websearch.read(
|
|
str(args.get("url", "")),
|
|
int(args.get("max_chars", 6000) or 6000),
|
|
int(args.get("offset", 0) or 0),
|
|
str(args.get("search", "") or ""),
|
|
)
|
|
except websearch.SearchError as exc:
|
|
raise VerbError(str(exc)) from exc
|
|
_record("read_page", url=str(args.get("url", ""))[:300])
|
|
return {"text": text}
|
|
|
|
|
|
def v_research(args: dict[str, Any]) -> dict[str, Any]:
|
|
"""One of the main model's lookup tools, run on the host.
|
|
|
|
Deliberately one verb with an allowlist rather than eight verbs: the
|
|
list of what a session may look up is then a single readable
|
|
thing, in one place, instead of something to be reconstructed from
|
|
the verb table.
|
|
"""
|
|
with STATE.lock:
|
|
_require_plan()
|
|
tool = str(args.get("tool", ""))
|
|
try:
|
|
text = websearch.research(tool, args.get("args") or {})
|
|
except websearch.SearchError as exc:
|
|
raise VerbError(str(exc)) from exc
|
|
_record("research", tool=tool)
|
|
return {"text": text}
|
|
|
|
|
|
def v_open_page(args: dict[str, Any]) -> dict[str, Any]:
|
|
"""Put a URL on the machine's screen in one call instead of six."""
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
sess = STATE.require_session()
|
|
try:
|
|
name = browser.open_url(sess, str(args.get("url", "")))
|
|
except browser.BrowserError as exc:
|
|
raise VerbError(str(exc)) from exc
|
|
_record("open_page", url=str(args.get("url", ""))[:300], browser=name)
|
|
return {"browser": name, "url": browser.validate(str(args.get("url", "")))}
|
|
|
|
|
|
def _require_desktop() -> None:
|
|
"""Wait for the graphical session, the first time anybody needs it.
|
|
|
|
LAZY, BECAUSE MOST SESSIONS NEVER LOOK AT THE SCREEN.
|
|
|
|
There used to be two kinds of session and only the desktop one waited
|
|
for a desktop at handover. There is one kind now, and making every
|
|
session wait for GNOME to finish painting before it can run a shell
|
|
command would hand the cost of a feature to the jobs that do not use
|
|
it - the opposite of why the split was removed.
|
|
|
|
So the wait happens here, on the first screen verb, and once. A job
|
|
that only ever runs commands never pays it; one that takes a
|
|
screenshot pays it exactly once, at the point where it matters.
|
|
"""
|
|
if STATE.desktop_ready:
|
|
return
|
|
sess = STATE.require_session()
|
|
try:
|
|
session_mod.await_desktop(sess, timeout=300.0)
|
|
except session_mod.SessionError as exc:
|
|
raise VerbError(
|
|
f"the desktop is not up, so there is nothing to look at yet: {exc}"
|
|
) from exc
|
|
STATE.desktop_ready = True
|
|
|
|
|
|
def v_launch_app(args: dict[str, Any]) -> dict[str, Any]:
|
|
"""Start a program on the real desktop, where a screenshot can see it."""
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
_require_desktop()
|
|
sess = STATE.require_session()
|
|
path = _guest_path(str(args.get("path", "")))
|
|
try:
|
|
note = browser.launch_app(sess, path)
|
|
except browser.BrowserError as exc:
|
|
raise VerbError(str(exc)) from exc
|
|
_record("launch_app", path=path)
|
|
return {"path": path, "status": note}
|
|
|
|
|
|
def v_reboot_vm(_args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
sess = STATE.require_session()
|
|
try:
|
|
with sess.guest(timeout=15.0) as agent:
|
|
agent.shutdown("reboot")
|
|
except (GuestError, OSError):
|
|
with sess.qmp() as monitor:
|
|
monitor.reset()
|
|
_record("reboot")
|
|
time.sleep(3.0)
|
|
session_mod._wait_for_guest(sess)
|
|
return {"rebooted": True,
|
|
"note": "the disk survived the reboot, so nothing you had written is lost."}
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# GUI verbs
|
|
# ---------------------------------------------------------------------
|
|
def _screen_size(sess) -> tuple[int, int]:
|
|
data = _screenshot_bytes(sess)
|
|
return _png_size(data)
|
|
|
|
|
|
def _screenshot_bytes(sess) -> bytes:
|
|
path = os.path.join(sess.run_dir, "screen.png")
|
|
try:
|
|
with sess.qmp() as monitor:
|
|
monitor.screenshot(path)
|
|
except (QmpError, OSError) as exc:
|
|
raise VerbError(f"could not take a screenshot: {exc}") from exc
|
|
deadline = time.monotonic() + 5.0
|
|
while time.monotonic() < deadline:
|
|
if os.path.exists(path) and os.path.getsize(path) > 0:
|
|
break
|
|
time.sleep(0.05)
|
|
with open(path, "rb") as handle:
|
|
return handle.read()
|
|
|
|
|
|
def v_screenshot(_args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
_require_desktop()
|
|
sess = STATE.require_session()
|
|
data = _screenshot_bytes(sess)
|
|
width, height = _png_size(data)
|
|
_record("screenshot", width=width, height=height, bytes=len(data))
|
|
return {"png_b64": base64.b64encode(data).decode(),
|
|
"width": width, "height": height}
|
|
|
|
|
|
def v_click(args: dict[str, Any]) -> dict[str, Any]:
|
|
return _pointer(args, "click")
|
|
|
|
|
|
def v_move(args: dict[str, Any]) -> dict[str, Any]:
|
|
return _pointer(args, "move")
|
|
|
|
|
|
def _pointer(args: dict[str, Any], action: str) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
_require_desktop()
|
|
sess = STATE.require_session()
|
|
width, height = _screen_size(sess)
|
|
x, y = int(args.get("x", 0)), int(args.get("y", 0))
|
|
button = str(args.get("button", "left"))
|
|
try:
|
|
with sess.qmp() as monitor:
|
|
if action == "click":
|
|
monitor.click(x, y, width, height, button)
|
|
else:
|
|
monitor.move(x, y, width, height)
|
|
except QmpError as exc:
|
|
raise VerbError(str(exc)) from exc
|
|
_record(action, x=x, y=y)
|
|
return {"x": x, "y": y, "screen": [width, height]}
|
|
|
|
|
|
def v_scroll(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
_require_desktop()
|
|
sess = STATE.require_session()
|
|
width, height = _screen_size(sess)
|
|
try:
|
|
with sess.qmp() as monitor:
|
|
monitor.scroll(int(args.get("x", width // 2)), int(args.get("y", height // 2)),
|
|
width, height, int(args.get("amount", -3)))
|
|
except QmpError as exc:
|
|
raise VerbError(str(exc)) from exc
|
|
_record("scroll", x=int(args.get("x", width // 2)),
|
|
y=int(args.get("y", height // 2)), amount=int(args.get("amount", -3)))
|
|
return {"scrolled": int(args.get("amount", -3))}
|
|
|
|
|
|
def v_key(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
_require_desktop()
|
|
sess = STATE.require_session()
|
|
keys = args.get("keys") or []
|
|
if isinstance(keys, str):
|
|
keys = [k.strip() for k in keys.split("+") if k.strip()]
|
|
try:
|
|
with sess.qmp() as monitor:
|
|
monitor.key([str(k) for k in keys])
|
|
except QmpError as exc:
|
|
raise VerbError(str(exc)) from exc
|
|
_record("key", keys=keys)
|
|
return {"pressed": keys}
|
|
|
|
|
|
def v_type(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
_require_desktop()
|
|
sess = STATE.require_session()
|
|
text = str(args.get("text", ""))
|
|
if len(text) > 2000:
|
|
raise VerbError(
|
|
"that is a lot to type through a keyboard. Write it to a file "
|
|
"with write_file and open the file in the machine instead."
|
|
)
|
|
try:
|
|
chords = keymap.to_chords(text)
|
|
except keymap.KeymapError as exc:
|
|
raise VerbError(str(exc)) from exc
|
|
try:
|
|
with sess.qmp() as monitor:
|
|
for chord in chords:
|
|
monitor.key(chord, hold_ms=25)
|
|
except QmpError as exc:
|
|
raise VerbError(str(exc)) from exc
|
|
_record("type", chars=len(text))
|
|
return {"typed": len(text)}
|
|
|
|
|
|
# Twenty minutes. Long enough for a Windows feature install or a big
|
|
# apt transaction, short enough that a wedged wait cannot eat a session.
|
|
# The agent is NOT told this number up front - it is one more sentence in
|
|
# a tool description that is paid for on every turn, and it only matters
|
|
# in the one case where it is exceeded, where the tool says so.
|
|
MAX_WAIT_SECONDS = 1200.0
|
|
|
|
|
|
def v_wait(args: dict[str, Any]) -> dict[str, Any]:
|
|
"""Sleep, while staying interruptible.
|
|
|
|
A long wait still has to notice a cancellation - a session stopped by
|
|
the user should not sit here for another quarter of an hour - so this
|
|
sleeps in short steps and checks between them rather than blocking.
|
|
"""
|
|
with STATE.lock:
|
|
_require_ready()
|
|
sess = STATE.require_session()
|
|
asked = float(args.get("seconds", 1.0))
|
|
seconds = max(0.1, min(asked, MAX_WAIT_SECONDS))
|
|
|
|
waited = 0.0
|
|
while waited < seconds:
|
|
if sess.cancelled():
|
|
_record("wait", seconds=round(waited, 1), cancelled=True)
|
|
return {"waited": round(waited, 1), "cancelled": True,
|
|
"note": "the session was cancelled while waiting."}
|
|
step = min(2.0, seconds - waited)
|
|
time.sleep(step)
|
|
waited += step
|
|
|
|
_record("wait", seconds=round(waited, 1))
|
|
result: dict[str, Any] = {"waited": round(waited, 1)}
|
|
if asked > MAX_WAIT_SECONDS:
|
|
result["note"] = (
|
|
f"You asked to wait {asked:.0f}s; a single wait is capped at "
|
|
f"{MAX_WAIT_SECONDS:.0f}s ({MAX_WAIT_SECONDS / 60:.0f} minutes), so that is "
|
|
"how long this waited. Check whether the thing you are waiting for has "
|
|
"finished - if it has not, wait again rather than assuming it failed."
|
|
)
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# publishing and finishing
|
|
# ---------------------------------------------------------------------
|
|
def v_git_commit(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
_require_plan()
|
|
_require_ready()
|
|
sess = STATE.require_session()
|
|
if not sess.share:
|
|
raise VerbError("this session has no project folder, so there is nothing to commit.")
|
|
message = str(args.get("message", "")).strip()
|
|
if not message:
|
|
raise VerbError("a commit needs a message saying what changed.")
|
|
# ASKED FOR BEFORE THE COMMIT, BECAUSE THE COMMIT CONSUMES IT.
|
|
# Once the staging has run, the working tree no longer shows what
|
|
# was skipped.
|
|
skipped = gitea.held_back(sess.share)
|
|
try:
|
|
sha = gitea.commit(sess.share, message, author_note=STATE.owner)
|
|
except gitea.GitError as exc:
|
|
raise VerbError(str(exc)) from exc
|
|
if sha:
|
|
STATE.commits.append(sha)
|
|
_record("commit", sha=sha, message=message[:200], held_back=len(skipped))
|
|
detail = "nothing had changed since the last commit" if not sha else ""
|
|
# SAID OUT LOUD, IN TOOL OUTPUT.
|
|
#
|
|
# A filter nobody mentions is indistinguishable from a bug: the
|
|
# agent writes test_thing.ps1, commits, and watches it not turn
|
|
# up in the release. Telling it here is also the only channel
|
|
# this stack has repeatedly measured the model to act on.
|
|
note = ""
|
|
if skipped:
|
|
shown = ", ".join(skipped[:8]) + ("..." if len(skipped) > 8 else "")
|
|
note = (
|
|
f"Not committed, and so not published: {shown}. Scratch and test "
|
|
"scripts stay on the machine's project folder where you can still "
|
|
"read and run them - the release is only what the work actually "
|
|
"produced. If one of those IS the deliverable, rename it to "
|
|
"something that is not a test name and commit again."
|
|
)
|
|
return {"sha": sha, "detail": detail, "held_back": skipped, "note": note}
|
|
|
|
|
|
def v_ask_operator(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
question = str(args.get("question", "")).strip()
|
|
if not question:
|
|
raise VerbError("no question was given.")
|
|
STATE.question = question[:2000]
|
|
_record("question", text=question[:500])
|
|
return {"asked": True}
|
|
|
|
|
|
def v_request_unfreeze(args: dict[str, Any]) -> dict[str, Any]:
|
|
"""The agent can ask. It cannot do, and there is no verb that does.
|
|
|
|
Unfreezing is a different unit, running as a different user, behind a
|
|
polkit check that only an active local session of the operator
|
|
satisfies. This verb records a request; a human is the only thing that
|
|
can act on it.
|
|
"""
|
|
with STATE.lock:
|
|
reason = str(args.get("reason", "")).strip()
|
|
if not reason:
|
|
raise VerbError(
|
|
"say what needs installing or changing in the base image, and why "
|
|
"it cannot be done inside this session."
|
|
)
|
|
STATE.unfreeze_request = reason[:2000]
|
|
_record("unfreeze_request", reason=reason[:500])
|
|
return {
|
|
"requested": True,
|
|
"note": (
|
|
"This is a request, not a change. The base image is unchanged and "
|
|
"will stay that way unless the operator unfreezes it themselves at "
|
|
"the machine. Anything you install in this session works for this "
|
|
"session only."
|
|
),
|
|
}
|
|
|
|
|
|
def v_finish(args: dict[str, Any]) -> dict[str, Any]:
|
|
with STATE.lock:
|
|
sess = STATE.require_session()
|
|
refusal = STATE.tasks.why_not_finished()
|
|
if refusal:
|
|
raise VerbError(refusal)
|
|
if not any(e["kind"] == "run" for e in STATE.transcript):
|
|
raise VerbError(
|
|
"no command has been run in this machine, so there is nothing to "
|
|
"report. Run the work before finishing - a summary of work that "
|
|
"was never attempted is worse than no answer."
|
|
)
|
|
summary = str(args.get("summary", "")).strip()
|
|
# `verification` IS DECLARED AS AN OBJECT AND ARRIVES AS A STRING.
|
|
#
|
|
# Often enough to matter: the schema says {command, expected} and
|
|
# the model sends the command on its own. This used to call .get on
|
|
# it and raise AttributeError out of the verb - so `finish` was
|
|
# refused with an internal error, on every attempt. Measured: a
|
|
# session did all six of its tasks, committed, passed its own
|
|
# check, and then could not close, trying seven times with varying
|
|
# arguments before marking the task blocked and giving up.
|
|
#
|
|
# The same shape of bug was fixed in the live view's renderer; this
|
|
# is the one that mattered, because the display only draws a line
|
|
# and this ends the session.
|
|
check = args.get("verification")
|
|
if isinstance(check, str):
|
|
check = {"command": check}
|
|
elif not isinstance(check, dict):
|
|
check = {}
|
|
command = str(check.get("command", "")).strip()
|
|
expected = str(check.get("expected", "")).strip()
|
|
if not command:
|
|
raise VerbError(
|
|
"finish needs a verification: a single command that demonstrates "
|
|
"the work is right, and what you expect it to print. It will be "
|
|
"run again by the supervisor, and its real exit code is what the "
|
|
"user is shown."
|
|
)
|
|
result = verify_mod.run(sess, command, expected, STATE.files_created)
|
|
STATE.finished = {
|
|
"summary": summary[:8000],
|
|
"verification": result.render(),
|
|
"verified": result.ok,
|
|
}
|
|
_record("finish", verified=result.ok)
|
|
return {"accepted": True, "verified": result.ok, "verification": result.render()}
|
|
|
|
|
|
def v_note(args: dict[str, Any]) -> dict[str, Any]:
|
|
"""What the model said this turn, put in the log beside what it did.
|
|
|
|
Harness-only, like session_ready: it is not in the sub-agent's tool
|
|
list and the model never learns it exists. The loop calls it once per
|
|
turn, so the log reads as an alternating narrative - what it was
|
|
thinking, then what it actually called - which is the difference
|
|
between a list of verbs and an account of a session.
|
|
|
|
The text is the model's VISIBLE reply, never its thinking: the
|
|
thinking is by far the largest thing it produces, it is discarded from
|
|
the context on purpose, and a log nobody can read through is the same
|
|
as no log.
|
|
"""
|
|
with STATE.lock:
|
|
if STATE.log is not None:
|
|
STATE.log.write("note", turn=args.get("turn"),
|
|
text=str(args.get("text", ""))[:sessionlog.NOTE_CHARS])
|
|
return {"noted": True}
|
|
|
|
|
|
def v_report(_args: dict[str, Any]) -> dict[str, Any]:
|
|
"""The tool result, assembled here rather than by the agent.
|
|
|
|
The agent's prose is downstream of guest output, which is downstream
|
|
of whatever a script chose to print. A summary that reaches the main
|
|
model unannotated is a channel from the guest into the model's
|
|
instructions, so the facts come from this side and the agent's words
|
|
are quoted, capped and labelled.
|
|
"""
|
|
import report
|
|
with STATE.lock:
|
|
return {"text": report.render(STATE)}
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# dispatch
|
|
# ---------------------------------------------------------------------
|
|
def _sh(value: str) -> str:
|
|
import shlex
|
|
return shlex.quote(value)
|
|
|
|
|
|
VERBS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
|
|
# orchestration
|
|
"append_file": v_append_file, "edit_file": v_edit_file,
|
|
"launch_app": v_launch_app,
|
|
"session_open": v_session_open, "session_ready": v_session_ready,
|
|
"session_close": v_session_close,
|
|
"session_status": v_session_status, "session_cancel": v_session_cancel,
|
|
"answer": v_answer, "reap": v_reap, "report": v_report, "note": v_note,
|
|
# tasks
|
|
"plan_tasks": v_plan_tasks, "update_task": v_update_task, "add_task": v_add_task,
|
|
# guest
|
|
"run": v_run, "write_file": v_write_file, "read_file": v_read_file,
|
|
"list_dir": v_list_dir, "check_internet": v_check_internet, "reboot_vm": v_reboot_vm,
|
|
# the web, from the host - the machine cannot reach SearXNG and must not
|
|
"web_search": v_web_search, "read_page": v_read_page, "open_page": v_open_page,
|
|
"research": v_research,
|
|
# operator-facing, never offered to the agent
|
|
"snapshot": v_snapshot, "snapshot_status": v_snapshot_status,
|
|
# gui
|
|
"screenshot": v_screenshot, "click": v_click, "move": v_move, "scroll": v_scroll,
|
|
"key": v_key, "type": v_type, "wait": v_wait,
|
|
# publishing
|
|
"git_commit": v_git_commit, "ask_operator": v_ask_operator,
|
|
"request_unfreeze": v_request_unfreeze, "finish": v_finish,
|
|
}
|
|
|
|
|
|
class Handler(socketserver.StreamRequestHandler):
|
|
timeout = 1800
|
|
|
|
def handle(self) -> None:
|
|
for raw in self.rfile:
|
|
line = raw.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
message = json.loads(line)
|
|
verb = str(message.get("verb", ""))
|
|
args = message.get("args") or {}
|
|
handler = VERBS.get(verb)
|
|
if handler is None:
|
|
raise VerbError(f"'{verb}' is not something this can do.")
|
|
reply = {"ok": True, "result": handler(args)}
|
|
except (VerbError, project.ProjectError, tasks_mod.TaskError,
|
|
session_mod.SessionError, gitea.GitError) as exc:
|
|
reply = {"ok": False, "error": str(exc)}
|
|
except Exception as exc: # noqa: BLE001
|
|
# Never let a traceback reach the agent and through it the
|
|
# model: it reads as a broken tool rather than a finding,
|
|
# which is the same reasoning as toolbase.py's catch-all.
|
|
traceback.print_exc(file=sys.stderr)
|
|
reply = {"ok": False,
|
|
"error": f"vmd hit an internal error ({type(exc).__name__}); "
|
|
"it has been logged. Do not retry the same call."}
|
|
self.wfile.write(json.dumps(reply).encode() + b"\n")
|
|
self.wfile.flush()
|
|
|
|
|
|
class Server(socketserver.ThreadingUnixStreamServer):
|
|
daemon_threads = True
|
|
allow_reuse_address = True
|
|
|
|
|
|
def main() -> int:
|
|
import grp
|
|
os.makedirs(SOCKET_DIR, mode=0o2770, exist_ok=True)
|
|
try:
|
|
os.chown(SOCKET_DIR, -1, grp.getgrnam(SOCKET_GROUP).gr_gid)
|
|
os.chmod(SOCKET_DIR, 0o2770)
|
|
except (OSError, KeyError) as exc:
|
|
print(f"vmd: could not set up {SOCKET_DIR}: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
# Anything a previous run left behind, before anybody connects. Same
|
|
# reasoning as octave/run.sh clearing its run directory at start.
|
|
reaped = session_mod.reap(verbose=True)
|
|
if reaped:
|
|
print(f"vmd: reaped {len(reaped)} leftover(s) from a previous run", file=sys.stderr)
|
|
|
|
if os.path.exists(SOCKET_PATH):
|
|
os.unlink(SOCKET_PATH)
|
|
server = Server(SOCKET_PATH, Handler)
|
|
os.chmod(SOCKET_PATH, 0o660)
|
|
try:
|
|
os.chown(SOCKET_PATH, -1, grp.getgrnam(SOCKET_GROUP).gr_gid)
|
|
except (OSError, KeyError):
|
|
pass
|
|
print(f"vmd: listening on {SOCKET_PATH}", file=sys.stderr)
|
|
try:
|
|
server.serve_forever(poll_interval=0.5)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
with STATE.lock:
|
|
if STATE.session is not None:
|
|
session_mod.destroy(STATE.session)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|