Skip to content
Merged
1 change: 1 addition & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ S15/S16 target terms below refine product intent. Definitions explicitly marked
**Shared and private computers (S16):** With explicit S16 computers (ADR 0087), each work owns its own workspace lease on a working VM, so different works run concurrently on the shared computer. A *private computer* is a separate working VM the owner gives to exactly one Bot; that Bot's new work uses only it and no other Bot's work is ever placed there. The assignment is permanent: a private computer never becomes shared or another Bot's, and retiring it only stops new work there. New Bots use the shared computer.

**Idle release (S16):** Jit stops a working VM that has had no open session for 30 minutes (owner decision 2026-11-07), keeping its disk, and starts it again under a new generation when work is placed there. A VM the owner stopped is never started by work.
**Screen control (S16, DESK16-01):** Who may write to a desktop working computer's screen: work (`agent`), the owner after every earlier work operation settled (`owner`), or nobody after the owner lost control (`fenced`). Every change advances the screen generation, so frames and input from before it are refused. Frames and input go through QEMU's controller-only QMP socket, never the guest network; typed text is sent once and never stored. Sensitive-site login stays in the protected browser.

**Protected control plane:** The authority/state/credential/effect boundary that working agents, including a guest administrator, cannot alter or impersonate. Physical placement and enforcement are proposed engineering, not established by a sibling process or a prompt.

Expand Down
48 changes: 48 additions & 0 deletions docs/adr/0090-s16-watch-and-take-over-a-working-computer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
status: implemented for DESK16-01 (#410), first slice; deterministic evidence; guest desktop session, desktop-browser egress and live host evidence pending (#410 follow-up, #431)
date: 2026-11-08
scope: DESK16-01 (#410); S16-AC05/AC07; FR-02/10/14/15/18/21/35/36/38/40; NFR-01/06/07/11/12/14
---

# Watch and take over a working computer

## Context

S16-AC07 asks the owner to watch the working screen, take exclusive control, complete a protected-input step and hand control back with fresh generations. Agent input must be fenced throughout, and old frames or handles must never regain authority. Sensitive-site credentials stay in the protected browser (S15 BROW15, ADR 0079); trusted sites may keep their session in the working VM browser (S16-D09, ADR 0084). #442 still waits for "a trusted site works in the VM browser after one owner login", which needs this takeover.

The S15/S16 working VM had no display: QEMU ran with `-display none` and no display device, and work used only shell and file tools over SSH. The protected browser already has an owner takeover (`BrowserOwner.takeover/frame/input/handBack`, field-based, generation-checked) on the S15 Web pages.

## Options compared

1. **Rakazo (pinned `abf3c08`)**: the computer image runs Xvfb, fluxbox, Chromium, `x11vnc -viewonly` and websockify/noVNC inside the sandbox; the API seals a screen capability (Bot, computer, screen generation, control lease) and the web client embeds noVNC; human control is a lease that interactive VNC needs. The agent runs as an unprivileged user without escalation, so the in-sandbox VNC server is outside its reach.
2. **Guest VNC in the Jit working VM**: in Jit the agent has root in the VM (RT15-01 option B). A guest VNC server, its input path and its fencing would be under the agent's control; noVNC also adds an MPL-2.0 client and a WebSocket proxy.
3. **Host-side screen through QMP (chosen)**: QEMU owns the virtual display. Frames come from QMP `screendump` (PNG) and input goes through QMP `input-send-event` on the controller-only QMP socket that already exists. No display server, port or guest channel is added; fencing is enforced in the controller, before any event reaches QEMU.

Option 3 keeps Rakazo's product concept (watch, take control with a lease, generation-fenced capability, hand back) and is simpler and stronger for a root-controlled guest. Frames are polled (about 2 per second while the owner drives, under 1 per second while watching) instead of streamed; that is enough for a login or a confirmation step and needs no new dependency. Streaming can replace polling later behind the same contract.

## Decision

1. **Desktop profile** (`s15-runtime/v1` `desktop: true`, requires `computers`). Jit-created VMs start with `-device virtio-vga,xres=1280,yres=800` (`virtio-gpu-pci` on aarch64), `-device virtio-tablet-pci` and `-device virtio-keyboard-pci`; `-display none` stays. The headless argv is unchanged byte for byte, and `/proc` re-verification accepts either exact profile, so a VM that was already running headless keeps working and reports `vm_desktop_restart_required` until its next start (idle sleep makes that natural). The VM15-03 launcher accepts an optional sixth `start` word `desktop` and nothing else; an installed older launcher refuses it, so a separate-identity host needs the installer's launcher refresh before desktop starts (owner-host transition, CUT16-01).
2. **Host screen device** (`QemuVmManagement.screen()`, `src/s15/vm/screen.ts`). `unavailable()` reports `vm_desktop_unavailable`, `vm_not_running` or `vm_desktop_restart_required` from controller facts only. `capture()` runs one QMP `screendump` at a time into the VM's hypervisor directory, reads it once and always removes it before the capture settles (under VM15-03 the file belongs to the QEMU identity, so its launcher's `discard-frame <vm>` removes exactly `<vm>/screen.png`; a frame that cannot be removed fails the capture). `stop()` (also the idle stop) and the launcher's `start` remove any frame left behind, so a stopped VM keeps none, and accepts only a bounded PNG whose IHDR is 1280×800. `send()` maps one validated input to QMP events: absolute pointer moves, button press/release pairs, wheel buttons, and key press/release batches with qcodes. Text is limited to characters a US keyboard types; anything else is refused (`screen_text_unsupported`), never approximated.
3. **Exclusive control** (`s16-computer-screen/v1`, `ScreenControl`, protected VM journal tables `s16_screen_control`, `s16_screen_commands`, `s16_screen_events`). States are `agent`, `settling`, `owner` and `fenced`, and every transition (takeover, lease lapse, restart, hand-back) advances the generation:
- Every working-VM operation holds an agent ticket from admission until it settled. `WorkingVmService` checks the ticket wherever it already checks session currency (before the claim, at dispatch, before start, before publish), so after a takeover no work operation is admitted, dispatched or published.
- Takeover moves to `settling` while tickets of the earlier generation are open and to `owner` when the last one ends. Owner input is refused (`takeover_settling`) until then, so work and owner never hold a writable generation together.
- Owner input names the generation and takeover id, is checked against the live row immediately before it is sent, and is serialized per computer. Hand-back first refuses new owner input, waits for the input being sent, then returns the screen to work under a new generation. An input still queued behind a hand-back is refused.
- Owner control lasts 30 s per renewal; the client renews every 10 s. A lapsed lease or a controller restart while the owner held control becomes `fenced`: nobody writes until the owner takes control again or hands back. Work never regains the screen because a tab closed.
- Idle release skips a computer that is not in `agent`.
- Frames are served only for the current generation and are dropped if a transition happened during the capture (`stale_screen`).
- Takeover and hand-back are idempotent per `requestId` (hash of the command, which holds no input). Input request ids are kept in memory for the current owner generation: a retry never sends twice, and a send whose outcome is unknown is refused (`screen_input_unknown`) instead of retried; no typed text, hash of it, or frame is written to the journal, history, memory or logs.
4. **Owner surface.** `GET /api/s16/client/computers/<vm>/screen` (state), `GET .../screen/frame?generation=N` (PNG, `private, no-store`, `nosniff`, CSP `default-src 'none'`), and `POST /api/s16/client/computers/screen/commands` with exact `s16-screen-command/v1` bodies (`takeover`, `renew`, `input`, `hand_back`), owner-authenticated with CSRF rechecked after the body. The Computers page gains "Watch screen" on running computers: live frame with a text alternative, take over / hand back / cancel, clicks, double and right clicks, wheel, a keyboard-capture region (Tab leaves it), a masked "type on the computer" field that clears after sending, and Enter/Tab/Esc/Backspace buttons. English and Thai copy.
5. **Lineage.** The screen view carries the computer's scope; a private computer's frames are its Bot's private data and the owner's. Frames are never given to a model in this slice: Schway's public tool results are text only, so an agent screenshot tool needs a Schway contract first (not added here).
6. **Protected browser stays separate.** Sensitive-site login keeps using the protected browser takeover (S15 `BrowserOwner`, ADR 0079): its frames and input never pass through the VM screen, and the VM screen never carries a protected-browser session.

## Consequences

- With `desktop` on, a Jit-created VM has a virtual GPU and input devices; the guest sees them as ordinary hardware. A text console is what the screen shows until a guest desktop session runs.
- While the owner holds a shared computer, every Bot's work on it pauses (`takeover_active`, which work treats like any refused VM operation). Private computers pause only their Bot.
- Residual: a process the work started earlier inside the guest (for example a detached background job) keeps running during takeover, and guest root can draw on or read its own screen. That is inherent to a root-controlled guest; it is why sensitive login stays in the protected browser. Freezing the work sessions' guest slices during takeover is a possible hardening.

## Pending (not delivered by this slice)

- Guest desktop session (X server on the virtual display, window manager, browser) and the desktop browser's egress: the VM proxy admits only per-session leases, so a desktop browser outside a work session needs a reviewed lease design before a trusted-site login can work in the VM. #442's remaining item depends on it.
- Live evidence on `jit-vm` (QEMU `screendump` and input on the real host, VM15-03 file permissions, restart into the desktop profile) belongs to LIVE16-01 (#431).
2 changes: 1 addition & 1 deletion docs/contracts/s15-working-vm-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Owner decision 2026-11-03: the default topology is one host running the controll
- **Local enrollment.** `VmEnrollment.enrollLocal(owner, {id, managerId, maxSessions?, signal?})` provisions the guest (no pasted bootstrap line), then runs the same inventory, sudo/systemd readiness, `vmIdentityMatches` (controller-chosen MAC and loopback SSH forward) and direct-egress probes as `complete`, and returns the ordinary `VmStatus`. Network isolation is proven only with `network: 'proxy-only'` from the hypervisor plus a refused direct-egress probe; `snapshot-recovery` and `hypervisor-stop` stay `unproven` until an observed protected restore/stop. Since revision 5 the setup job below drives it through `VmEnrollment.provisionLocal`.
- **Image.** `localVmImages` pins Ubuntu 26.04 LTS `release-20261016` (`https://cloud-images.ubuntu.com/releases/resolute/release-20261016/…-{amd64,arm64}.img`) with SHA-256 `5645d0318ad21f643ebb1ac4567514620e9893380b07b0201fda163baa5b7eb5` (amd64, not `amd64v3`, because reference hosts may lack AVX2) and `8eb876c2ccc4b6ff89e4a65d511815923b285e04503ba465809bf587f763da24` (arm64), taken from the release's GPG-verified `SHA256SUMS`. Download is HTTPS without redirects or credentials, size-bounded, then hashed; a mismatch is `vm_image_unverified`, unreachable downloads `vm_image_unavailable`. The verified base is mode 0400; each VM uses a qcow2 overlay.
- **Keys and seed.** The controller generates an Ed25519 client key and the guest's Ed25519 host key, writes `known_hosts` before first boot and seeds both through NoCloud (`jit` user, key-only with `restrict`, passwordless sudo, no password/root login, sshd forwarding off, no generated extra host keys). The seed also keeps proxy variables through sudo, disables apt pipelining and marks the NIC optional with no DHCP DNS/NTP. A changed host key fails closed (`vm_host_key_changed`).
- **QEMU argv (`qemuArgv`).** KVM, `-cpu host`, `-nodefaults`, `-no-user-config`, no display/monitor, seccomp `-sandbox on,obsolete=deny,resourcecontrol=deny`, a 1 MiB console ring buffer (diagnostics only), a controller-only QMP Unix socket, one virtio disk (overlay, `cache=none`), one read-only virtio seed, and exactly `-netdev user,id=net0,restrict=on,ipv6=off,net=192.168.5.0/24,host=192.168.5.2,hostfwd=tcp:127.0.0.1:<port>-:22,guestfwd=tcp:192.168.5.100:3128-cmd:/usr/bin/nc -U <state>/proxy.sock` with one `virtio-net-pci` NIC. `qemuArgvFacts` parses `/proc/<pid>/cmdline`; `inspect()` reports `proxy-only` only for a byte-for-byte rebuild of this profile with the persisted MAC and port, otherwise `unproven`; a foreign process behind the unit is `vm_boundary_unproven`.
- **QEMU argv (`qemuArgv`).** KVM, `-cpu host`, `-nodefaults`, `-no-user-config`, no monitor, `-display none` (no VNC, SPICE or window), and no virtual display device unless the owner opted in to DESK16-01 (`desktop: true`: one `virtio-vga` (x86_64) or `virtio-gpu-pci` (aarch64) at 1280×800 with a virtio tablet and keyboard, reached only through the controller's QMP socket and no guest channel, see `s16-computer-screen-v1`; a VM started headless stays headless until its next start), seccomp `-sandbox on,obsolete=deny,resourcecontrol=deny`, a 1 MiB console ring buffer (diagnostics only), a controller-only QMP Unix socket, one virtio disk (overlay, `cache=none`), one read-only virtio seed, and exactly `-netdev user,id=net0,restrict=on,ipv6=off,net=192.168.5.0/24,host=192.168.5.2,hostfwd=tcp:127.0.0.1:<port>-:22,guestfwd=tcp:192.168.5.100:3128-cmd:/usr/bin/nc -U <state>/proxy.sock` with one `virtio-net-pci` NIC. `qemuArgvFacts` parses `/proc/<pid>/cmdline`; `inspect()` reports `proxy-only` only for a byte-for-byte rebuild of this profile with the persisted MAC and port, otherwise `unproven`; a foreign process behind the unit is `vm_boundary_unproven`.
- **Lifecycle.** `systemd-run --user --collect --unit=<name>.service --property=Type=exec --property=TimeoutStopSec=15 -- <argv>` in the service user's lingering user manager. `start()` waits for QMP, the pinned host key and an admitted SSH login. `stop()` sends QMP `quit` (then unit stop/kill) and requires the unit inactive, the QEMU PIDs gone and the overlay write lock released, else `vm_stop_unconfirmed`. Optional `VmManagement.resume?()` and `WorkingVmService.reattach()` re-verify a surviving VM after a controller restart and restart a VM that was running before a host restart (new generation, re-verified before `ready`); stopped or restored VMs stay stopped.
- **Snapshots.** Exact `^jit-[a-f0-9]{32}$` qcow2 internal snapshots: live via QMP `blockdev-snapshot-internal-sync` (crash-consistent, siblings keep running) or `qemu-img snapshot -c` when stopped; restore = proven stop plus `qemu-img snapshot -a`, leaving the VM stopped (`vm_snapshot_unknown` for a missing tag); retire deletes only the exact tag. Policy claims, usage, generations and the journal are outside the guest.
- **Sizing.** `defaultLocalVmResources`: vCPU = min(⌊CPUs/2⌋, CPUs−2); memory = min(round(0.45 × RAM to whole GiB), ⌊(RAM − 6 GiB)/256 MiB⌋ × 256 MiB); at least 1 vCPU and 2 GiB, otherwise `vm_host_too_small`; disk 60 GiB sparse, refused (`vm_disk_insufficient`) unless free space minus the installer reserve (`localVmDiskReserve`, the `install.sh` rule) holds it. 10 vCPU / 20 GiB gives 5 / 9 GiB; 4 vCPU / 8 GiB gives 2 / 2 GiB. `GET /s15/vm/resources?managerId=` shows current values, defaults and host facts; `POST /s15/vm/resources {managerId,cpus,memoryMiB,diskGiB}` edits them (memory in 256 MiB steps leaving the controller 2 GiB, disks only grow); they apply at the next start.
Expand Down
Loading
Loading