Initial Commit

This commit is contained in:
2026-09-15 15:24:03 -04:00
commit 4dc7086e7e
605 changed files with 101380 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
# Base images and installation media
Every session boots a **throwaway overlay** over a frozen base image. Nothing the agent
installs survives. So there has to be a way to put software *into* the base, and it has to
be a way the agent cannot reach.
That is the **Tool Server VM Maintenance** app, in your applications menu and on your
desktop. It starts polkit-gated systemd units and touches nothing itself.
## Frozen means the owner write bit
Not a flag, not a database — `chmod u-w` on the qcow2. Six places test it and they all test
the same bit, so `ls -l` tells you the truth:
```sh
sudo stat -c '%n %a' /var/lib/owuivm/base/*.qcow2
# 444 = frozen
```
Consequences:
- **Frozen + Start** boots a throwaway copy. This is exactly what the agent gets, and you
can rehearse it with no risk of changing anything.
- **Unfrozen + Start** boots the base itself, and changes are kept.
- **Nothing re-freezes automatically.** What catches a forgotten thaw is that vmd refuses to
open a session while a base is writable — the agent stops working rather than quietly
writing to your original.
- Freezing re-records the image's checksum, which **retires any saved fast-start machine**.
The app shows that as *stale*; press *Save fast start* again.
## The quick way: import a prebuilt image
Building takes an afternoon. Downloading a finished one takes a coffee, and for Windows it
is the difference between clicking through an installer in a VNC console and not.
```sh
sudo ./vm/images/import.sh ubuntu # ~9.6 GB
sudo ./vm/images/import.sh windows # ~41 GB
sudo ./vm/images/import.sh both
```
They come from
<https://nextcloud.grantlan.com/s/OWUIToolsVMDisks>, are checksum-verified against a
manifest published beside them, and are installed frozen and owned correctly — which is the
real reason this is a script rather than a list of `curl` commands. Three things have to be
exactly right or it fails much later, in ways that do not point at their cause:
- the owner must be `owuivmadm`, or the maintenance tool cannot thaw it;
- the mode must be `0444`, because **frozen is the owner write bit and nothing else** — a
`0644` base means vmd refuses every session with "freeze it and try again";
- the `.sha256` must land beside the disk, or every saved fast-start machine is treated as
stale and sessions silently boot from cold.
The download **resumes**: the server supports range requests, so `^C` and re-run picks up
where it stopped rather than starting the 41 GB again. It checks you have the space before
it begins, and it refuses to overwrite a base image that is already **frozen** — a
part-downloaded one is writable by definition, which is how it tells the two apart and why
resuming works at all.
Hosting your own is one variable:
```sh
sudo OWUIVM_IMAGE_SOURCE=https://files.example.com/s/TOKEN ./vm/images/import.sh ubuntu
```
Any web server will do — the script only ever does a ranged GET and verifies what it gets.
### What is in them
Ubuntu 26.04 desktop with the guest agent, static networking, a Chromium and Firefox to
drive, and the first-boot noise (cloud-init, unattended upgrades, `wait-online`) turned off.
Windows 11 with virtio drivers, the in-guest broker and an auto-logging-on local
administrator.
Nothing personal is in either: they are installers run to completion and then frozen, with
no accounts, keys or data beyond what the installer creates. The guest password is
documented in `vm/images/ubuntu/user-data` and is not a secret — there is no network path to
either machine.
**On Windows licensing**: the image contains an unactivated Windows 11. Whether you may run
it, and for how long, is between you and Microsoft's terms — an unactivated install works
with cosmetic nags, and an evaluation build expires. If that matters to you, build your own
from media you are licensed for; the manual procedure is below and the result is identical.
### Or build them yourself
Everything below produces the same thing from scratch, and is the right path if you want to
change what is in the image.
## Getting installation media
Two ways, and the app's way is easier.
### From the app
On a machine with no disk yet, press **Insert disc…**, pick an `.iso`, and watch it copy.
It is checked before it is accepted — a download that was really an HTML error page is
rejected there and then, rather than becoming a machine that will not boot for no visible
reason.
The copy happens as **you**, out of your own home directory, because the privileged units
run with `ProtectHome=yes` and could not read `~/Downloads` even if asked. It lands in a
staging directory you own, and the unit only checks it and moves it.
### From the command line
```sh
sudo ./vm/images/fetch.sh ubuntu # downloads, verifies against Canonical's SHA256SUMS
sudo ./vm/images/fetch.sh virtio # the virtio-win drivers, for Windows
sudo ./vm/images/fetch.sh windows /path/to/Win11.iso # you supply this one
```
Media lives in `/var/lib/owuivm/iso`. Which file belongs to which machine is the `iso` glob
in [`../../configs/vms.toml`](../../configs/vms.toml) — `ubuntu*.iso` and `[Ww]in*.iso`.
**It is not `*.iso`, and that matters.** The rule is "newest match wins", so with an Ubuntu
disc sitting next to a Windows one, a `*.iso` pattern is how a Windows install quietly boots
the Ubuntu installer.
## Building Ubuntu
```sh
sudo ./vm/images/ubuntu/build.sh # ~25 minutes, unattended
```
An autoinstall: it repacks the ISO with a seed, boots it headless, installs, adds the guest
agent, sets static networking, then freezes the result and records its checksum.
It **refuses to overwrite an existing base**. Delete the old one first if you mean to
rebuild.
## Building Windows
No unattended path — it is driven by hand through the app.
1. Put `Win11_*.iso` and `virtio-win.iso` in the library.
2. Press **Install from ISO** on the Windows row. The console opens.
3. Click through setup. Load the virtio storage driver from the second CD
(`amd64\w11`) when the installer says it cannot find a disk.
4. When Windows is installed and logged in, press **Shut down**.
5. Press **Installed — remove disc**.
6. Press **Freeze**.
Nothing on this host can work out for itself that an install has finished, and the one time
something tried — treating a disk over 512 MB as done — it declared a half-finished install
complete and left the machine at an EFI shell. So step 5 is a person saying so.
## Making sessions start faster
**Save fast start** boots the image once and saves the running machine to disk. Sessions
then resume from it: about 30 seconds instead of 90–115, and on Windows that includes
PowerShell's cold start.
Requires the image to be frozen and shut down. A saved machine is tied by checksum to the
exact image it came from, so changing the image retires it — which is correct, and is why
the app tells you when it has gone stale rather than silently booting cold forever.
## Renaming a machine
**Rename** changes what it is called in the app and in session reports. The `id` never
changes: it names the disk, the systemd unit instance, the saved-machine directory, and the
value the assistant passes as `os`, and it appears in regex allowlists inside the privileged
launch scripts.
Renames are stored in `/var/lib/owuivm/registry/vm-names.toml` and merged over
`vms.toml`, so the registry keeps its comments. Delete that file to go back to the shipped
names.
+85
View File
@@ -0,0 +1,85 @@
# The VM sub-agent
`vm_agent` hands a task to a **second model** that has a real, throwaway machine in front of
it — Ubuntu or Windows. That model writes the script, runs it, reads the actual error, fixes
it, and comes back with evidence.
The assistant can reason, search and calculate. It cannot *try* anything. Asked whether a
PowerShell script works, it can only read documentation about PowerShell. This is the
missing half.
## What the assistant sees
One tool, and none of the machinery. The sub-agent's own thirty-odd tools, its screenshots
and its transcript never enter the assistant's context — it gets a task in and a report out.
Three outcomes:
- **DONE** — a header of verified facts assembled by the daemon (what ran, exit codes, files
changed, the commit, the supervisor's independent re-run of the agent's own verification
command), plus the agent's summary, clearly delimited and marked untrusted.
- **NEEDS INPUT** — it asked something. Answer and call again.
- **IN PROGRESS** — rare; the session ran past its wall clock.
## Why the report is assembled by the daemon
Everything the guest prints reaches the sub-agent, then the tool result, then the assistant,
then you. A script — or a file left in a shared project folder by an earlier session — can
emit text shaped like an instruction.
So the **facts in the report are produced by the trusted side**: commands run, exit codes,
files changed, commit sha, and the re-run verdict. The agent's prose is quoted, capped, and
labelled as a claim. If the supervisor's re-run of the verification command fails, the
summary is presented as a claim rather than a finding.
## The isolation, in one page
**The machine has real internet and no access to your network.** The primary control is the
systemd unit, not a firewall:
```ini
IPAddressDeny=127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 …
```
Deny-only, with **no `IPAddressAllow`**. systemd checks Allow *first* and grants on a match,
so adding `IPAddressAllow=0.0.0.0/0` to "allow the internet" disables the entire deny list.
A second, independent nftables table carries the same drops.
Networking is QEMU's user-mode stack, so every guest packet is re-originated by the qemu
process as an ordinary socket: no L2 presence, no ARP, no DHCP spoofing, no MAC on a host
interface. QEMU runs unprivileged, with an explicit device list and `/dev/kvm` as its only
device.
**The verification is that each layer works alone.** `tests/vm_host_asserts.sh` deletes the
nftables table and re-runs the whole negative suite, which must still pass, then strips
`IPAddressDeny` from a scratch unit and runs it again. Two independent proofs, neither
trusting the other.
### What it does not prevent
The machine can reach anything you publish to the internet, exactly as any stranger can.
That is a consequence of giving it real internet access, and it is recorded rather than
fixed.
## What survives a session
Nothing on the machine. Every session boots a throwaway overlay over a **frozen** base image
and the overlay is deleted afterwards.
The project folder does survive: a per-project dataset with a quota, shared into the guest,
which is also a git working tree. That is what gets committed and published.
## Where the pieces are
| Path | What |
|---|---|
| `vm/host/` | systemd units, the nftables guard, polkit, the installer |
| `vm/vmd/` | the privileged daemon: sessions, QMP, the guest channel, git, the task list |
| `vm/gui/` | the maintenance app |
| `vm/images/` | fetching media and building base images |
| `vmagent/` | the agent loop — holds no secrets, reaches exactly one address |
| `vmgw/`, `vmguard/` | its one path to Ollama, and the filter that enforces it |
| `configs/agent/` | its prompts, tool definitions and parameters |
Everything privileged is behind one unix socket with a fixed verb list. The agent container
has no Gitea token, no Open WebUI key, and no way to unfreeze a disk.
+72
View File
@@ -0,0 +1,72 @@
# When a VM session goes wrong
## First: read the session log, not the chat
Every session writes its own transcript, and it outlives the machine:
```sh
sudo vmctl logs # every session, newest first
sudo vmctl log # the most recent one, rendered
sudo vmctl log <chat-id> # the session for a conversation
sudo vmctl log <project> # by project name
```
It records every verb with a timestamp, what the model said each turn, and a census at the
end — *"what it actually did: 34× run, 12× screenshot… it never called click"*.
**Two records cannot answer "what did it do", and both look as though they can.** The tool
result used to count only `run`. And Open WebUI's status history is *sampled* every two
seconds, so fast verbs are usually overwritten before the filter sees them — measured, 456
samples caught `launch_app` nine times and not one of the screenshots that demonstrably
happened. Read the log.
## Common symptoms
| Symptom | Cause |
|---|---|
| The call fails after about five minutes | `AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER` is unset on Open WebUI. It defaults to **300**, not to unlimited. See [../advanced-params.md](../advanced-params.md). |
| No live view; the chat sits empty | The `vm_agent_progress` filter is not installed or not enabled, or `ENABLE_FORWARD_USER_INFO_HEADERS` is off. See [../filters.md](../filters.md). |
| "The machine has been destroyed" seconds in | A second `vm_agent` call in one conversation reading the first one's finished state. Fixed; if you see it again the api container is stale — `docker compose up -d --build api`. |
| Sessions refuse to start | A base image is unfrozen. Freeze it in the maintenance app. `sudo stat -c '%n %a' /var/lib/owuivm/base/*.qcow2` — 444 is frozen. |
| The agent says it has no internet | It used `ping`. `ping` can **never** work in the guest — `net.ipv4.ping_group_range` denies the unprivileged ICMP sockets the user-mode network stack needs — while HTTPS works fine. `check_internet` is the tool for this. |
| Git fails with exit 128, "dubious ownership" | The agent tried to run git inside the machine, where `.git` is owned by another uid over the shared filesystem. It should use `git_commit`, which runs on the host. `run` refuses mutating git now and says so. |
| Nothing was published | No Gitea token in `/etc/owuivm/vmd.env`. The session still ran and kept its files. See [../setup/gitea.md](../setup/gitea.md). |
| The published release contains test scripts | It should not — a scratch filter excludes them at commit time, and `git_commit` reports what it held back. If a real deliverable was held back, its name matched a scratch pattern; the list is in `vm/vmd/gitea.py`. |
## Checking the isolation still holds
```sh
sudo ./tests/vm_host_asserts.sh
```
This is the one to run after changing anything in `vm/host/`. It turns each layer off in
turn and re-runs the same probe against the others, so it fails if a control has quietly
started depending on another one.
## Driving a session by hand
Skip the model entirely when you want to know whether the machinery works:
```sh
sudo -u owuivmd vmctl open --os ubuntu --project scratch
sudo -u owuivmd vmctl ready # waits for the guest agent
sudo -u owuivmd vmctl plan "check the machine came up"
sudo -u owuivmd vmctl run 'uname -a'
sudo -u owuivmd vmctl shot /tmp/screen.png
sudo -u owuivmd vmctl close
```
If that works and sessions do not, the problem is in the agent loop, not the sandbox.
## Cleaning up after a crash
Orphans are reaped when the next session starts, but to do it now:
```sh
sudo systemctl stop owuivm.service
sudo ls -la /Pool/vmrun/ # overlays; should be empty between sessions
sudo ls -la /run/owuivm/
```
An overlay left behind is harmless — it is deleted when the next session starts — but it
does hold disk.