11 KiB
Executable File
11 KiB
Executable File
Архив исходника docs/superpowers/plans/2026-10-08-desktop-r3.md
Источник: Virfield, commit a1460d3372bdfbb5cb19d633d41dd5d3fb3164e6. Снимок перед релизной очисткой. Исторические утверждения ниже относятся к моменту исходной записи; актуальное состояние — в personal/projects/virfield.
> **Reconciled status — 2026-10-14:** This is a dated design/execution record.
> It is not a current deployment runbook or live task tracker. See
> [current delivery state](../../VIRFIELD-3-PLAN.md) and
> [current acceptance](../../VERIFICATION.md); old paths/statuses and unchecked
> example steps below describe their original execution context.
> **2026-10-15 decision:** automatic VM account-password rotation is rejected;
> standard `admin/admin` is the approved target, still pending implementation.
> Historical rotation steps below are superseded by the [maintained plan](../../VIRFIELD-3-PLAN.md#decisions-and-current-boundary).
# R3 Desktop Access to VM Leases — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Open a ready VM lease's desktop (Tart or HTPC macOS VM) in Screen Sharing.app through a private, lease-bound loopback tunnel, with `virfield desktop`, the Web Console and MCP.
**Architecture:** Lease preparation enables guest macOS Screen Sharing. Lease tunnels become per service (`ssh`, `desktop`); a desktop tunnel dials through a runtime `ServiceDialer` (Tart: lease IP:5900 on the host-only network; HTPC VM: the guest's 5900 forwarded by passt and published on the node loopback, reached over node SSH) and returns `vnc://admin:<lease password>@127.0.0.1:<port>`.
**Tech Stack:** Go, POSIX sh guest/node scripts, dockurr/macos (passt `USER_PORTS`, `VNC_PORT`), macOS `launchctl`, Screen Sharing (Apple RFB 003.889).
**Spec:** `docs/superpowers/specs/2026-10-08-desktop-r3-design.md`
---
## File structure
| File | Responsibility |
|---|---|
| `internal/guestssh/lease.go` | Lease script enables Screen Sharing; Prepare verifies 5900; `LeasePassword` |
| `internal/baremetal/vmscripts.go`, `vm.go` | VM containers forward guest 5900 to node loopback `ssh port+1000`; `DialService`; `LeasePassword` delegation |
| `internal/config/config.go` | Reserve the desktop port range |
| `internal/provider/provider.go` | `ServiceDialer`, `DesktopCredentials` interfaces |
| `internal/fleet/fleet.go` | Route `DialService` and `LeasePassword` by lease provider |
| `internal/domain/ssh.go` | `Tunnel.Service`, `Tunnel.URL` |
| `internal/control/tunnel.go` | Tunnels keyed by lease and service; desktop dial and URL |
| `internal/httpapi/server.go` | Optional `{"service"}` body; `?service=` on DELETE |
| `cmd/virfield/main.go` | `virfield desktop LEASE_ID [--print]` |
| `internal/mcpadapter/server.go` | `service` on `vm_tunnel`/`vm_tunnel_close` |
| `internal/web/app.js` | Desktop button |
---
### Task 1: Guest Screen Sharing in lease preparation
**Files:** Modify `internal/guestssh/lease.go`; Test `internal/guestssh/lease_test.go` (or `client_test.go` beside the existing Prepare tests).
- [ ] **Step 1: Failing tests.** (a) `TestLeaseScriptEnablesScreenSharing`: `leaseScript` contains `launchctl enable system/com.apple.screensharing` and `launchctl kickstart -k system/com.apple.screensharing`, each run through `sudo -S -p ''` with the new password. (b) `TestLeasePasswordReadsTheLeaseCredential`: after `CredentialsFor(l)` saves a credential with scope `leases`, `m.LeasePassword(l)` returns its password; a lease without credentials returns `image_credentials_missing`.
- [ ] **Step 2:** `go test ./internal/guestssh/` — FAIL.
- [ ] **Step 3: Implement.** Append to `leaseScript` before the authorized_keys rewrite:
```sh
printf '%s\n' "$new" | sudo -S -p '' /bin/launchctl enable system/com.apple.screensharing
printf '%s\n' "$new" | sudo -S -p '' /bin/launchctl kickstart -k system/com.apple.screensharing
```
In `Prepare`, after the `sshd -T` policy check, verify the listener (bounded retry, Screen Sharing starts asynchronously):
```go
listening, err := verified.Run(ctx, "for i in 1 2 3 4 5 6 7 8 9 10; do /usr/sbin/netstat -an -p tcp | /usr/bin/grep -q '[.]5900 .*LISTEN' && echo yes && exit 0; sleep 2; done; echo no", "")
if err != nil || strings.TrimSpace(listening) != "yes" {
return result, domain.Err("lease_ssh_failed", "Guest Screen Sharing did not start for the lease")
}
```
Add:
```go
// LeasePassword is the lease's guest password, which signs in to Screen
// Sharing as the lease account.
func (e *Manager) LeasePassword(l domain.Lease) (string, error) {
c, err := LoadCredentials(e.derive("leases").CredentialPath(l))
if err != nil || !c.Secured {
return "", domain.Err("image_credentials_missing", "Lease credentials are unavailable")
}
return c.Password, nil
}
```
- [ ] **Step 4:** `go test ./internal/guestssh/` — PASS.
- [ ] **Step 5:** Commit `Enable guest Screen Sharing for each VM lease`.
### Task 2: HTPC VM desktop endpoint
**Files:** Modify `internal/baremetal/vmscripts.go`, `vm.go`, `internal/config/config.go`; Tests `vmscripts_test.go`, `vm_test.go`, `config_test.go`.
- [ ] **Step 1: Failing tests.** (a) `TestVMStartPublishesItsPortAndHoldsTheNode` additionally expects `-e USER_PORTS=22,5900`, `-e VNC_PORT=5950` and `-p 127.0.0.1:23100:5900` for a lease on 22100 (image VMs: no desktop publish). (b) `TestDialServiceDesktopTunnelsToNodeLoopback`: with the forwarding test server from `ssh_test.go`, `r.DialService(ctx, domain.Lease{Location:"htpc", SSHPort:22100, ImageID:"image-x"}, "desktop")` requests `127.0.0.1:23100`; `"ssh"` returns an `invalid_request`. (c) config: `port_base` 64500 with `max` 2 is rejected (desktop range beyond 65535).
- [ ] **Step 2:** `go test ./internal/baremetal/ ./internal/config/` — FAIL.
- [ ] **Step 3: Implement.** `vmStartScript` takes `$10` = desktop publish (`127.0.0.1:<port+1000>` for leases, empty for images) and adds `-e USER_PORTS=22,5900 -e VNC_PORT=5950` plus `${10:+-p "$10:5900"}`; `startVM` passes it. `DesktopPortOffset = 1000`. `DialService` reuses `DialGuest` with port `SSHPort+1000` for `desktop`. Config: `PortBase+DesktopPortOffset+2*Max <= 65535`. Runtime `LeasePassword(l)` delegates to `r.guests` (add to `Guests`).
- [ ] **Step 4:** tests PASS.
- [ ] **Step 5:** Commit `Publish HTPC VM desktops on the node loopback`.
### Task 3: Service dialer and fleet routing
**Files:** Modify `internal/provider/provider.go`, `internal/fleet/fleet.go`; Test `internal/fleet/fleet_test.go`.
- [ ] **Step 1: Failing test** `TestServiceDialRoutesByProvider`: a fleet with a primary that does not implement `ServiceDialer` and a node runtime that does; `DialService(lease{Provider:"baremetal"}, "desktop")` reaches the node; a primary lease returns `provider.ErrNoServiceDialer`. `LeasePassword` routes the same way through the routed preparer.
- [ ] **Step 2:** FAIL.
- [ ] **Step 3: Implement.**
```go
// ServiceDialer reaches a lease's private service endpoint.
type ServiceDialer interface {
DialService(ctx context.Context, l domain.Lease, service string) (net.Conn, error)
}
var ErrNoServiceDialer = errors.New("runtime has no service dialer")
```
`fleet.Fleet.DialService` routes by `l.Provider` to a runtime implementing `ServiceDialer`, else returns `ErrNoServiceDialer`. `routedPreparer.LeasePassword` routes by provider to an implementer of `LeasePassword(domain.Lease) (string, error)`.
- [ ] **Step 4:** PASS. **Step 5:** Commit `Route lease service connections by runtime`.
### Task 4: Per-service tunnels
**Files:** Modify `internal/domain/ssh.go`, `internal/control/tunnel.go`, `internal/control/controller.go` (callers); Test `internal/control/tunnel_test.go`.
- [ ] **Step 1: Failing tests.** (a) `TestDesktopTunnelReturnsVNCURLAndForwards`: a ready VM lease (fake backend IP `127.0.0.1`; a local listener stands in for 5900 via a test `ServiceDialer`); `OpenTunnel(ctx, id, "desktop")` returns `Service:"desktop"` and `URL` `vnc://admin:<password from fake preparer>@127.0.0.1:<port>`; bytes flow; an SSH tunnel for the same lease is independent. (b) Desktop on a host lease (`ImageID == ""`) → `lease_unavailable`. (c) Release closes both tunnels. (d) `CloseTunnel(ctx, id, "desktop")` leaves the SSH tunnel open.
- [ ] **Step 2:** FAIL.
- [ ] **Step 3: Implement.** `domain.Tunnel` gains `Service string `+"`json:\"service\"`"+` and `URL string `+"`json:\"url,omitempty\"`"+`. `c.tunnels` key = `id+"/"+service`; `tunnel` stores `service` and a `dial func(ctx) (net.Conn, error)`: SSH dials `lease.IP:SSH.Port` as today; desktop uses the backend's `ServiceDialer` when it implements it and the lease provider is secondary, else `net.Dialer` to `lease.IP:5900`. The URL uses `url.UserPassword(l.GuestUser, password)` with the password from `c.leasePreparer.(interface{ LeasePassword(domain.Lease) (string, error) })`. `reconcileTunnels` iterates all keys with the existing rules. Existing callers pass `"ssh"`.
- [ ] **Step 4:** `go test ./internal/control/` PASS. **Step 5:** Commit `Open lease tunnels per service, including desktop`.
### Task 5: HTTP, CLI, MCP, Web
**Files:** `internal/httpapi/server.go` (+test), `cmd/virfield/main.go` (+test if present), `internal/mcpadapter/server.go`, `internal/web/app.js`.
- [ ] **Step 1: Failing tests.** HTTP: `POST /leases/{id}/tunnel` with `{"service":"desktop"}` passes `desktop`; with no body passes `ssh` (unchanged); unknown service → 400 `invalid_request`; `DELETE …/tunnel?service=desktop`.
- [ ] **Step 2:** FAIL.
- [ ] **Step 3: Implement.** HTTP decodes an optional body (empty body allowed). CLI: `virfield desktop LEASE_ID [--print]` → POST with `{"service":"desktop"}`, prints the URL; without `--print` runs `/usr/bin/open <url>`. MCP: `vm_tunnel`/`vm_tunnel_close` take `service` (enum `ssh|desktop`, default `ssh`). Web: on ready VM leases (`image_id` set) a "Desktop" button POSTs and sets `window.location = url`.
- [ ] **Step 4:** `make check` PASS. **Step 5:** Commit `Expose lease desktops through the API, CLI, MCP and Web Console`.
### Task 6: Live acceptance and docs
- [ ] Deploy; acquire `ventura-xcode-15.2` (Tart) and `htpc-sonoma-14`; for each: `virfield desktop --print`, read 12 bytes from the tunnel and expect `RFB 003.889`; open the URL in Screen Sharing.app and take a screenshot of the signed-in desktop; release; tunnel port refuses connections.
- [ ] Update `docs/OPERATIONS.md` (desktop), `docs/VERIFICATION.md` (R3 record), `docs/AGENT-GUIDE.md` (`vm_tunnel` service), `docs/VIRFIELD-3-PLAN.md` status, Obsidian; commit `Record R3 desktop acceptance`.
## Self-review notes
- Spec coverage: guest enablement (T1), HTPC endpoint (T2), routing (T3), tunnel + URL (T4), clients (T5), live checks (T6). Bare metal and noVNC are out of scope per spec.
- Existing HTPC VM leases started before T2 keep their old container until restarted; new leases get the desktop publish.