@braedonsaunders/appkit-desk 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,233 @@
1
+ # @braedonsaunders/appkit-desk
2
+
3
+ Per-agent Debian machines in Cloud Hypervisor microVMs: a terminal, a
4
+ filesystem, arbitrary software, and — only when genuinely needed — a real
5
+ desktop, all on one machine with one identity.
6
+
7
+ A desk boots **headless** (a few hundred megabytes of RAM: kernel, filesystem,
8
+ network, the guest agent). The desktop environment is installed in the base
9
+ image but is not running. When work genuinely needs a screen, the compositor is
10
+ started *on the machine the agent is already using* as a service — one
11
+ filesystem, one machine, one event stream. This package owns the mechanism and
12
+ exposes ports; the consuming application supplies policy behind those ports and
13
+ owns the record. The package never touches a database and knows nothing about
14
+ tenants, employees, or approvals.
15
+
16
+ ## Choosing a tier
17
+
18
+ The desk is the middle and top of a cost ladder. Reach for the cheapest tier
19
+ that does the job; the expensive tiers are strictly worse at tasks the cheap
20
+ tiers already cover.
21
+
22
+ | Tier | Surface | When | Cost |
23
+ | --- | --- | --- | --- |
24
+ | Document/tool abilities, in-process script sandboxes | No VM at all | Structured outputs, pure computation | Ordinary model calls |
25
+ | **Headless desk** (this package) | Shell, filesystem, background jobs, persistent disk | Real software, persistent state, downloads | A small resident VM |
26
+ | **Desk with a screen open** | GUI apps, pixels + opportunistic accessibility tree | Software with no CLI, visual verification | ~1.2GB+ resident, a vision call per unassisted step |
27
+
28
+ Size the concurrency cap against screen-open desks, not headless ones.
29
+
30
+ ## Booting a desk
31
+
32
+ ```ts
33
+ import { createDeskHost, isDeskSupported } from '@braedonsaunders/appkit-desk'
34
+
35
+ if (!isDeskSupported()) {
36
+ // Fail closed: no KVM or no Cloud Hypervisor means no desk ability at all,
37
+ // not a degraded one.
38
+ throw new Error('This host cannot run desks.')
39
+ }
40
+
41
+ const host = createDeskHost({
42
+ imageRoot: '/data/agent-disks',
43
+ capacity: 8,
44
+ idleSuspendMs: 5 * 60_000,
45
+ ports: {
46
+ policy: { allowExec: ({ command }) => policyFor(command) },
47
+ onEvent: (event) => ledger.append(event), // the typed desk event union
48
+ audit: (entry) => auditLog.append(entry), // handover boundaries only
49
+ },
50
+ })
51
+
52
+ const desk = await host.start({
53
+ deskId: 'agent-7',
54
+ baseImage: '/data/agent-disks/base.img',
55
+ overlayPath: '/data/agent-disks/overlays/agent-7.img',
56
+ memoryMb: 384,
57
+ vcpus: 2,
58
+ })
59
+
60
+ const result = await desk.exec({ command: '/usr/bin/git', args: ['clone', repo] })
61
+ const job = await desk.exec({ command: '/usr/bin/serve', keepAlive: true }) // dies with the lease
62
+
63
+ const screen = await desk.screen.start({ width: 1280, height: 900 })
64
+ const { png, a11y, focused } = await screen.observe() // lossless PNG, on demand
65
+ await screen.input.click(640, 320)
66
+
67
+ // The live view is a different job: H.264, for a human driving the screen.
68
+ for await (const chunk of screen.video({ fps: 30 })) {
69
+ send(chunk) // init segment first, then one unit per media fragment
70
+ }
71
+
72
+ await desk.screen.stop() // back to headless; the machine keeps running
73
+ ```
74
+
75
+ There are three ways to see the screen and they take different trades.
76
+
77
+ - **`observe()`** is what a model looks at: a lossless PNG plus windows and the
78
+ accessibility tree, on demand and infrequent.
79
+ - **`video()`** is what a person drives by. A video codec ships the difference
80
+ between pictures and a desktop is mostly still, so it costs one to two orders
81
+ of magnitude fewer bytes than the same screen as stills — and bytes between
82
+ the guest and the host are what actually bounds a live view. Chunks are
83
+ ordered: the `init` segment first, then `media` fragments, resumable only at
84
+ one whose `keyframe` is true.
85
+ - **`frames()`** is for a consumer that needs whole pictures — one feeding an
86
+ encoder of its own, or one that cannot decode H.264. `format` picks `jpeg`
87
+ (roughly a tenth the bytes) or `png` (exact).
88
+
89
+ All three are the screen's real size and never rescaled, so all three anchor the
90
+ coordinate space a click is aimed in. `video()` and `frames()` are both masked:
91
+ neither emits anything while a handover is active.
92
+
93
+ Every desk boots from one golden **raw** base image plus a per-desk
94
+ copy-on-write overlay, so patching the base patches every desk on its next boot
95
+ while agent installs and home directories persist. The overlay is a plain raw
96
+ file cloned from the base with `cp --reflink=auto` — an instant, block-sharing
97
+ CoW clone on XFS/Btrfs and a graceful full-copy fallback on ext4. It is
98
+ deliberately *not* a qcow2 backing overlay: Cloud Hypervisor cannot follow disk
99
+ backing chains and rejects them (`UnsupportedFeature` /
100
+ `MaxNestingDepthExceeded`), so the disk is passed as `image_type=raw` with no
101
+ chain to follow. `buildDeskLaunchPlan` produces the entire invocation — VMM
102
+ argv, overlay-creation step, vsock socket path, TAP device and MAC — as
103
+ inspectable data before anything is spawned, and it fails closed: a missing
104
+ `/dev/kvm`, VMM binary, kernel, base image, or overlay directory throws rather
105
+ than producing a plan that cannot boot.
106
+
107
+ ## Leases, idle suspend, and the queue
108
+
109
+ A desk is resident under a lease. `renewLease(ms)` extends it; activity defers
110
+ the idle timer; a desk past its lease or idle deadline is suspended — the VM
111
+ stops, the disk persists, applications cold-start on `resume`. Keep-alive jobs
112
+ die with the lease, and their termination is recorded as `job_exit` so no
113
+ process ever runs where an operator cannot see it.
114
+
115
+ Residency is bounded by a hard capacity cap. Starts beyond the cap queue FIFO
116
+ rather than overcommitting host memory. `host.stats()` reports
117
+ `{ resident, queued, capacity, suspended, lastStartedAt, lastSuspendedAt,
118
+ reconnects, lastReconnectAt, lastReconnectDeskId, lastError }`; queue depth is
119
+ worth alerting on, and so is a `reconnects` count that climbs. With an injected
120
+ `now`, tests drive all of this deterministically through `host.sweep()`.
121
+
122
+ ## A connection is re-established, not assumed
123
+
124
+ The vsock channel to a guest is not a fact learned once at boot. It can drop
125
+ mid-lease for reasons that say nothing about whether the desk is usable — the
126
+ guest agent restarts, the bridge drops, the guest wedges for a moment — and
127
+ treating that as terminal stranded the desk for the rest of its lease with a
128
+ healthy guest behind it. So the backend reconnects: same retry path, same
129
+ `confirmGuest` ping, bounded window and backoff. It never reconnects after
130
+ `shutdown()` or once the VMM has exited, because then the desk genuinely needs
131
+ a fresh boot; when the window runs out the host suspends the desk so `resume()`
132
+ boots a new one instead of handing back a dead handle.
133
+
134
+ Two things a caller must handle, because pretending otherwise would be a lie:
135
+ a request that was **in flight** when the channel dropped rejects with
136
+ `DeskRequestFateUnknownError` — the guest may already have run it, and silently
137
+ replaying an `exec` that sent mail is the wrong kind of resilience. And the
138
+ guest's own capture state does not survive its agent restarting, so live
139
+ `frames()`/`video()` iterators **end** on a reconnect and the coordinate anchor
140
+ is cleared; observe (or take a frame) again before aiming.
141
+
142
+ ## The coordinate contract
143
+
144
+ **Input coordinates are in the pixel space of the most recent `observe()`,
145
+ one to one.** Any scaling applied on the way out must be undone on the way in.
146
+ The package enforces this rather than documenting it and hoping: coordinate
147
+ input before the first `observe()`, or outside its bounds, throws. Getting
148
+ this wrong makes every click land slightly off in a way that looks like model
149
+ failure and is very hard to diagnose.
150
+
151
+ Perception is pixels-primary: `observe()` always returns a PNG, and
152
+ opportunistically includes the focused application's AT-SPI accessibility tree
153
+ (`a11y`) when one is exposed. `screen.a11y.invoke(nodeId, action)` targets by
154
+ role and name when a tree exists; the pixel path always works.
155
+
156
+ ## The handover masking contract
157
+
158
+ `screen.handover.begin({ ttlMs, scope })` returns a URL through which a human
159
+ can view or control the screen — for example to complete a login the agent
160
+ cannot. The masking rules are load-bearing:
161
+
162
+ - While a handover is active, **input events never reach the `onEvent`
163
+ recording port** — no `click`, `type`, `key`, `scroll`, `drag`,
164
+ `window_focus`, or `app_launch` is emitted, even though the input itself is
165
+ faithfully forwarded to the guest.
166
+ - **Frames are not emitted** to `frames()` consumers for the duration.
167
+ - **`clipboard.read()` is refused** during a handover, so a freshly typed
168
+ credential cannot ride the clipboard into the agent's context.
169
+ - Only `handover_begin` and `handover_end` cross the boundary, carrying actor,
170
+ scope, and duration — never content. Both reach `onEvent` (for the ledger)
171
+ and the `audit` port.
172
+
173
+ A handover ends explicitly, at its TTL (`reason: 'expired'`), or when the desk
174
+ is suspended (`reason: 'revoked'`). The failure this prevents is concrete:
175
+ keystrokes typed by a human during a credential handover leaking into an
176
+ append-only record that cannot be edited afterward.
177
+
178
+ ## The event union
179
+
180
+ `onEvent` receives a closed union — `shell_command`, `app_launch`, `click`,
181
+ `type`, `key`, `scroll`, `drag`, `window_focus`, `screen_open`,
182
+ `screen_close`, `handover_begin`, `handover_end`, `job_start`, `job_exit` —
183
+ each stamped with `deskId` and an ISO timestamp. The union is defined here so
184
+ the consumer's ledger and this package agree on the taxonomy; persistence is
185
+ entirely the consumer's concern.
186
+
187
+ ## The backend port and the guest agent
188
+
189
+ `DeskBackend` is the seam that keeps everything testable without a hypervisor:
190
+ `boot(plan)` returns a `DeskMachine` — a request/response channel to the
191
+ in-guest agent plus an event subscription. The default is
192
+ `cloudHypervisorBackend`, which creates the overlay, spawns Cloud Hypervisor
193
+ with the plan's argv, performs the vsock `CONNECT` handshake, and speaks the
194
+ framed protocol. Its process-spawning glue is thin on purpose; the launcher
195
+ and the socket transport are injectable, and CI substitutes in-memory fakes.
196
+
197
+ The wire protocol — length-prefixed JSON frames with bounded sizes, strict
198
+ field validation, and a closed operation set — is pure code in `protocol.ts`,
199
+ shared by both ends. The in-guest agent (`guest-agent.ts`) is the
200
+ security-critical piece: it is the only new attack surface in the design, so
201
+ it is small enough to read in one sitting, does no parsing it does not need,
202
+ dispatches through a closed switch, and treats any framing violation as fatal
203
+ to the connection. Its message-handling core is pure and hard-tested; contact
204
+ with the guest OS is injected as handlers by the guest's init glue.
205
+
206
+ `verifyDeskHost()` belongs in service startup: it boots a throwaway microVM
207
+ through the backend and distinguishes a host that is unusable (wrong platform,
208
+ missing VMM or images — throws) from capabilities that are merely absent,
209
+ reported as booleans: `kvm`, `vsock`, and `virtioGpu`.
210
+
211
+ Every additional provider runs `verifyDeskBackendConformance()` against a
212
+ disposable launch plan. The shared check verifies request/capability round trips,
213
+ idempotent terminal shutdown, and refusal of work after shutdown. Provider test
214
+ suites extend this contract; they do not restate it.
215
+
216
+ `exportPortableDeskHome()` and `importPortableDeskHome()` are the migration seam
217
+ between providers. Sources expose entries and file bytes; exports produce a
218
+ bounded, sorted, content-addressed manifest preserving directories, files,
219
+ safe relative symlinks, modes, and timestamps. Imports verify every SHA-256,
220
+ path, symlink target, size, and aggregate limit before opening a sink, then
221
+ stage the complete home and make it authoritative with one `commit()`. Any
222
+ failure calls `rollback()`. This moves an agent's working home, not an operating
223
+ system image: installed packages and provider-specific machine state remain
224
+ the provider's concern.
225
+
226
+ `createDeskFrameDeduplicator()` assigns exact SHA-256 identities to observations
227
+ without retaining their bytes. A repeated identity can reuse the prior audit
228
+ frame and omit another model image; reset it whenever the capture session or
229
+ masking boundary changes.
230
+
231
+ Do not fall back to unconfined execution when this package reports an
232
+ unsupported host. A deployment without KVM loses the desk ability entirely —
233
+ the same fail-closed posture as the rest of AppKit.
package/backend.d.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The backend port and its default Cloud Hypervisor implementation.
3
+ *
4
+ * `DeskBackend` is the seam that keeps the package testable without a
5
+ * hypervisor: the host asks a backend to boot a `DeskLaunchPlan` and gets a
6
+ * `DeskMachine` — a request/response channel to the in-guest agent plus an
7
+ * event subscription. CI and non-KVM developer machines inject an in-memory
8
+ * fake; production uses `cloudHypervisorBackend`, whose process-spawning glue
9
+ * is deliberately thin because everything that can be pure lives in the plan
10
+ * builder and the protocol module.
11
+ */
12
+ import { type ChildProcess } from 'node:child_process';
13
+ import type { Duplex } from 'node:stream';
14
+ import { DeskError, type DeskLaunchPlan, type DeskLauncherIdentity } from './plan.js';
15
+ import { type GuestCommand, type GuestEventMessage } from './protocol.js';
16
+ /**
17
+ * A request that was in flight when the guest connection dropped.
18
+ *
19
+ * Distinct from a plain `DeskError` because the caller's next move is
20
+ * different: this request may ALREADY HAVE RUN. The frame reached the guest,
21
+ * and whether the guest executed it before the channel went away is not
22
+ * knowable from the host. An `exec` may have sent mail; a `click` may have
23
+ * landed. So the transport refuses to guess — it names the ambiguity and lets
24
+ * the caller decide whether the operation is safe to repeat. Anything that
25
+ * rejects with a plain `DeskError` instead never reached the guest at all.
26
+ */
27
+ export declare class DeskRequestFateUnknownError extends DeskError {
28
+ readonly name: string;
29
+ }
30
+ /**
31
+ * What happened to the transport underneath a machine.
32
+ *
33
+ * `reconnected` means the host lost the guest channel mid-lease and got it
34
+ * back; the desk is usable again. It is NOT a promise that the guest is
35
+ * unchanged — see the note on `onConnectionChange`. `lost` means it will not
36
+ * come back on this VM and the desk needs a fresh boot.
37
+ */
38
+ export type DeskConnectionChange = {
39
+ state: 'reconnected';
40
+ deskId: string;
41
+ /** How the previous connection went away. */
42
+ reason: string;
43
+ /** Wall-clock time the desk spent with no channel to its guest. */
44
+ downtimeMs: number;
45
+ /** Reconnects this machine has survived, including this one. */
46
+ reconnects: number;
47
+ } | {
48
+ state: 'lost';
49
+ deskId: string;
50
+ /** Why it is gone for good: the VMM exited, or the window ran out. */
51
+ reason: string;
52
+ };
53
+ /** A booted microVM as the host sees it: one desk, one guest-agent channel. */
54
+ export interface DeskMachine {
55
+ readonly deskId: string;
56
+ /**
57
+ * Send one request to the in-guest agent and await its correlated result.
58
+ *
59
+ * Rejects with `DeskError` when the guest reports an error, the request
60
+ * times out, or the connection is gone for good — in that last case the
61
+ * request never left the host, so repeating it is safe.
62
+ *
63
+ * A connection lost mid-lease is re-established rather than fatal: a call
64
+ * that arrives while that is happening WAITS for it instead of failing.
65
+ * A call that was already in flight when the channel dropped rejects with
66
+ * `DeskRequestFateUnknownError`, because the host cannot tell whether the
67
+ * guest ran it.
68
+ */
69
+ request(command: GuestCommand, options?: {
70
+ timeoutMs?: number;
71
+ }): Promise<unknown>;
72
+ /**
73
+ * Subscribe to unsolicited guest events (job exits, frames, focus changes).
74
+ *
75
+ * Subscriptions belong to the machine, not to the socket under it, so they
76
+ * survive a reconnect. What does NOT survive is the guest's own capture
77
+ * state: a guest agent that restarted has no screen, no frame encoder and
78
+ * no video encoder, and a subscriber will simply stop receiving. Watch
79
+ * `onConnectionChange` to notice.
80
+ */
81
+ subscribe(listener: (event: GuestEventMessage) => void): () => void;
82
+ /**
83
+ * Watch the transport under this machine.
84
+ *
85
+ * Optional: a backend whose channel cannot drop has nothing to report. The
86
+ * default backend reports every reconnect, because a reconnect that leaves
87
+ * no trace turns a guest agent restarting every few minutes into a desk that
88
+ * misbehaves for reasons nobody can find.
89
+ */
90
+ onConnectionChange?(listener: (change: DeskConnectionChange) => void): () => void;
91
+ /** Stop the VM. Idempotent; resolves once the VMM process has exited. */
92
+ shutdown(): Promise<void>;
93
+ }
94
+ export interface DeskBackend {
95
+ boot(plan: DeskLaunchPlan): Promise<DeskMachine>;
96
+ }
97
+ export type DeskProcessLauncher = (command: string, args: readonly string[], options: {
98
+ identity?: DeskLauncherIdentity;
99
+ }) => ChildProcess;
100
+ export interface CloudHypervisorBackendOptions {
101
+ /** Spawns the VMM and image tooling; injectable for tests. */
102
+ launcher?: DeskProcessLauncher;
103
+ /** Opens the host side of the vsock Unix socket; injectable for tests. */
104
+ connect?: (socketPath: string) => Duplex;
105
+ platform?: NodeJS.Platform;
106
+ /**
107
+ * Clock behind every connect and reconnect deadline. Injectable for the same
108
+ * reason `DeskHostOptions.now` is: a test drives a window to its end by
109
+ * moving the clock, rather than sleeping for it and hoping the machine it
110
+ * runs on is fast enough.
111
+ */
112
+ now?: () => number;
113
+ idFactory?: () => string;
114
+ requestTimeoutMs?: number;
115
+ connectTimeoutMs?: number;
116
+ connectRetryDelayMs?: number;
117
+ /** Bound on a single handshake attempt; see attemptHandshake. */
118
+ handshakeTimeoutMs?: number;
119
+ /**
120
+ * How long a machine keeps trying to get its guest channel back after an
121
+ * unexpected drop before declaring the desk lost. Bounded on purpose: the
122
+ * VMM is still alive so a reconnect is cheap and usually fast, but retrying
123
+ * forever against a guest that is never coming back is worse than failing.
124
+ */
125
+ reconnectWindowMs?: number;
126
+ /**
127
+ * First delay between reconnect attempts. It doubles toward a ceiling, so
128
+ * this is the fast first poll rather than the steady rate. Separate from
129
+ * `connectRetryDelayMs`, which is sized for a guest that is still cold
130
+ * booting; here the guest was up a moment ago.
131
+ */
132
+ reconnectRetryDelayMs?: number;
133
+ killGraceMs?: number;
134
+ guestAgentPort?: number;
135
+ }
136
+ export declare function createCloudHypervisorBackend(options?: CloudHypervisorBackendOptions): DeskBackend;
137
+ /** The default backend: real Cloud Hypervisor over a real vsock socket. */
138
+ export declare const cloudHypervisorBackend: DeskBackend;
139
+ //# sourceMappingURL=backend.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backend.d.ts","sourceRoot":"","sources":["../src/backend.ts"],"names":[],"mappings":"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"}