Shared desktop boundary
Shared desktop boundary
RfbInputFilter.push returns only measured frames and a protocol close reason. Each frame carries bytes, its input-mutation flag and the controller revision; DesktopRuntime admits those frames against the live control lease before forwarding. View-only input contributes no frame. The unused concatenated output and dropped counter are removed; RfbServerFilter.push still owns the server-to-viewer byte stream and returns the bytes to forward (src/desktop/rfb-server-filter.ts:18).
src/desktop/ owns the display-keyed controller, framed RFB transport, clipboard policy and standard producer routes. PluginContext.registerDesktopProvider is the only contribution path. Browser owns host Chrome/X supervision and uses packages/plugin-shared/x11Desktop.mjs; Sandbox owns a GNOME 50 Wayland desktop in each managed Project container. Core imports neither implementation. Producers use an epoch-qualified display identity. The optional bounded, authorized prepare hook runs before initial agent use and every ticket, and before a control stream only while its session is not ready yet, because a collapsed chat tile holds that stream for as long as the conversation is open; it never runs for passive stills or revalidation. The server filter frames only headers and passes each rectangle body through as it arrives, so a full-screen rectangle is neither held back nor re-copied while it grows. Agent preparation receives the active Project id to refuse foreign-Project side effects before execution. Without the hook no preparation occurs.
Runner desktop.command calls are authorized from the daemon's active-turn table and executed by this same controller. hostCancel, turn abort and runner exit revoke admission and abort the operation; the serial slot is not freed until the provider promise settles. No runner-local control authority exists. Unknown command outcomes cannot be replayed. Each tool contributes a typed operation-kind/input schema validated at the host boundary; producers receive the tool identity intact. Settled request records retire with the host turn, not a producer-wide permanent ledger. Display fences retain the controller's serial queue until operations settle, while session close affects only that session. Tickets carry an independently advancing connection generation.
Sandbox starts the one guest desktop on first use through elowen-desktop --json ensure. It then runs until a Project member stops it or the container stops. There are no agent start/stop/launch tools, idle stop or application ownership projection. The model uses one Desktop tool: one flat advertised object, validated by a discriminated union inside the tool; apps start from Bash with desktop-open. The agent can ask for a person with the handover action, which runs core's takeover operation kind (controller.requestTakeover, then a fresh look), after an authorized look has put the desktop card into the chat; only a person claims control, through that tile, and cancelling the tool call ends the wait. Account Desktops allows members with Sandbox access to stop the shared desktop after confirmation; it does not claim control. DesktopRuntime logs every control change of a display at info level (an agent request or its withdrawal, a claim with the account id and reason, the return to the agent with its reason), never a lease, view id or credential.
The human-only POST /plugins/sandbox/api/desktop/stop?projectId=… rechecks Project membership and request scope. The plugin API dispatcher sets auth.agent only for agent-scoped credentials (src/api/routes/pluginApi.ts:64), and this route refuses them (plugins/sandbox/lib/desktopApi.mjs:19-20). It uses the existing leased guest execution path, not a new host operation or image: stop the compositor and dependent agent; the compositor's packaged ExecStop ends elowen-desktop-session.target and all desktop-open apps, then stop the user's graphical target and portals. The agent service cgroup owns the bridge. The existing Project queue orders stop after any in-flight start; display/session fences close human control, viewers and active commands with user_stopped. Both stop commands must exit zero and the final guest status must be inactive before this route writes stopped; a nonzero result returns desktop_stop_failed. Each explicit stop repeats the teardown, even for a stored stopped observation, because recovery status only checks the compositor and agent and cannot prove that a previous partial portal cleanup completed. Agent preparation may ensure; stream/ticket preparation only observes a stored ready desktop, and stills never prepare. No viewer or boot recovery restarts a stopped desktop; the next explicit Desktop call may start it. Guest-root shell authority remains the limitation described below.
The takeover gate covers the Desktop tool, the only agent path core admits and audits. It is not a boundary against the agent's own shell: guest commands, including Bash, run as root inside the container, so they can reach the guest agent's control socket or the compositor's input interfaces directly while a person holds control. Closing that would require running the agent's guest commands without root; no check inside the container can hold against root.
The screen size has one source, plugins/sandbox/guest/desktop/agent/screen.json (1920x1080): the guest agent bundles it, desktopPayloadFiles renders it into the shell unit's --virtual-monitor and DESKTOP_SESSION_CONTRACT publishes it for the images listed by rootfsCatalog.mjs::desktopCapability in ROOTFS_RECIPES['project-base'].desktop.imageContracts, the sole desktop admission catalog. Revisions without a contract have no supported desktop. Only the current project-base@19 pin and desktop contract are published by this release, without version-specific parsing or result fallback. The host compares the guest's reported size with the current contract. Whole-screen and window screenshots for the model come at the largest quarter scale that a provider passes unchanged (1568 px edge, 1.2 MP: 0.75 at 1920x1080, 1 at 1280x800), and the guest encoder picks a PNG row filter per row, so even a full-screen photograph stays under the 3 MiB bound. The passive still asks the guest for the smallest quarter scale that fills its 480x300 box. Existing materialized Projects retain their image through start, restart and boot recovery. Explicit recreate or restore involving a retired image is refused before any environment mutation; a new Project uses the current image. Strict guest status validates boot identity, desktop epoch, state, geometry and private VNC credentials; status never starts the guest. Commands retain bounded strict GUI JSON and PNG validation. Observation identity is adopted only by the daemon and revalidated around commands and connector admission. Migration 13 invalidates old X11 rows, removes lifecycle-only columns and obsolete queued actions, preserving the canonical Project foreign key and cascade. Reconciliation observes and fences unavailable displays without starting or stopping them; persisted stopped rows are excluded, and a probe that raced a newer observation cannot overwrite it. Daemon disposal settles connectors and operations without stopping guest applications.
Code mode's generic observation seam is details.modelObservation: { key }, validated in plugins/code-mode/src/protocol/observation.ts and catalogued in docs/plugin-author/seam-catalog.md, row 64. It retains the latest canonical result content per key until yield/completion, reserves text budget, preserves normalized image blocks and removes identical script forwarding. Desktop declares its Project key; tools without a declaration retain script-owned output. Automatic content does not become authored memory output. Demand-based text allocation returns unused shares to larger keys; media is reserved on admission and released on key replacement. Invalid declarations produce a visible cell diagnostic while preserving the completed tool result. The 8 MiB per-text-block limit accommodates the full bounded Desktop batch.
Desktop expectations share the single runtime parser plugins/sandbox/lib/desktopSelectors.mjs between daemon preflight and the bundled guest. The browser's desktopActionText.ts also imports its isRef predicate to hide bare guest references in action captions without a second recognition rule. Guest and plugin browser TypeScript configurations admit the shared JavaScript modules, so types are read from their source without a second declaration copy. Mixed bare text with filters or predicates is rejected before input; only tokens containing = introduce grammar; ambiguous exact selectors report the observed scope, at most five bounded role/name candidates and a narrower selector. desktopResult.mjs and its schema-inferred JSDoc type own the public result discriminant and validation for host failures and guest replies: success/error_type/action_delivered, with null delivery meaning unknown. Preflight failures carry explicit action_delivered:false evidence from the refusing boundary; submission alone is not proof of delivery. Transport and protocol failures after guest execution remain unknown. The existing host RPC error envelope carries required, nullable, validated details containing the error code and explicit delivery evidence; the runner reconstructs that evidence instead of reducing every failure to a message. The current guest payload retains correct upstream AT-SPI state bit positions across both words, derives unnamed GTK 4 menu labels from labelled-by relations or one unambiguous semantic descendant with provenance, includes transient popups in their owner's scope, and resolves coordinate action ownership by accessibility PID, skipping unrelated applications whose lookup fails. A mapped window whose tree is still starting produces a successful, explicitly incomplete look rather than a false action failure. Desktop admission follows the same desktopCapability and imageContracts catalog; host and guest use one strict result parser and no compatibility branches.
The guest also uses the existing Accessibility and Input seams for committed numeric Value writes and explicit keyboard destinations. fill on a spin button validates numeric bounds, writes Value once and verifies both Value and numerically parsed displayed Text, including when the stored value already matched. Absent Value or Text support, rounding or clamping fails without a text fallback. type requires target; key takes exactly one of target or window. One command preparation activates the destination window, refreshes identity and clicks an unfocused element at its centre, then verifies focused state. Already focused targets keep their caret and selection; an editable click only receives focus confirmation. Popup targets verify their compositor owner, never require focus on the popup surface itself. No GrabFocus call or application/toolkit branch is used for keyboard preparation. Guards check identity and focus around plain-text runs, at navigation and paste boundaries, and between chord presses, with key releases preserved on interruption. No last-click state or input retry exists. The full contract, bounded guard cost and intentional Tab/Return behavior are documented in docs/PLUGIN_DEV.md, Desktop section. Delivery evidence remains independent from verification and observations.
User-directed nested shares use the core tool trace, not the latest-per-key model observation. The host externalizes each successful nested result's image bytes when the call settles and stores references on that call's ToolTraceCall.images, with imageOrder for producer-local completion order and imageTimestamp for completion time across exec/wait results, including late in-place settlements. A nested ShareImage({ latest: true }) reads the producer's existing record log through its call scope before exec or wait reports it; later journal lookups read those same references. Repeated screenshots therefore remain individually shareable even though Desktop's model observation keeps only its latest state. sharedImage and sharedFile still drive the existing live events and hydration, while collectImageFiles includes nested image references in authorization and retention. Latest-image sharing also verifies that its stored bytes remain readable. Before either share tool returns success, BrainStore.recordSharedAttachment commits a validated ownership reference as an off-branch native custom entry of type elowen.shared-attachment, with data.details.sharedImage or data.details.sharedFile. This contains no caption or transcript copy. The existing attachment SQL projection and collectors read it as well as ordinary tool results, so the authenticated route, daily sweep and conversation deletion all see the same durable journal reference before any live attachment event. No live grants, delays or client retries are involved; commit failure prevents success and delivery. Identical references already owned by the session need no additional entry. Ownership entries survive turn deletion and rewind, are excluded from the rewind's branch-linearity proof and can never be selected by leaf restoration; an attachment-only journal has no model branch. The original turn token survives the nested scope. Each share tool reserves its quota slot synchronously before any await and releases it on refusal or failure, so concurrent calls still admit at most four successful shares per tool per turn. Refused shares carry an error and its reason; transcript grouping retains each ShareImage/ShareFile path separately. Raw image bytes or a tool's echoed reference never become a second transcript authority. Ordinary nested image references are best-effort: storage failure or a full trace budget drops those references while preserving the successful original result and the latest retained image. Explicit share delivery reserves an admitted trace row and its exact bounded metadata through the existing runWithToolImages scope before ownership is committed. Outstanding reservations count synchronously against new rows, notes and optional display upgrades; settlement consumes them and failure releases them. Budget rejection grants no ownership and spends no successful turn allowance. Direct calls have no nested trace reservation. Delivery may omit optional timing and display fields; completed approval answers keep their existing mandatory-state exception to the display cap without revoking admitted shares. Off-branch ownership is attachment authority, while the accepted trace record is the visible delivery.
The guest accessibility contracts live in agent/accessibilityTypes.ts, shared by the adapter, commands and its internal owners without circular dependencies. The adapter delegates reference identity, stale descriptions, scoped retention and persistent allocation to agent/refRegistry.ts::RefRegistry. Its constructor reserves 1000 identifiers before any snapshot can expose one: write and fsync a private temporary file, close, rename, then fsync the state directory before advancing the counter. Every later block follows the same order; corrupt or unwritable state fails admission. Recorded snapshots retain at most 20,000 entries and 1000 recently dropped descriptions; unrecorded snapshots do not replace the map. agent/tableOverview.ts::TableOverview receives the adapter's typed property, call, node, address and bounded-read operations, not its bus or registry. It owns table dimensions, cell renderer text and the recorded visible overview, with the existing 60-row, 26-column and 12-call bounds; missing geometry yields dimensions only. Atspi.snapshot, cell resolution and cell text use this owner directly. Neither owner adds background work.
lib/desktopResult.mjs::DESKTOP_PROTOCOL_LIMITS owns shared wire and screenshot bounds. The guest CLI and control socket, Frames.png, daemon command validation and DesktopRuntime import it directly; guest builds bake those same values into both bundles. PNG bytes are capped at 3 MiB, derived base64 at 4 MiB, ordinary JSON at 1 MiB and screenshot JSON at base64 plus 64 KiB. The independent control request limit stays 1 MiB. Existing coordinate, 2048-pixel output edge and 0.25-to-4 scale bounds remain unchanged; actual input coordinates still use screen.json. Limits perform no work until their existing validators run. The Shell extension exposes only live ListWindows, Activate, HideOverview and GetCursor methods and cursor signals. The Chrome installer relies on package status, stderr and its systemd exit status; it writes no parallel status file.
The guest payload seam is desktopAgentFiles() in plugins/sandbox/guest/desktop/agent/payload.mjs. npm run build:desktop-agent produces the gitignored Node 24 agent and CLI bundles inside that plugin before the asset-copy step. Both the image builder and rootfsCatalog.mjs consume the same prebuilt bytes through desktopPayloadFiles(); daemon startup only reads and hashes them, with no compiler or npm dependency resolution. Missing bundles fail loudly and require a build, not a runtime fallback. Generated .mjs files participate in the existing copied-plugin dist-integrity check.
The guest capture (agent/frames.ts) exports the ScreenCast stream through pipewiresrc with stream.is-live=false and an unsynchronized fdsink: ScreenCast already paces frames, while live GstBaseSrc otherwise adds the first buffer's startup timestamp offset to every subsequent frame and waits for it, even when the sink has sync=false. This is an immediate raw-frame export, not an audio/video playback timeline. The sink does not retain its last sample and the source does not force a copy, so PipeWire can recycle its buffer after the write. The pipe still carries a complete BGRx frame; it does not carry PipeWire damage metadata. Two receive buffers exchange roles after each complete frame instead of copying it a second time. Native range comparisons reject unchanged 64-row bands, then unchanged scanlines, before comparing only unseen tiles of changed rows. Pixel bytes and per-tile generations remain exact, including partial edge tiles; RFB and screenshots use only the latest complete frame. Measured at 1920x1080 on one CPU with a maximized text editor, pointer-to-update median fell from 32.0 to 17.3 ms; a 15-second dense-text scroll increased from 16.5 to 18.0 updates/s while agent CPU fell from 25.1% to 22.8% of a core and capture GStreamer from 5.7% to 4.5%. Rendering still saturated the one-core quota; faster capture does not remove that limit.
The guest RFB server (agent/rfb.ts) sends damaged 64-pixel tiles losslessly as ZRLE. Adjacent damaged tiles are joined into rectangles (runs along a tile row, then equal runs stacked over the following rows), because each rectangle costs a header and one zlib flush, so a full-screen change is one rectangle. Inside it a tile whose pixels are all equal is sent as a solid ZRLE tile, and the byte-aligned 32-bit formats noVNC and TigerVNC request are converted by copying bytes rather than by per-pixel arithmetic. Measured on a 1920x1080 text screen this took a full update from 336 rectangles in about 52 ms to one rectangle in about 11 ms at a similar size; the default Quality mode retains full-resolution text. When a viewer offers Tight (7) and an RFB quality level, a large update may instead go as one whole-screen Tight JPEG at TigerVNC's quality for that level (the web client asks for level 8, JPEG quality 92). The JPEG is sent only when it is smaller than the lossless update is estimated to be: the estimate is the bytes per pixel the viewer's last ZRLE update measured, since a continuous ZRLE stream cannot be encoded and then withheld, so the first Quality update after connecting is always lossless, and no JPEG is tried while the estimate is below the last JPEG's size. One second after the last JPEG the tiles it carried are resent losslessly. The encoder (agent/jpeg.ts) is the image's GStreamer jpegenc (libjpeg-turbo) as one long-lived process per quality, fed whole BGRx frames converted to full-range 4:2:0 YCbCr; it stops with the last viewer, and a failed encoder leaves the session lossless and says so on stderr. The core transport already frames Tight JPEG. Measured on one CPU at 1920x1080: a scroll of a news page fell from 664 to 285 KiB and 32 to 19 ms, a full-screen photograph from 5136 to 960 KiB and 128 to 29 ms, while dense text such as a spreadsheet stays lossless in Quality when its q92 JPEG is larger.
The same guest RFB server recognizes quality level 2 plus DesktopSize or ExtendedDesktopSize support as Speed. Each viewer keeps its own mode and tile versions. It announces a 75% framebuffer after initial SetEncodings negotiation and on runtime changes, serialized after an in-flight update, preferring ExtendedDesktopSize when offered; ServerInit remains native-sized because the client has not advertised encodings yet. An already-pipelined native-size update request is clipped to the new bounds. Pointer input retains the preceding acknowledged geometry until the resize write completes and the peer requests the entire framebuffer at the announced dimensions; an old-size request or a cropped region is not an acknowledgement. Further mode changes wait for that acknowledgement, so rapid switches cannot bypass an intermediate geometry. Pointer events are mapped when parsed, preserving stream order even while their input actions queue. Coordinates outside the acknowledged bounds are clamped without disconnecting, and every button mask is processed so a release cannot leave a held button behind. In acknowledged Speed mode, pointer pixel centers map back with floor((coordinate + 0.5) × 4/3), keeping the native right and bottom edge reachable on complete 4-pixel groups; tools, screenshots, capture and the actual display stay native-sized. agent/scaled-frame.ts caches one BGRx image across Speed viewers and integrates only changed native 64px tiles into aligned 48px tiles, with exact integer 4-to-3 area weights and no pixel allocations. Dimensions are floor(native × 3/4), cropping the final incomplete source group. At 1920×1080 the view is 1440×810 and its cache costs about 4.45 MiB. A whole-screen request with at least one quarter of its pixels damaged goes directly as Tight JPEG q50, without Quality's lossless calibration or size comparison; smaller damage, cropped requests and the one-second calm-screen refresh stay lossless. Quality's encoding policy is unchanged. Separate lazy GStreamer encoders retain the two framebuffer geometries; both stop with the last viewer and an encoder failure disables JPEG for the session. Synthetic full-frame scaling on the host measured median 7.26 ms and p95 9.24 ms over 50 warmed samples; a single tile averaged 0.014 ms. These are scaler-only host timings, not a guest frame-rate guarantee. On the measured dense-text screen full-size JPEG q50 was 165 kB versus 306 kB lossless, motivating q50 plus the modest resolution reduction.
The guest captures monitor pixels with Mutter cursor mode 0. The GNOME extension exports the current cursor through io.elowen.Desktop1.GetCursor, CursorChanged and PointerChanged: bounded base64 RGBA pixels, hotspot, scale, monotonic sprite serial, position and visibility. CursorTracker signals drive it without polling; GNOME Shell's native Shell.Screenshot.composite_to_stream reads the Cogl sprite into an alpha-preserving GdkPixbuf, with concurrent shape changes coalesced. Export failures fail desktop admission rather than substituting a pointer. agent/cursor.ts validates and scales that state once. Clients requesting a local cursor receive its shape initially and on shape/visibility changes; their framebuffer has no cursor. The server prefers the offered VMware alpha cursor encoding 0x574d5664, whose fixed RGBA pixels preserve colour and fractional transparency. noVNC 1.7 offers it, whereas its -239 decoder assumes BGRx despite negotiating RGBx for the framebuffer. Clients offering only RFB Cursor -239 receive their negotiated pixel format with a row-padded binary alpha mask. Other viewers receive an alpha-composited private snapshot, with independent cursor tile damage for both old and new rectangles even when capture is idle. Agent screenshots and passive stills use the same composition before region cropping and scaling. Captured pixels are never modified. Core's one client filter retains both cursor encodings only for a connection whose authenticated ticket carries a control lease; claiming or returning control reconnects. Browser's x11vnc producer uses this same negotiation, drawing the remote pointer for watch-only clients and sending its shape for controllers. The existing unclaimed-surface CSS pointer rule stays unchanged. Sprite storage is bounded to 512 by 512 pixels and reads occur only on cursor changes; a shape-only RFB update copies no framebuffer. Session.watchPointer receives the cursor state and RFB server viewer-count callback. With a connected viewer, agent motion sends at most six eased absolute events over 180 ms and finishes exactly at the target before pressing; without viewers it is immediate. Drag uses that same motion path, not a second unconditional animation; without viewers its intermediate real events are still sent, but with no animation waits. Like a click, a drag waits 60 ms between reaching its start and pressing, 80 ms after the press before moving and 80 ms at the destination before releasing, because a toolkit that receives press, motion and release in one frame (GIMP on Wayland) registers no drag. Cancellation stops further motion before a press. Human RFB events bypass agent motion and feedback. Agent button press and release, including drag endpoints, trigger a 480 ms expanding, fading ring with 80 ms damage ticks and a final erase. A separate effect-damage plane admits that ring to both controller and watcher live frames without drawing the remote pointer for controllers. Screenshots and passive stills compose only the actual cursor, never the live click ring. The last viewer leaving cancels the effect timer.
Cursor composition also accepts a clean viewer-sized picture and its explicit scale. Speed's shared cache is built only from capture pixels; each viewer copies that cache before drawing its own pointer and live ring at 75% coordinates. The native cursor and effect damage arrays retain their native tile indices, which map exactly from 64px to 48px rectangles. Versions, cursor bytes and immutable composited pixels are captured before asynchronous JPEG, so changes during encoding stay pending. A pending cursor shape and a large Speed frame share one framebuffer update, preserving immediate q50 JPEG even for the controller's first frame. Controllers in both modes keep the native sprite dimensions and hotspot: noVNC's scaleViewport scales the framebuffer, while its cursor utility uses the received bitmap directly as a CSS cursor in local screen pixels. A mode change resets both pixel and cursor versions after the old-sized update completes.
The guest agent owns private state and sockets under /run/elowen-desktop. Boot and epoch changes fence the old controller, streams and in-flight commands; private credentials are never persisted or included in public projections. On start the agent removes a dead predecessor's state and temporary files before publishing its own session, so status never reports a crashed agent's epoch as ready. elowen-desktop --json status|ensure exits 0 whenever it printed a valid status, including failed with its reason; a non-zero exit means no valid status exists. Commands keep their GUI exit codes. The host passes every user-supplied argument after --, so text that starts with a dash is typed, never parsed as an option. Closing a control connection aborts its request, drops its queued requests and releases held keys and buttons, so a cancelled turn stops typing. Element references (e<n>) never repeat for the life of the container disk: the agent reserves blocks in an atomically written high-water file under StateDirectory=elowen-desktop and fails loudly when it cannot read or write it, so a reference from before an agent restart cannot hit a different control. Within one agent a reference stays bound to one element identity (accessibility address, role and label): a recorded snapshot reuses it when that element is seen again, replaces only the windows it covered and keeps the references of other open windows, and a reference whose element is gone fails stale_ref naming the element. desktop-open runs ensure before launching and launches with Type=exec, so a program that is missing or cannot be executed fails the command with a stderr message instead of exiting 0 with nothing on screen. It records the open windows (windows) before the launch and then waits up to 10 s for a new one (await-window), printing its title or that none appeared; both exit 0. await-window takes one known id per window, up to the 256 windows the compositor list admits. wait and text~= expectations share one matcher over the complete element list (labels and values), so text past the 400 listed entries or a terminal's last 15 lines is found, and the snapshot's own omission and diagnostic lines never satisfy a wait. Each transient user service has BindsTo and After on elowen-desktop-session.target, which is itself bound to and ordered after graphical-session.target. Stopping or losing the session stops the program's whole service cgroup, never restarting its old Wayland connection in the new shell. The shell unit's ExecStop stops the session target before terminating the compositor on an orderly stop; ExecStopPost also cleans up after a shell crash. This lifetime applies to every program launched through the helper, not only GTK applications, and does not claim ownership of programs launched elsewhere. The host adopts the newest guest observation in arrival order; an ensure that a concurrent status poll or first use overtook succeeds when the adopted desktop is admitted. Accessibility input, RFB, ticket, takeover, caption and narration seams remain producer-neutral, and the guest never branches on which program has focus. Printable ASCII is typed as keysyms; other text is pasted through the session clipboard with the shortcut the receiving element's generic AT-SPI role asks for (ctrl+shift+v for the terminal role, ctrl+v otherwise), read from the explicitly targeted element and used only when a paste is needed. A table with more than 1000 children is summarized from the generic Table and Component interfaces: the two viewport corner cells bound the visible slice (60 rows by 26 columns at most), falling back to walking the first row and column when a corner reports no cell, and every cell is read once with at most 12 in flight, the same bound the tree walk uses. Only a recorded snapshot builds that overview, because it is text the model reads: a reference check or selector lookup (an unrecorded snapshot) reads no cells, so revalidating a cell address in LibreOffice Calc costs one window walk instead of one plus roughly ten round trips per visible cell. A node reporting no children is never asked for them.
The typed machine gateway connectDesktop opens only the fixed guest connector through the existing privileged execution-session registry. op: desktop-connect and lifetime: connection remove the execution runtime ceiling only for that exact argv, Project ownership proof, empty environment and root cwd. Attach/handshake deadlines, capacity, cancellation intent and verified guest settlement remain unchanged. Ordinary executions retain their deadlines. No daemon code dials a guest-writable host socket.
Owning code
src/desktop/:DisplayController(controller.ts),DesktopRuntime(runtime.ts), the RFB filters (rfb-filter.ts,rfb-server-filter.ts), the command schema (commandSchema.ts) and the shared types (types.ts).src/plugins/api.ts:PluginContext.registerDesktopProvider, the only contribution path.src/subagent/hostRpc.ts: thedesktop.commandRPC and thehostCancelmessage that runners use.plugins/sandbox/lib/: the Sandbox producer.desktopRuntime.mjsregisters the provider and holds the stop logic.desktopApi.mjsholds the human-only routes,desktopTools.mjstheDesktoptool,desktopCommands.mjsthe screenshot scaling,desktopResult.mjsanddesktopSelectors.mjsthe shared parsers,rootfsCatalog.mjsthe admission catalog anddesktopDb.mjsthe migrations.plugins/sandbox/guest/desktop/agent/: the guest agent.frames.tscaptures,rfb.tsserves RFB,jpeg.tsencodes,scaled-frame.tsscales,cursor.tscomposes the pointer,input.tsinjects input,accessibility.tsreads the element tree andcommands.tsruns the CLI.screen.jsonholds the geometry.plugins/code-mode/src/protocol/observation.ts: themodelObservationseam.src/brain/tools/shareImageTool.ts,src/brain/tools/shareFileTool.ts,src/store/brainStore.ts(recordSharedAttachment) andsrc/plugins/policyContext.ts(runWithToolImages): nested sharing.
Minimal use
A producer registers once with the host. Sandbox's registration is the reference (plugins/sandbox/lib/desktopRuntime.mjs:369-380, abbreviated):
const desktop = ctx.registerDesktopProvider({
clipboard: 'disabled',
initialControl: 'unclaimed',
subscribe(sessionId, send) { /* return an unsubscribe function */ },
prepare: async (session, userId, signal, projectId, kind) => { /* optional */ },
});
The full contract is DesktopProvider in src/desktop/types.ts. The Browser plugin in the registry also references this seam (browser/src/index.ts). It uses the shared X11 helper in packages/plugin-shared/x11Desktop.mjs.
Absence, limits and cost
- No provider means no desktop surface. A
preparehook that is absent means nothing is prepared before use. - Limits: a screenshot is capped at 3 MiB (
plugins/sandbox/guest/desktop/agent/frames.ts:176). A passive still is capped at 480 by 300 pixels and 1 MiB (plugins/sandbox/lib/desktopRuntime.mjs:451,plugins/sandbox/lib/desktopRuntime.mjs:467). - Element reads are bounded: 400 listed entries (
accessibility.ts:67), 256 windows (plugins/sandbox/guest/desktop/agent/protocol.ts:71), 1000 children before a table summary, a visible slice of at most 60 rows by 26 columns, and at most 12 concurrent reads (accessibility.ts:214-216,accessibility.ts:321-323,accessibility.ts:345-349). - Shares: at most four successful
ShareImagecalls per tool per turn (src/brain/tools/shareImageTool.ts:19). - Cost: each managed Project runs one guest desktop on the Project's single-core quota. The memory and frame-rate figures above are measurements taken on the reference host, not guarantees.
Real consumers
- Sandbox (bundled): the guest-backed producer, GNOME 50 on Wayland inside each managed Project.
- Browser (registry): the host Chrome producer on X11, which draws the remote pointer for watch-only viewers.
lib/desktopLimits.mjs::DESKTOP_PROTOCOL_LIMITS owns shared wire and screenshot bounds in a dependency-free module. Import it directly as import { DESKTOP_PROTOCOL_LIMITS as limits } from './desktopLimits.mjs'; the guest CLI and control socket, Frames.png, daemon command validation and DesktopRuntime consume the same frozen object. Guest builds bake those values into both bundles. Importing limits does not construct the result parser; lib/desktopResult.mjs continues to own result validation and failure construction and does not re-export limits. PNG bytes are capped at 3 MiB, derived base64 at 4 MiB, ordinary JSON at 1 MiB and screenshot JSON at base64 plus 64 KiB. The independent control request limit stays 1 MiB. Existing coordinate, 2048-pixel output edge and 0.25-to-4 scale bounds remain unchanged; actual input coordinates still use screen.json. Limits perform no validation or background work on their own; without a consumer, no bound is enforced. Existing validators apply them when processing a command, frame or socket message. The Shell extension exposes only live ListWindows, Activate, HideOverview and GetCursor methods and cursor signals. The Chrome installer relies on package status, stderr and its systemd exit status; it writes no parallel status file.