#!/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())