From 640e7fc1bf4e45ea0e3957fc09d1fb5edc5cd6ae Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Thu, 13 Aug 2026 17:49:18 -0300 Subject: [PATCH 01/32] add secure Linux screen capture contract --- electron/capabilities.cjs | 15 +++++-- electron/capabilities.test.mjs | 27 ++++++++++++- electron/main.mjs | 62 +++++++++++++++++++++++----- electron/preload.cjs | 2 + electron/screen-preview.cjs | 61 ++++++++++++++++++++++++++++ electron/screen-preview.test.mjs | 69 ++++++++++++++++++++++++++++++++ package.json | 2 +- src/types/ogb.d.ts | 2 + 8 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 electron/screen-preview.cjs create mode 100644 electron/screen-preview.test.mjs diff --git a/electron/capabilities.cjs b/electron/capabilities.cjs index 7ec7a75c..685807d1 100644 --- a/electron/capabilities.cjs +++ b/electron/capabilities.cjs @@ -35,6 +35,8 @@ function desktopCapabilities({ } = {}) { const hostPlatform = normalizedPlatform(platform); const isMac = hostPlatform === "darwin"; + const hostSession = linuxSession(hostPlatform, env); + const linuxPreview = hostPlatform === "linux" && hostSession !== "headless"; const localAvailable = localComputerReady(hostPlatform, localConnection); return { @@ -48,14 +50,19 @@ function desktopCapabilities({ : hostPlatform === "win32" ? "Windows" : "Desktop", - session: linuxSession(hostPlatform, env), + session: hostSession, packaged: Boolean(packaged), }, windowChrome: isMac ? "mac-inset" : "native", screenPreview: { - available: isMac, - interaction: isMac ? "direct" : "none", - ...(!isMac ? { reasonCode: "unsupported-platform" } : {}), + available: isMac || linuxPreview, + interaction: isMac || hostSession === "x11" ? "direct" : hostSession === "wayland" ? "portal-picker" : "none", + ...(!(isMac || linuxPreview) + ? { + reasonCode: + hostPlatform === "linux" ? "headless-session" : "unsupported-platform", + } + : {}), }, dictation: { available: isMac, diff --git a/electron/capabilities.test.mjs b/electron/capabilities.test.mjs index a51f0f12..b7239762 100644 --- a/electron/capabilities.test.mjs +++ b/electron/capabilities.test.mjs @@ -21,7 +21,7 @@ describe("desktop capabilities", () => { }); }); - it.each(["linux", "win32", "freebsd"])("fails closed on %s", (platform) => { + it.each(["win32", "freebsd"])("fails closed on %s", (platform) => { const capabilities = desktopCapabilities({ platform, env: { DISPLAY: ":0" }, @@ -38,6 +38,31 @@ describe("desktop capabilities", () => { }); }); + it("offers direct Xorg preview without enabling local control", () => { + const capabilities = desktopCapabilities({ + platform: "linux", + env: { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }, + localConnection: { mode: "embedded" }, + }); + + expect(capabilities.screenPreview).toEqual({ available: true, interaction: "direct" }); + expect(capabilities.localComputer.available).toBe(false); + }); + + it("offers portal-mediated Wayland preview and fails closed when headless", () => { + expect( + desktopCapabilities({ + platform: "linux", + env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0", DISPLAY: ":0" }, + }).screenPreview, + ).toEqual({ available: true, interaction: "portal-picker" }); + expect(desktopCapabilities({ platform: "linux", env: {} }).screenPreview).toEqual({ + available: false, + interaction: "none", + reasonCode: "headless-session", + }); + }); + it("detects Wayland before XWayland and distinguishes X11 and headless Linux", () => { expect(linuxSession("linux", { WAYLAND_DISPLAY: "wayland-0", DISPLAY: ":0" })).toBe("wayland"); expect(linuxSession("linux", { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" })).toBe("x11"); diff --git a/electron/main.mjs b/electron/main.mjs index 083ef6f7..ff3aa21b 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -1,4 +1,5 @@ -import { app, BrowserWindow, clipboard, desktopCapturer, ipcMain, session, shell, systemPreferences, utilityProcess } from "electron"; +import { app, BrowserWindow, clipboard, desktopCapturer, ipcMain, screen, session, shell, systemPreferences, utilityProcess } from "electron"; +import { createRequire } from "node:module"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,6 +10,8 @@ import { startUpdater, registerUpdaterIpc } from "./updater.mjs"; import capabilitiesModule from "./capabilities.cjs"; const { desktopCapabilities } = capabilitiesModule; +const require = createRequire(import.meta.url); +const { createDisplayMediaGuard, selectCaptureSource } = require("./screen-preview.cjs"); const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 127.0.0.1 explicitly — vite binds IPv4; a bare "localhost" here can @@ -117,6 +120,16 @@ const ERROR_PAGE = ); let cuaReady = Promise.resolve({ mode: "unavailable", reason: "not-started" }); +const displayMediaGuard = createDisplayMediaGuard(); +let displayMediaRequestCount = 0; + +function rendererOrigin() { + return new URL(app.isPackaged ? `http://127.0.0.1:${SERVER_PORT}` : DEV_URL).origin; +} + +ipcMain.on("screen:preview-intent", (event) => { + event.returnValue = displayMediaGuard.begin(event.senderFrame); +}); function createWindow() { const isMac = process.platform === "darwin"; @@ -179,6 +192,7 @@ function createWindow() { `unexpected packaged renderer URL: ${result.location} (expected ${expectedLocation})`, ); } + result.displayMediaRequests = displayMediaRequestCount; console.log(`[smoke] renderer-ready ${JSON.stringify(result)}`); } catch (error) { console.error(`[smoke] renderer-failed ${error?.stack ?? error}`); @@ -196,7 +210,7 @@ function createWindow() { return win; } -// "This Mac" screen preview — served from the main process so the Screen +// Local-control screen preview — served from the main process so the Screen // Recording permission prompt attributes to the app, never the server ipcMain.handle("screen:frame", async () => { if (process.platform !== "darwin") return null; @@ -287,16 +301,44 @@ ipcMain.handle("desktop:capabilities", async () => app.whenReady().then(async () => { if (process.platform === "darwin") app.dock.setIcon(APP_ICON); - // getDisplayMedia in the renderer → this handler → ScreenCaptureKit, all - // inside the app's own processes — the one capture path macOS reliably - // attributes to the app (registers it in the Screen Recording pane and - // prompts). Used by the onboarding "Enable screen preview" button. - if (process.platform === "darwin") { + // Display capture remains user-initiated. The renderer first sends a + // short-lived one-shot intent, then calls getDisplayMedia in the same click. + // The handler binds that request to the same frame/origin, rejects audio, + // and requires Electron's active user-gesture signal. + if (process.platform === "darwin" || process.platform === "linux") { session.defaultSession.setDisplayMediaRequestHandler( - (_request, callback) => { + (request, callback) => { + displayMediaRequestCount += 1; + if (!displayMediaGuard.consume(request, rendererOrigin())) { + callback({}); + return; + } + + const capabilities = desktopCapabilities({ + platform: process.platform, + env: process.env, + packaged: app.isPackaged, + }); + const captureHost = + process.platform === "darwin" ? "darwin" : capabilities.host.session; + if (!capabilities.screenPreview.available) { + callback({}); + return; + } + desktopCapturer - .getSources({ types: ["screen"] }) - .then((sources) => callback(sources[0] ? { video: sources[0] } : {})) + .getSources({ types: ["screen"], thumbnailSize: { width: 0, height: 0 } }) + .then((sources) => { + const source = selectCaptureSource({ + sources, + host: captureHost, + primaryDisplayId: + process.platform === "linux" && captureHost === "x11" + ? screen.getPrimaryDisplay().id + : null, + }); + callback(source ? { video: source } : {}); + }) .catch(() => callback({})); }, { useSystemPicker: false }, diff --git a/electron/preload.cjs b/electron/preload.cjs index d649d623..a7dfbaca 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -6,6 +6,8 @@ contextBridge.exposeInMainWorld("ogb", { /** Host platform ("darwin" | "win32" | "linux") — for platform-aware UI. */ platform: process.platform, getCapabilities: () => ipcRenderer.invoke("desktop:capabilities"), + /** Arms exactly one display-media request from the current renderer frame. */ + beginScreenPreviewIntent: () => ipcRenderer.sendSync("screen:preview-intent"), /** One frame of this computer's screen as a data: URL when supported. */ screenFrame: () => ipcRenderer.invoke("screen:frame"), speechStart: (options) => ipcRenderer.invoke("speech:start", options), diff --git a/electron/screen-preview.cjs b/electron/screen-preview.cjs new file mode 100644 index 00000000..dad59281 --- /dev/null +++ b/electron/screen-preview.cjs @@ -0,0 +1,61 @@ +function frameKey(frame) { + if (!Number.isInteger(frame?.processId) || !Number.isInteger(frame?.routingId)) return null; + return `${frame.processId}:${frame.routingId}`; +} + +function originOf(value) { + try { + return new URL(value).origin; + } catch { + return null; + } +} + +function createDisplayMediaGuard({ now = Date.now, ttlMs = 5_000 } = {}) { + const intents = new Map(); + + function prune() { + const current = now(); + for (const [key, expiresAt] of intents) { + if (expiresAt < current) intents.delete(key); + } + } + + return Object.freeze({ + begin(frame) { + const key = frameKey(frame); + if (!key) return false; + prune(); + intents.set(key, now() + ttlMs); + return true; + }, + + consume(request, expectedOrigin) { + const key = frameKey(request?.frame); + if (!key) return false; + const expiresAt = intents.get(key); + intents.delete(key); + + return Boolean( + expiresAt !== undefined && + expiresAt >= now() && + request.userGesture === true && + request.videoRequested === true && + request.audioRequested === false && + originOf(request.securityOrigin) === originOf(expectedOrigin), + ); + }, + }); +} + +function selectCaptureSource({ sources, host, primaryDisplayId }) { + if (!Array.isArray(sources) || sources.length === 0) return null; + if (host === "wayland") return sources.length === 1 ? sources[0] : null; + if (host === "x11") { + return sources.find((source) => String(source.display_id) === String(primaryDisplayId)) ?? null; + } + if (host === "darwin") return sources[0]; + return null; +} + +module.exports = { createDisplayMediaGuard, frameKey, originOf, selectCaptureSource }; diff --git a/electron/screen-preview.test.mjs b/electron/screen-preview.test.mjs new file mode 100644 index 00000000..67a67272 --- /dev/null +++ b/electron/screen-preview.test.mjs @@ -0,0 +1,69 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { createDisplayMediaGuard, selectCaptureSource } = require("./screen-preview.cjs"); + +const frame = { processId: 10, routingId: 20 }; +const validRequest = { + frame, + securityOrigin: "http://127.0.0.1:8799", + videoRequested: true, + audioRequested: false, + userGesture: true, +}; + +describe("display media request guard", () => { + it("allows one trusted, video-only request from the frame that declared intent", () => { + const guard = createDisplayMediaGuard({ now: () => 1_000 }); + + expect(guard.begin(frame)).toBe(true); + expect(guard.consume(validRequest, "http://127.0.0.1:8799/")).toBe(true); + expect(guard.consume(validRequest, "http://127.0.0.1:8799/")).toBe(false); + }); + + it.each([ + ["missing user gesture", { userGesture: false }], + ["audio capture", { audioRequested: true }], + ["missing video", { videoRequested: false }], + ["untrusted origin", { securityOrigin: "https://example.com" }], + ["different frame", { frame: { processId: 10, routingId: 21 } }], + ])("rejects %s", (_name, change) => { + const guard = createDisplayMediaGuard({ now: () => 1_000 }); + guard.begin(frame); + + expect( + guard.consume({ ...validRequest, ...change }, "http://127.0.0.1:8799"), + ).toBe(false); + }); + + it("rejects an expired intent", () => { + let current = 1_000; + const guard = createDisplayMediaGuard({ now: () => current, ttlMs: 500 }); + guard.begin(frame); + current = 1_501; + + expect(guard.consume(validRequest, "http://127.0.0.1:8799")).toBe(false); + }); +}); + +describe("display source selection", () => { + const sources = [ + { id: "first", display_id: "41" }, + { id: "primary", display_id: "42" }, + ]; + + it("matches the Xorg primary display instead of choosing the first source", () => { + expect(selectCaptureSource({ sources, host: "x11", primaryDisplayId: 42 })).toEqual( + sources[1], + ); + expect(selectCaptureSource({ sources, host: "x11", primaryDisplayId: 99 })).toBeNull(); + }); + + it("accepts only the single portal-selected Wayland source", () => { + expect( + selectCaptureSource({ sources: [sources[0]], host: "wayland", primaryDisplayId: 42 }), + ).toEqual(sources[0]); + expect(selectCaptureSource({ sources, host: "wayland", primaryDisplayId: 42 })).toBeNull(); + }); +}); diff --git a/package.json b/package.json index ad703f7e..9881c081 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "test:watch": "vitest", "test:cua": "pnpm build:cua && node scripts/smoke-cua.mjs", "test:cua-container": "node scripts/smoke-cua-container.mjs", - "check:electron": "node --check electron/main.mjs && node --check electron/terminal-launch.mjs && node --check electron/preload.cjs && node --check electron/capabilities.cjs && node --check electron/cua-connection.cjs && node --check electron/cua.mjs && node --check electron/speech.mjs", + "check:electron": "node --check electron/main.mjs && node --check electron/terminal-launch.mjs && node --check electron/preload.cjs && node --check electron/capabilities.cjs && node --check electron/cua-connection.cjs && node --check electron/cua.mjs && node --check electron/screen-preview.cjs && node --check electron/speech.mjs", "preview": "vite preview", "build:server": "tsc -p tsconfig.server.build.json", "build:speech": "node electron/build-speech-helper.mjs", diff --git a/src/types/ogb.d.ts b/src/types/ogb.d.ts index c798ab21..b62d1788 100644 --- a/src/types/ogb.d.ts +++ b/src/types/ogb.d.ts @@ -32,6 +32,8 @@ declare global { ogb?: { platform: NodeJS.Platform; getCapabilities(): Promise; + /** Arms one user-initiated display capture request from this frame. */ + beginScreenPreviewIntent(): boolean; screenFrame(): Promise; /** Start native dictation. Call mode supplies endpointMs so silence * finalizes a turn; composer dictation omits it and remains manual. */ From 40ab011ceb694d2bb398226c3cffe19f95256fcd Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Thu, 13 Aug 2026 17:49:24 -0300 Subject: [PATCH 02/32] add preview-only Linux screen UI --- src/components/ComputerPanel.tsx | 5 +- src/components/LocalScreenPreview.tsx | 192 ++++++++++++++++++++++++++ src/lib/screen-preview.test.ts | 69 +++++++++ src/lib/screen-preview.ts | 67 +++++++++ 4 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 src/components/LocalScreenPreview.tsx create mode 100644 src/lib/screen-preview.test.ts create mode 100644 src/lib/screen-preview.ts diff --git a/src/components/ComputerPanel.tsx b/src/components/ComputerPanel.tsx index 23715f04..6412370b 100644 --- a/src/components/ComputerPanel.tsx +++ b/src/components/ComputerPanel.tsx @@ -1,6 +1,6 @@ // The bot's computer, in the right-side slot. Where it runs decides the // whole flow: cloud → provision the box on open (idempotent) and preview -// via SSE frames or a ~4s screenshot poll; local ("This Mac") → frames +// via SSE frames or a ~4s screenshot poll; local ("This computer") → frames // come from the Electron main process (desktopCapturer over the preload // bridge — box endpoints are never touched); off → parked. Auto (unset) // prefers the cloud box when one exists, else local inside the app. @@ -23,6 +23,7 @@ import { ApiKeyRow } from "./ApiKeys"; import { cn } from "@/lib/cn"; import { useDesktopCapabilities } from "./DesktopCapabilities"; import { RoutineEditor } from "./RoutinesPage"; +import { LocalScreenPreview } from "./LocalScreenPreview"; async function api(path: string, init?: RequestInit): Promise { const res = await fetch(path, { headers: { "content-type": "application/json" }, ...init }); @@ -434,6 +435,8 @@ export function ComputerPanel({ bot }: { bot: Bot }) { )} + + {/* Computer source */}
Runs on
diff --git a/src/components/LocalScreenPreview.tsx b/src/components/LocalScreenPreview.tsx new file mode 100644 index 00000000..01078988 --- /dev/null +++ b/src/components/LocalScreenPreview.tsx @@ -0,0 +1,192 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Loader2, Monitor, RotateCcw, Square } from "lucide-react"; + +import { requestScreenPreview, stopScreenPreview } from "@/lib/screen-preview"; +import { useDesktopCapabilities } from "./DesktopCapabilities"; + +type PreviewPhase = + | "idle" + | "requesting" + | "streaming" + | "cancelled" + | "ended" + | "unavailable" + | "error"; + +const phaseCopy: Record, string> = { + idle: "Start a private, view-only preview when you need it.", + cancelled: "Screen selection was cancelled. Nothing is being shared.", + ended: "Screen sharing ended. Nothing is being shared.", + unavailable: "Screen preview isn't available in this desktop session.", + error: "Couldn't start screen preview.", +}; + +export function LocalScreenPreview() { + const { capabilities, ready } = useDesktopCapabilities(); + const preview = capabilities.screenPreview; + const isLinux = capabilities.host.platform === "linux"; + const [phase, setPhase] = useState("idle"); + const [message, setMessage] = useState(phaseCopy.idle); + const [sourceLabel, setSourceLabel] = useState("Selected screen"); + const videoRef = useRef(null); + const streamRef = useRef(null); + const requestId = useRef(0); + + const releaseStream = useCallback((nextPhase: PreviewPhase, nextMessage: string) => { + requestId.current += 1; + const stream = streamRef.current; + streamRef.current = null; + if (videoRef.current) videoRef.current.srcObject = null; + stopScreenPreview(stream); + setPhase(nextPhase); + setMessage(nextMessage); + }, []); + + useEffect( + () => () => { + requestId.current += 1; + const stream = streamRef.current; + streamRef.current = null; + stopScreenPreview(stream); + }, + [], + ); + + const start = async () => { + if ( + !preview.available || + !window.ogb?.beginScreenPreviewIntent || + !navigator.mediaDevices?.getDisplayMedia + ) { + setPhase("unavailable"); + setMessage(phaseCopy.unavailable); + return; + } + + releaseStream("requesting", "Waiting for screen selection…"); + const currentRequest = requestId.current; + const result = await requestScreenPreview({ + beginIntent: () => window.ogb!.beginScreenPreviewIntent(), + getDisplayMedia: (constraints) => navigator.mediaDevices.getDisplayMedia(constraints), + }); + + if (currentRequest !== requestId.current) { + if (result.ok) stopScreenPreview(result.stream); + return; + } + if (!result.ok) { + setPhase(result.phase); + setMessage(result.message); + return; + } + + const stream = result.stream; + const videoTrack = stream.getVideoTracks()[0]; + streamRef.current = stream; + setSourceLabel(videoTrack.label || "Selected screen"); + videoTrack.addEventListener( + "ended", + () => { + if (streamRef.current !== stream) return; + releaseStream("ended", phaseCopy.ended); + }, + { once: true }, + ); + if (videoRef.current) { + videoRef.current.srcObject = stream; + void videoRef.current.play().catch(() => {}); + } + setPhase("streaming"); + setMessage("Preview active. The bot still cannot control this computer."); + }; + + if (!isLinux) return null; + const retry = + phase === "cancelled" || phase === "ended" || phase === "unavailable" || phase === "error"; + + return ( +
+
+
+
+ Preview this computer +
+
+ Preview only — the bot cannot control this computer. +
+
+ + Preview only + +
+ +
+
+ + {phase === "streaming" && ( +
+ + {preview.interaction === "portal-picker" ? sourceLabel : "This computer"} + + + Sharing + +
+ )} + + +
+ ); +} diff --git a/src/lib/screen-preview.test.ts b/src/lib/screen-preview.test.ts new file mode 100644 index 00000000..e36af1b5 --- /dev/null +++ b/src/lib/screen-preview.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + requestScreenPreview, + screenPreviewFailure, + stopScreenPreview, +} from "./screen-preview"; + +function fakeStream(videoTracks = 1, totalTracks = videoTracks) { + const tracks = Array.from({ length: totalTracks }, () => ({ stop: vi.fn() })); + return { + stream: { + getTracks: () => tracks, + getVideoTracks: () => tracks.slice(0, videoTracks), + } as unknown as MediaStream, + tracks, + }; +} + +describe("screen preview request", () => { + it("does nothing until start is called, then arms intent before requesting video-only media", async () => { + const beginIntent = vi.fn(() => true); + const { stream } = fakeStream(); + const getDisplayMedia = vi.fn(async () => stream); + + expect(beginIntent).not.toHaveBeenCalled(); + expect(getDisplayMedia).not.toHaveBeenCalled(); + + await expect(requestScreenPreview({ beginIntent, getDisplayMedia })).resolves.toEqual({ + ok: true, + stream, + }); + expect(beginIntent).toHaveBeenCalledOnce(); + expect(beginIntent.mock.invocationCallOrder[0]).toBeLessThan( + getDisplayMedia.mock.invocationCallOrder[0], + ); + expect(getDisplayMedia).toHaveBeenCalledWith({ video: true, audio: false }); + }); + + it("stops a stream that contains no video track", async () => { + const { stream, tracks } = fakeStream(0, 1); + const result = await requestScreenPreview({ + beginIntent: vi.fn(() => true), + getDisplayMedia: vi.fn(async () => stream), + }); + + expect(result).toMatchObject({ ok: false, phase: "unavailable" }); + for (const track of tracks) expect(track.stop).toHaveBeenCalledOnce(); + }); + + it("does not request media when the desktop host rejects preview intent", async () => { + const getDisplayMedia = vi.fn(); + + await expect( + requestScreenPreview({ beginIntent: () => false, getDisplayMedia }), + ).resolves.toMatchObject({ ok: false, phase: "unavailable" }); + expect(getDisplayMedia).not.toHaveBeenCalled(); + }); + + it("normalizes a cancelled chooser and stops every live track", () => { + expect(screenPreviewFailure(new DOMException("cancelled", "AbortError"))).toMatchObject({ + ok: false, + phase: "cancelled", + }); + const { stream, tracks } = fakeStream(2); + stopScreenPreview(stream); + for (const track of tracks) expect(track.stop).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/screen-preview.ts b/src/lib/screen-preview.ts new file mode 100644 index 00000000..1f343d6e --- /dev/null +++ b/src/lib/screen-preview.ts @@ -0,0 +1,67 @@ +export type ScreenPreviewFailurePhase = "cancelled" | "unavailable" | "error"; + +export type ScreenPreviewStartResult = + | { ok: true; stream: MediaStream } + | { ok: false; phase: ScreenPreviewFailurePhase; message: string }; + +type ScreenPreviewRequest = { + beginIntent: () => boolean; + getDisplayMedia: (constraints: DisplayMediaStreamOptions) => Promise; +}; + +export function stopScreenPreview(stream: Pick | null) { + for (const track of stream?.getTracks() ?? []) track.stop(); +} + +export function screenPreviewFailure(error: unknown): Exclude { + const name = error instanceof DOMException ? error.name : ""; + if (name === "NotAllowedError" || name === "AbortError") { + return { + ok: false, + phase: "cancelled", + message: "Screen selection was cancelled. Nothing is being shared.", + }; + } + if ( + name === "NotFoundError" || + name === "NotReadableError" || + name === "NotSupportedError" || + name === "SecurityError" + ) { + return { + ok: false, + phase: "unavailable", + message: "Screen preview isn't available right now.", + }; + } + return { ok: false, phase: "error", message: "Couldn't start screen preview." }; +} + +export async function requestScreenPreview({ + beginIntent, + getDisplayMedia, +}: ScreenPreviewRequest): Promise { + try { + // Keep these synchronous and adjacent so Chromium sees the media request + // in the same user gesture that armed the one-shot main-process intent. + if (!beginIntent()) { + return { + ok: false, + phase: "unavailable", + message: "Screen preview isn't available from this window.", + }; + } + const stream = await getDisplayMedia({ video: true, audio: false }); + if (stream.getVideoTracks().length === 0) { + stopScreenPreview(stream); + return { + ok: false, + phase: "unavailable", + message: "The selected source did not provide a video stream.", + }; + } + return { ok: true, stream }; + } catch (error) { + return screenPreviewFailure(error); + } +} From d2a1463dc3cfc7948850e6c84e6b1248c0df091f Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Thu, 13 Aug 2026 17:49:29 -0300 Subject: [PATCH 03/32] document and smoke-test Linux screen preview --- README.md | 8 +++++--- docs/linux-desktop.md | 34 ++++++++++++++++++++++++++------- scripts/smoke-linux-package.mjs | 7 ++++++- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 767ea110..93d4df22 100644 --- a/README.md +++ b/README.md @@ -216,11 +216,13 @@ pnpm package:linux # Ubuntu x64: .deb + AppImage; no Swift required |---|---|---|---| | Packaged app, embedded harness, local agent CLIs | Supported | Beta | Beta | | Composio and Box/cloud computers | Supported | Beta | Beta | -| Local screen preview and computer control | Supported | Planned | Planned after compositor validation | +| Explicit preview-only local screen capture | Supported | Beta | Pending GNOME portal validation | +| Bot control of this computer | Supported | Planned | Planned after compositor validation | | Native on-device dictation | Supported | Planned | Planned | -Unavailable native features fail closed on Ubuntu without blocking chat or cloud features. Linux local computer -control, Wayland capture/automation, dictation, and ARM64 are tracked in +The Linux preview is user-initiated and never enables local bot control or Auto routing. Unavailable native +features fail closed without blocking chat or cloud features. Linux local computer control, Wayland automation, +dictation, and ARM64 are tracked in [#29](https://github.com/milind-soni/OpenMausBot/issues/29) and are not claimed by the baseline package. These credentials are optional — local chat works without them. Paste a key once in **App Settings** (gear diff --git a/docs/linux-desktop.md b/docs/linux-desktop.md index 1bd53b3f..3f02f2c5 100644 --- a/docs/linux-desktop.md +++ b/docs/linux-desktop.md @@ -10,11 +10,13 @@ installed builds do not require Node, pnpm, Swift, or a terminal at runtime. - Chat, streaming turns, approvals, bot-to-bot communication, and local data storage. - Composio connected apps and Box cloud computers. - External documentation and OAuth links in the default browser. +- An explicit, view-only local screen preview on GNOME Xorg, with a Wayland portal implementation pending + final real-session validation. -The first beta intentionally does **not** claim Linux dictation, local screen preview, or control of this -computer. Those controls are unavailable in the UI and fail closed in the Electron and server layers. Use a -Cloud box when a bot needs a computer. Xorg computer control, Wayland validation, bundled CUA, dictation, and -ARM64 are follow-ups in [issue #29](https://github.com/milind-soni/OpenMausBot/issues/29). +The local preview does **not** give the bot control of this computer. Linux dictation and local computer +control remain unavailable and fail closed in the Electron, server, and UI layers. Use a Cloud box when a bot +needs a computer it can act on. Xorg computer control, Wayland automation, bundled CUA, dictation, and ARM64 +are follow-ups in [issue #29](https://github.com/milind-soni/OpenMausBot/issues/29). ## Build packages @@ -105,9 +107,21 @@ Restart OpenMausBot after installing or signing in to a CLI. ## Xorg and Wayland -The baseline shell, chat, cloud computers, and connected apps work in both GNOME session types. OpenMausBot -detects Wayland before XWayland when both `WAYLAND_DISPLAY` and `DISPLAY` exist, so future capture features do -not accidentally bypass portal-mediated behavior. +The shell, chat, cloud computers, and connected apps work in both GNOME session types. Preview-only capture is +validated on Xorg; its Wayland portal path is implemented but remains a release candidate until the complete +chooser/cancel/end matrix passes in a real GNOME Wayland session. OpenMausBot detects Wayland before XWayland +when both `WAYLAND_DISPLAY` and `DISPLAY` exist, so capture cannot accidentally bypass portal-mediated behavior. + +Open the Computer panel and use the separate **Preview this computer** card. Capture never starts when the app +or panel opens. + +- **Xorg:** **Start preview** captures the primary monitor directly. +- **Wayland:** **Choose a screen** opens the GNOME portal chooser once. The selected stream stays open until + you press **Stop preview**, close the panel, end sharing from GNOME, or quit the app. + +Cancelling or ending Wayland sharing returns to a calm **Try again** state and never reopens the chooser +automatically. OpenMausBot does not capture screen audio, remember the selected monitor after restart, or +offer an **Open Settings** action on Linux. Local computer control remains disabled on both session types in this beta. Future Xorg support will require a validated `cua-driver`; Wayland support will remain disabled until the exact GNOME/Mutter action surface has @@ -142,6 +156,12 @@ considered for automatic discovery. Choose **Cloud box** in the Computer panel and add a Box token in App Settings. **This computer** is disabled on Linux until local CUA control is implemented and validated. +### Screen preview does not start + +On Xorg, confirm the session has an active display with `echo "$XDG_SESSION_TYPE"`; it should print `x11`. +On Wayland, confirm `xdg-desktop-portal` and the GNOME portal backend are running, then click **Try again** to +open a new chooser. Cancelling or stopping sharing never causes an automatic second prompt. + ### The AppImage does not start Confirm the executable bit and architecture: diff --git a/scripts/smoke-linux-package.mjs b/scripts/smoke-linux-package.mjs index 08161c67..6382f54e 100644 --- a/scripts/smoke-linux-package.mjs +++ b/scripts/smoke-linux-package.mjs @@ -84,14 +84,19 @@ async function stopProcess() { try { const result = await until(async () => smokeResult, "the packaged renderer smoke result"); - const { capabilities, health, location, title } = result; + const { capabilities, displayMediaRequests, health, location, title } = result; if (health?.app !== "openmausbot" || health.static !== true) { throw new Error(`unexpected embedded health response: ${JSON.stringify(health)}`); } if (!String(title).includes("OpenMausBot")) throw new Error(`unexpected renderer title: ${title}`); if (capabilities.host.platform !== "linux") throw new Error("renderer did not report Linux"); + if (capabilities.host.session !== "x11") throw new Error("Xvfb did not report an X11 session"); + if (!capabilities.screenPreview.available || capabilities.screenPreview.interaction !== "direct") { + throw new Error("X11 screen preview capability was not available"); + } if (capabilities.dictation.available) throw new Error("dictation must be unavailable on Linux"); if (capabilities.localComputer.available) throw new Error("local control must be unavailable on Linux"); + if (displayMediaRequests !== 0) throw new Error("launch triggered display capture without user intent"); if (existsSync(marker)) throw new Error("Linux executed the CUA sentinel"); await waitForExit(); From a5b720c99eacdabb4609765b3f1869ca896720cb Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Thu, 13 Aug 2026 18:44:38 -0300 Subject: [PATCH 04/32] document validated Wayland preview --- README.md | 2 +- docs/linux-desktop.md | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 93d4df22..263b357e 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ pnpm package:linux # Ubuntu x64: .deb + AppImage; no Swift required |---|---|---|---| | Packaged app, embedded harness, local agent CLIs | Supported | Beta | Beta | | Composio and Box/cloud computers | Supported | Beta | Beta | -| Explicit preview-only local screen capture | Supported | Beta | Pending GNOME portal validation | +| Explicit preview-only local screen capture | Supported | Beta | Beta | | Bot control of this computer | Supported | Planned | Planned after compositor validation | | Native on-device dictation | Supported | Planned | Planned | diff --git a/docs/linux-desktop.md b/docs/linux-desktop.md index 3f02f2c5..5e53e93f 100644 --- a/docs/linux-desktop.md +++ b/docs/linux-desktop.md @@ -10,8 +10,8 @@ installed builds do not require Node, pnpm, Swift, or a terminal at runtime. - Chat, streaming turns, approvals, bot-to-bot communication, and local data storage. - Composio connected apps and Box cloud computers. - External documentation and OAuth links in the default browser. -- An explicit, view-only local screen preview on GNOME Xorg, with a Wayland portal implementation pending - final real-session validation. +- An explicit, view-only local screen preview on GNOME Xorg and GNOME Wayland. The Wayland path uses the + native portal chooser and keeps the selected PipeWire stream open until the user stops sharing. The local preview does **not** give the bot control of this computer. Linux dictation and local computer control remain unavailable and fail closed in the Electron, server, and UI layers. Use a Cloud box when a bot @@ -107,10 +107,10 @@ Restart OpenMausBot after installing or signing in to a CLI. ## Xorg and Wayland -The shell, chat, cloud computers, and connected apps work in both GNOME session types. Preview-only capture is -validated on Xorg; its Wayland portal path is implemented but remains a release candidate until the complete -chooser/cancel/end matrix passes in a real GNOME Wayland session. OpenMausBot detects Wayland before XWayland -when both `WAYLAND_DISPLAY` and `DISPLAY` exist, so capture cannot accidentally bypass portal-mediated behavior. +The shell, chat, cloud computers, connected apps, and preview-only capture work in both GNOME session types. +The Wayland chooser/select/persistent-stream/cancel/end/retry lifecycle has been validated in a real Ubuntu +24.04 GNOME Wayland session. OpenMausBot detects Wayland before XWayland when both `WAYLAND_DISPLAY` and +`DISPLAY` exist, so capture cannot accidentally bypass portal-mediated behavior. Open the Computer panel and use the separate **Preview this computer** card. Capture never starts when the app or panel opens. From 7f332a77befa028538950d9c657c7d22e4fd6966 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Thu, 13 Aug 2026 18:52:59 -0300 Subject: [PATCH 05/32] handle cancelled Wayland capture cleanly --- electron/main.mjs | 22 +++++++++++++++++----- electron/screen-preview.cjs | 23 ++++++++++++++++++++++- electron/screen-preview.test.mjs | 20 +++++++++++++++++++- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/electron/main.mjs b/electron/main.mjs index ff3aa21b..7865e5e9 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -11,7 +11,9 @@ import capabilitiesModule from "./capabilities.cjs"; const { desktopCapabilities } = capabilitiesModule; const require = createRequire(import.meta.url); -const { createDisplayMediaGuard, selectCaptureSource } = require("./screen-preview.cjs"); +const { createDisplayMediaGuard, invokeDisplayMediaCallback, selectCaptureSource } = require( + "./screen-preview.cjs", +); const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 127.0.0.1 explicitly — vite binds IPv4; a bare "localhost" here can @@ -127,6 +129,16 @@ function rendererOrigin() { return new URL(app.isPackaged ? `http://127.0.0.1:${SERVER_PORT}` : DEV_URL).origin; } +function respondToDisplayMediaRequest(callback, response) { + const error = invokeDisplayMediaCallback(callback, response); + // An empty response intentionally rejects the renderer request, and Electron + // can surface that rejection by throwing from the callback. A selected + // source should never fail delivery, so keep that path visible in logs. + if (error && response.video) { + console.error("[screen-preview] failed to deliver selected source:", error); + } +} + ipcMain.on("screen:preview-intent", (event) => { event.returnValue = displayMediaGuard.begin(event.senderFrame); }); @@ -310,7 +322,7 @@ app.whenReady().then(async () => { (request, callback) => { displayMediaRequestCount += 1; if (!displayMediaGuard.consume(request, rendererOrigin())) { - callback({}); + respondToDisplayMediaRequest(callback, {}); return; } @@ -322,7 +334,7 @@ app.whenReady().then(async () => { const captureHost = process.platform === "darwin" ? "darwin" : capabilities.host.session; if (!capabilities.screenPreview.available) { - callback({}); + respondToDisplayMediaRequest(callback, {}); return; } @@ -337,9 +349,9 @@ app.whenReady().then(async () => { ? screen.getPrimaryDisplay().id : null, }); - callback(source ? { video: source } : {}); + respondToDisplayMediaRequest(callback, source ? { video: source } : {}); }) - .catch(() => callback({})); + .catch(() => respondToDisplayMediaRequest(callback, {})); }, { useSystemPicker: false }, ); diff --git a/electron/screen-preview.cjs b/electron/screen-preview.cjs index dad59281..f9cb8858 100644 --- a/electron/screen-preview.cjs +++ b/electron/screen-preview.cjs @@ -58,4 +58,25 @@ function selectCaptureSource({ sources, host, primaryDisplayId }) { return null; } -module.exports = { createDisplayMediaGuard, frameKey, originOf, selectCaptureSource }; +// Electron may throw synchronously from the display-media callback when an +// empty response rejects a video request. That rejection is expected after a +// portal cancellation, but allowing it to escape from a Promise catch creates +// an unhandled rejection in the main process. Return the error to the caller so +// successful-response failures can still be logged without destabilizing the +// cancellation path. +function invokeDisplayMediaCallback(callback, response) { + try { + callback(response); + return null; + } catch (error) { + return error; + } +} + +module.exports = { + createDisplayMediaGuard, + frameKey, + invokeDisplayMediaCallback, + originOf, + selectCaptureSource, +}; diff --git a/electron/screen-preview.test.mjs b/electron/screen-preview.test.mjs index 67a67272..748c4ce7 100644 --- a/electron/screen-preview.test.mjs +++ b/electron/screen-preview.test.mjs @@ -2,7 +2,9 @@ import { createRequire } from "node:module"; import { describe, expect, it } from "vitest"; const require = createRequire(import.meta.url); -const { createDisplayMediaGuard, selectCaptureSource } = require("./screen-preview.cjs"); +const { createDisplayMediaGuard, invokeDisplayMediaCallback, selectCaptureSource } = require( + "./screen-preview.cjs", +); const frame = { processId: 10, routingId: 20 }; const validRequest = { @@ -67,3 +69,19 @@ describe("display source selection", () => { expect(selectCaptureSource({ sources, host: "wayland", primaryDisplayId: 42 })).toBeNull(); }); }); + +describe("display media callback", () => { + it("contains Electron's synchronous rejection for an empty portal response", () => { + const rejection = new TypeError("Video was requested, but no video stream was provided"); + const callback = () => { + throw rejection; + }; + + expect(() => invokeDisplayMediaCallback(callback, {})).not.toThrow(); + expect(invokeDisplayMediaCallback(callback, {})).toBe(rejection); + }); + + it("returns null after delivering a selected source", () => { + expect(invokeDisplayMediaCallback(() => {}, { video: { id: "screen:0" } })).toBeNull(); + }); +}); From 1c7cc4e4ef023df713cd47e0a093f6e82ebbca8e Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Thu, 13 Aug 2026 19:14:24 -0300 Subject: [PATCH 06/32] add safe Linux CUA diagnostics --- electron/cua-linux.cjs | 466 ++++++++++++++++++++++++++++++++++++ electron/cua-linux.test.mjs | 244 +++++++++++++++++++ package.json | 2 +- 3 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 electron/cua-linux.cjs create mode 100644 electron/cua-linux.test.mjs diff --git a/electron/cua-linux.cjs b/electron/cua-linux.cjs new file mode 100644 index 00000000..56d290fd --- /dev/null +++ b/electron/cua-linux.cjs @@ -0,0 +1,466 @@ +const { spawn } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const CERTIFIED_DRIVER_VERSION = "0.19.3"; +const CERTIFIED_MANIFEST_SCHEMA = "1"; +const DEFAULT_TIMEOUT_MS = 8_000; +const DEFAULT_MAX_OUTPUT_BYTES = 512 * 1024; + +const SESSION_ENV_KEYS = new Set([ + "AT_SPI_BUS", + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", + "HOME", + "LANG", + "LANGUAGE", + "LC_ALL", + "PATH", + "USER", + "WAYLAND_DISPLAY", + "XAUTHORITY", + "XDG_CURRENT_DESKTOP", + "XDG_RUNTIME_DIR", + "XDG_SESSION_DESKTOP", + "XDG_SESSION_TYPE", +]); + +function unavailable(reasonCode, message, details = {}) { + return { status: "unavailable", reasonCode, message, ...details }; +} + +function sanitizePath(value) { + const seen = new Set(); + const entries = []; + for (const entry of String(value ?? "").split(path.delimiter)) { + if (!entry || !path.isAbsolute(entry)) continue; + const normalized = path.normalize(entry); + if (seen.has(normalized)) continue; + seen.add(normalized); + entries.push(normalized); + } + return entries.join(path.delimiter); +} + +function desktopCommandEnvironment(source = process.env, additions = {}) { + const env = {}; + for (const [key, value] of Object.entries(source)) { + if (value == null) continue; + if (SESSION_ENV_KEYS.has(key) || key.startsWith("LC_")) env[key] = String(value); + } + env.PATH = sanitizePath(source.PATH); + // Keep the finite probes deterministic and avoid an unrelated network check. + // This affects only children owned by OpenMausBot and does not change the + // user's persisted Cua preferences. + env.CUA_DRIVER_RS_UPDATE_CHECK = "false"; + for (const [key, value] of Object.entries(additions)) { + if (value != null) env[key] = String(value); + } + return env; +} + +function commandFailure(code, message, details = {}) { + const error = new Error(message); + error.code = code; + Object.assign(error, details); + return error; +} + +function runCuaCommand(binary, args, { + env = desktopCommandEnvironment(), + timeoutMs = DEFAULT_TIMEOUT_MS, + maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES, +} = {}) { + return new Promise((resolve, reject) => { + let settled = false; + let timedOut = false; + let overflowed = false; + let stdout = Buffer.alloc(0); + let stderr = Buffer.alloc(0); + const child = spawn(binary, args, { + env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + const finish = (fn, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(value); + }; + const stop = () => { + try { + child.kill("SIGKILL"); + } catch {} + }; + const collect = (current, chunk) => { + const next = Buffer.concat([current, chunk]); + if (stdout.length + stderr.length + chunk.length > maxOutputBytes) { + overflowed = true; + stop(); + } + return next.subarray(0, maxOutputBytes); + }; + + child.stdout?.on("data", (chunk) => { + stdout = collect(stdout, chunk); + }); + child.stderr?.on("data", (chunk) => { + stderr = collect(stderr, chunk); + }); + child.once("error", (error) => + finish(reject, commandFailure("spawn-failed", `Could not start Cua Driver: ${error.message}`)), + ); + child.once("close", (exitCode, signal) => { + if (timedOut) { + finish( + reject, + commandFailure("command-timeout", "Cua Driver did not respond in time.", { timeoutMs }), + ); + return; + } + if (overflowed) { + finish( + reject, + commandFailure("output-too-large", "Cua Driver returned too much diagnostic output."), + ); + return; + } + resolve({ + exitCode, + signal, + stdout: stdout.toString("utf8"), + stderr: stderr.toString("utf8"), + }); + settled = true; + clearTimeout(timer); + }); + + const timer = setTimeout(() => { + timedOut = true; + stop(); + }, timeoutMs); + timer.unref?.(); + }); +} + +function pathComponents(target) { + const resolved = path.resolve(target); + const root = path.parse(resolved).root; + const relative = resolved.slice(root.length).split(path.sep).filter(Boolean); + const components = [root]; + let current = root; + for (const part of relative) { + current = path.join(current, part); + components.push(current); + } + return components; +} + +function safeOwner(stat, currentUid) { + return stat.uid === currentUid || stat.uid === 0; +} + +function validatePathComponents(target, currentUid) { + for (const component of pathComponents(path.dirname(target))) { + const stat = fs.lstatSync(component); + if (!safeOwner(stat, currentUid)) { + return unavailable( + "unsafe-driver-owner", + `Cua Driver path component is owned by an unexpected user: ${component}`, + ); + } + const rootOwnedStickyDirectory = stat.isDirectory() && stat.uid === 0 && (stat.mode & 0o1000) !== 0; + if ((stat.mode & 0o022) !== 0 && !rootOwnedStickyDirectory) { + return unavailable( + "unsafe-driver-permissions", + `Cua Driver path component is group- or world-writable: ${component}`, + ); + } + } + return null; +} + +function validateDriverCandidate(candidate, { currentUid = process.getuid?.() ?? os.userInfo().uid } = {}) { + if (!path.isAbsolute(candidate)) { + return unavailable("driver-path-not-absolute", "Cua Driver path must be absolute.", { + candidate, + }); + } + + let linkStat; + let canonicalPath; + let targetStat; + try { + linkStat = fs.lstatSync(candidate); + canonicalPath = fs.realpathSync(candidate); + targetStat = fs.statSync(canonicalPath); + } catch (error) { + return unavailable("driver-not-found", `Cua Driver was not found at ${candidate}.`, { + candidate, + cause: error?.code, + }); + } + + if (!safeOwner(linkStat, currentUid) || !safeOwner(targetStat, currentUid)) { + return unavailable( + "unsafe-driver-owner", + "Cua Driver must be owned by the current user or root.", + { candidate, canonicalPath }, + ); + } + if (!targetStat.isFile()) { + return unavailable("driver-not-file", "Cua Driver must resolve to a regular file.", { + candidate, + canonicalPath, + }); + } + if ((targetStat.mode & 0o111) === 0) { + return unavailable("driver-not-executable", "Cua Driver is not executable.", { + candidate, + canonicalPath, + }); + } + if ((targetStat.mode & 0o022) !== 0) { + return unavailable( + "unsafe-driver-permissions", + "Cua Driver must not be group- or world-writable.", + { candidate, canonicalPath }, + ); + } + + try { + const lexicalError = validatePathComponents(candidate, currentUid); + if (lexicalError) return { ...lexicalError, candidate, canonicalPath }; + const canonicalError = validatePathComponents(canonicalPath, currentUid); + if (canonicalError) return { ...canonicalError, candidate, canonicalPath }; + fs.accessSync(canonicalPath, fs.constants.X_OK); + } catch (error) { + return unavailable("driver-not-executable", "Cua Driver cannot be executed.", { + candidate, + canonicalPath, + cause: error?.code, + }); + } + + return { status: "found", path: canonicalPath }; +} + +function discoverLinuxCuaDriver({ env = process.env, homeDir = os.homedir(), currentUid } = {}) { + const explicit = env.CUA_DRIVER_PATH; + if (explicit) { + const result = validateDriverCandidate(explicit, { currentUid }); + return result.status === "found" ? { ...result, source: "environment" } : result; + } + + const localCandidate = path.join(homeDir, ".local", "bin", "cua-driver"); + if (fs.existsSync(localCandidate)) { + const result = validateDriverCandidate(localCandidate, { currentUid }); + if (result.status === "found") return { ...result, source: "user-local" }; + return result; + } + + let firstUnsafe = null; + for (const directory of sanitizePath(env.PATH).split(path.delimiter).filter(Boolean)) { + const candidate = path.join(directory, "cua-driver"); + if (!fs.existsSync(candidate)) continue; + const result = validateDriverCandidate(candidate, { currentUid }); + if (result.status === "found") return { ...result, source: "path" }; + firstUnsafe ??= result; + } + return ( + firstUnsafe ?? + unavailable( + "driver-not-found", + "Cua Driver was not found. Install it, then try again.", + ) + ); +} + +function parseJsonObject(value, label) { + let parsed; + try { + parsed = JSON.parse(value); + } catch { + throw commandFailure("invalid-json", `${label} returned invalid JSON.`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw commandFailure("invalid-json", `${label} returned an invalid JSON object.`); + } + return parsed; +} + +function parseVersion(stdout) { + const match = String(stdout).trim().match(/(?:cua-driver\s+)?(\d+\.\d+\.\d+)/i); + return match?.[1] ?? null; +} + +function validateManifest(manifest, binaryPath) { + if (manifest.schema_version !== CERTIFIED_MANIFEST_SCHEMA) { + throw commandFailure( + "unsupported-manifest", + `Cua Driver manifest schema ${String(manifest.schema_version)} is not supported.`, + ); + } + if (manifest.binary_version !== CERTIFIED_DRIVER_VERSION) { + throw commandFailure( + "unsupported-driver-version", + `Cua Driver ${String(manifest.binary_version)} is not supported; install ${CERTIFIED_DRIVER_VERSION}.`, + ); + } + const invocation = manifest.mcp_invocation; + if ( + !invocation || + typeof invocation.command !== "string" || + !Array.isArray(invocation.args) || + invocation.args.length !== 1 || + invocation.args[0] !== "mcp" + ) { + throw commandFailure("unsupported-manifest", "Cua Driver returned an unsupported MCP contract."); + } + let invocationPath; + try { + invocationPath = fs.realpathSync(invocation.command); + } catch { + throw commandFailure("unsupported-manifest", "Cua Driver MCP command could not be verified."); + } + if (invocationPath !== binaryPath) { + throw commandFailure("unsupported-manifest", "Cua Driver MCP command does not match the verified binary."); + } + return { command: binaryPath, args: ["mcp"] }; +} + +function validateDoctor(report) { + if (typeof report.ok !== "boolean" || !Array.isArray(report.probes)) { + throw commandFailure("invalid-doctor-report", "Cua Driver returned an invalid doctor report."); + } + const probes = report.probes.map((probe) => { + if ( + !probe || + typeof probe.label !== "string" || + !["ok", "warn", "err"].includes(probe.status) || + typeof probe.message !== "string" + ) { + throw commandFailure("invalid-doctor-report", "Cua Driver returned an invalid doctor probe."); + } + return { + label: probe.label, + status: probe.status, + message: probe.message, + ...(typeof probe.detail === "string" ? { detail: probe.detail } : {}), + }; + }); + const byLabel = new Map(probes.map((probe) => [probe.label, probe])); + const display = byLabel.get("display server"); + const x11 = byLabel.get("X11 connection"); + const atSpi = byLabel.get("AT-SPI"); + if (!report.ok || probes.some((probe) => probe.status === "err")) { + throw commandFailure("doctor-failed", "Cua Driver diagnostics reported an error.", { probes }); + } + if (display?.status !== "ok" || !display.message.startsWith("X11 ")) { + throw commandFailure("x11-unavailable", "Cua Driver did not confirm an Xorg display.", { probes }); + } + if (x11?.status !== "ok") { + throw commandFailure("x11-unavailable", "Cua Driver could not verify the Xorg session.", { probes }); + } + if (atSpi?.status !== "ok") { + throw commandFailure("at-spi-unavailable", "Cua Driver could not reach the AT-SPI accessibility bus.", { + probes, + }); + } + return { ok: true, probes, warnings: probes.filter((probe) => probe.status === "warn") }; +} + +async function inspectLinuxCuaDriver({ + platform = process.platform, + env = process.env, + homeDir = os.homedir(), + currentUid, + run = runCuaCommand, +} = {}) { + const session = String(env.XDG_SESSION_TYPE ?? "").toLowerCase(); + if (platform !== "linux") { + return unavailable("unsupported-platform", "Linux local control is only available on Ubuntu."); + } + if (session !== "x11" && session !== "xorg") { + return unavailable( + session === "wayland" ? "wayland-unsupported" : "x11-required", + session === "wayland" + ? "Local control is not available in a Wayland session. Sign in with GNOME on Xorg." + : "Local control requires an interactive GNOME on Xorg session.", + ); + } + if (!env.DISPLAY) { + return unavailable("display-unavailable", "Local control requires an active Xorg display."); + } + + const discovered = discoverLinuxCuaDriver({ env, homeDir, currentUid }); + if (discovered.status !== "found") return discovered; + const commandEnv = desktopCommandEnvironment(env); + + try { + const versionResult = await run(discovered.path, ["--version"], { env: commandEnv }); + const driverVersion = parseVersion(versionResult.stdout || versionResult.stderr); + if (versionResult.exitCode !== 0 || driverVersion !== CERTIFIED_DRIVER_VERSION) { + return unavailable( + "unsupported-driver-version", + `Cua Driver ${driverVersion ?? "unknown"} is not supported; install ${CERTIFIED_DRIVER_VERSION}.`, + { path: discovered.path, source: discovered.source, driverVersion }, + ); + } + + const manifestResult = await run(discovered.path, ["manifest"], { env: commandEnv }); + if (manifestResult.exitCode !== 0) { + return unavailable("manifest-failed", "Cua Driver manifest validation failed.", { + path: discovered.path, + source: discovered.source, + }); + } + const manifest = parseJsonObject(manifestResult.stdout, "Cua Driver manifest"); + const mcp = validateManifest(manifest, discovered.path); + + const doctorResult = await run(discovered.path, ["doctor", "--json"], { env: commandEnv }); + const doctorReport = parseJsonObject(doctorResult.stdout, "Cua Driver doctor"); + if (doctorResult.exitCode !== 0 && doctorReport.ok !== false) { + return unavailable("doctor-failed", "Cua Driver diagnostics failed.", { + path: discovered.path, + source: discovered.source, + }); + } + const doctor = validateDoctor(doctorReport); + + return { + status: "ready", + path: discovered.path, + source: discovered.source, + driverVersion, + manifestSchema: manifest.schema_version, + mcp, + doctor, + commandEnv, + }; + } catch (error) { + return unavailable(error?.code ?? "diagnostics-failed", error?.message ?? String(error), { + path: discovered.path, + source: discovered.source, + ...(error?.probes ? { probes: error.probes } : {}), + }); + } +} + +module.exports = { + CERTIFIED_DRIVER_VERSION, + CERTIFIED_MANIFEST_SCHEMA, + desktopCommandEnvironment, + discoverLinuxCuaDriver, + inspectLinuxCuaDriver, + parseVersion, + runCuaCommand, + sanitizePath, + validateDoctor, + validateDriverCandidate, + validateManifest, +}; diff --git a/electron/cua-linux.test.mjs b/electron/cua-linux.test.mjs new file mode 100644 index 00000000..706c2147 --- /dev/null +++ b/electron/cua-linux.test.mjs @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { + desktopCommandEnvironment, + discoverLinuxCuaDriver, + inspectLinuxCuaDriver, + runCuaCommand, + sanitizePath, + validateDriverCandidate, +} = require("./cua-linux.cjs"); + +const temporaryDirectories = []; + +function temporaryDirectory() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "openmausbot-cua-linux-")); + temporaryDirectories.push(directory); + return directory; +} + +function executable(directory, name = "cua-driver") { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + const file = path.join(directory, name); + fs.writeFileSync(file, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + return file; +} + +function healthyDoctor() { + return { + ok: true, + probes: [ + { label: "binary", status: "ok", message: "cua-driver 0.19.3" }, + { label: "display server", status: "ok", message: "X11 (DISPLAY=:0)" }, + { label: "X11 connection", status: "ok", message: "connected, 1 visible top-level window" }, + { label: "AT-SPI", status: "ok", message: "org.a11y.Bus reachable via session bus" }, + { label: "telemetry", status: "warn", message: "test warning" }, + ], + }; +} + +function successfulRunner(binary) { + return vi.fn(async (_command, args, options) => { + expect(_command).toBe(binary); + expect(options.env.OPENAI_API_KEY).toBeUndefined(); + if (args[0] === "--version") return { exitCode: 0, stdout: "cua-driver 0.19.3\n", stderr: "" }; + if (args[0] === "manifest") { + return { + exitCode: 0, + stdout: JSON.stringify({ + schema_version: "1", + binary_version: "0.19.3", + binary_path: binary, + mcp_invocation: { command: binary, args: ["mcp"] }, + }), + stderr: "", + }; + } + return { exitCode: 0, stdout: JSON.stringify(healthyDoctor()), stderr: "" }; + }); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("Linux CUA discovery", () => { + it("rejects an invalid explicit override without falling through", () => { + const root = temporaryDirectory(); + const fallback = executable(path.join(root, "bin")); + const result = discoverLinuxCuaDriver({ + env: { CUA_DRIVER_PATH: path.join(root, "missing"), PATH: path.dirname(fallback) }, + homeDir: root, + }); + expect(result).toMatchObject({ status: "unavailable", reasonCode: "driver-not-found" }); + }); + + it("resolves the official user-local symlink to its canonical executable", () => { + const root = temporaryDirectory(); + const release = executable(path.join(root, ".cua-driver", "packages", "releases", "0.19.3")); + const localBin = path.join(root, ".local", "bin"); + fs.mkdirSync(localBin, { recursive: true, mode: 0o700 }); + fs.symlinkSync(release, path.join(localBin, "cua-driver")); + + expect(discoverLinuxCuaDriver({ env: { PATH: "" }, homeDir: root })).toEqual({ + status: "found", + path: release, + source: "user-local", + }); + }); + + it("ignores empty and relative PATH entries and preserves literal metacharacters", () => { + const root = temporaryDirectory(); + const safeDirectory = path.join(root, "driver $; directory"); + const binary = executable(safeDirectory); + const value = ["", ".", "relative/bin", safeDirectory, safeDirectory].join(path.delimiter); + expect(sanitizePath(value)).toBe(safeDirectory); + expect(discoverLinuxCuaDriver({ env: { PATH: value }, homeDir: path.join(root, "home") })).toEqual({ + status: "found", + path: binary, + source: "path", + }); + }); + + it("rejects a group-writable executable", () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + fs.chmodSync(binary, 0o720); + expect(validateDriverCandidate(binary)).toMatchObject({ + status: "unavailable", + reasonCode: "unsafe-driver-permissions", + }); + }); +}); + +describe("Linux CUA diagnostics", () => { + it("passes only the minimal desktop environment and returns a certified contract", async () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + const run = successfulRunner(binary); + const result = await inspectLinuxCuaDriver({ + platform: "linux", + homeDir: path.join(root, "home"), + env: { + CUA_DRIVER_PATH: binary, + XDG_SESSION_TYPE: "x11", + DISPLAY: ":0", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus", + PATH: "/usr/bin", + HOME: root, + OPENAI_API_KEY: "must-not-leak", + }, + run, + }); + + expect(result).toMatchObject({ + status: "ready", + path: binary, + driverVersion: "0.19.3", + manifestSchema: "1", + mcp: { command: binary, args: ["mcp"] }, + }); + expect(result.doctor.warnings).toHaveLength(1); + expect(run).toHaveBeenCalledTimes(3); + }); + + it("fails before discovery or execution outside Xorg", async () => { + const run = vi.fn(); + const result = await inspectLinuxCuaDriver({ + platform: "linux", + env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0", DISPLAY: ":0" }, + run, + }); + expect(result).toMatchObject({ status: "unavailable", reasonCode: "wayland-unsupported" }); + expect(run).not.toHaveBeenCalled(); + }); + + it("rejects version and manifest drift", async () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + const run = vi.fn(async (_command, args) => { + if (args[0] === "--version") return { exitCode: 0, stdout: "cua-driver 0.20.0", stderr: "" }; + throw new Error("should not continue"); + }); + const result = await inspectLinuxCuaDriver({ + platform: "linux", + homeDir: root, + env: { CUA_DRIVER_PATH: binary, XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }, + run, + }); + expect(result).toMatchObject({ status: "unavailable", reasonCode: "unsupported-driver-version" }); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("requires healthy X11 and AT-SPI probes", async () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + const run = successfulRunner(binary); + run.mockImplementationOnce(async () => ({ exitCode: 0, stdout: "cua-driver 0.19.3", stderr: "" })); + run.mockImplementationOnce(async () => ({ + exitCode: 0, + stdout: JSON.stringify({ + schema_version: "1", + binary_version: "0.19.3", + mcp_invocation: { command: binary, args: ["mcp"] }, + }), + stderr: "", + })); + run.mockImplementationOnce(async () => ({ + exitCode: 0, + stdout: JSON.stringify({ + ...healthyDoctor(), + probes: healthyDoctor().probes.map((probe) => + probe.label === "AT-SPI" ? { ...probe, status: "warn" } : probe, + ), + }), + stderr: "", + })); + const result = await inspectLinuxCuaDriver({ + platform: "linux", + homeDir: root, + env: { CUA_DRIVER_PATH: binary, XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }, + run, + }); + expect(result).toMatchObject({ status: "unavailable", reasonCode: "at-spi-unavailable" }); + }); +}); + +describe("bounded command execution", () => { + it("times out and bounds output", async () => { + await expect( + runCuaCommand(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { timeoutMs: 20 }), + ).rejects.toMatchObject({ code: "command-timeout" }); + + await expect( + runCuaCommand(process.execPath, ["-e", "process.stdout.write('x'.repeat(10000))"], { + maxOutputBytes: 64, + }), + ).rejects.toMatchObject({ code: "output-too-large" }); + }); +}); + +describe("minimal child environment", () => { + it("keeps desktop session values but drops application secrets", () => { + expect( + desktopCommandEnvironment({ + HOME: "/home/test", + DISPLAY: ":0", + PATH: ":relative:/usr/bin:/usr/bin", + OPENAI_API_KEY: "secret", + }), + ).toEqual({ + HOME: "/home/test", + DISPLAY: ":0", + PATH: "/usr/bin", + CUA_DRIVER_RS_UPDATE_CHECK: "false", + }); + }); +}); diff --git a/package.json b/package.json index 9881c081..595fe0ab 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "test:watch": "vitest", "test:cua": "pnpm build:cua && node scripts/smoke-cua.mjs", "test:cua-container": "node scripts/smoke-cua-container.mjs", - "check:electron": "node --check electron/main.mjs && node --check electron/terminal-launch.mjs && node --check electron/preload.cjs && node --check electron/capabilities.cjs && node --check electron/cua-connection.cjs && node --check electron/cua.mjs && node --check electron/screen-preview.cjs && node --check electron/speech.mjs", + "check:electron": "node --check electron/main.mjs && node --check electron/terminal-launch.mjs && node --check electron/preload.cjs && node --check electron/capabilities.cjs && node --check electron/cua-connection.cjs && node --check electron/cua-linux.cjs && node --check electron/cua.mjs && node --check electron/screen-preview.cjs && node --check electron/speech.mjs", "preview": "vite preview", "build:server": "tsc -p tsconfig.server.build.json", "build:speech": "node electron/build-speech-helper.mjs", From 3c8914e2ff62030a089e350f3e1e813aefe206eb Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Thu, 13 Aug 2026 19:22:33 -0300 Subject: [PATCH 07/32] supervise private Linux CUA runtime --- electron/capabilities.cjs | 33 +- electron/capabilities.test.mjs | 30 +- electron/cua-connection.cjs | 22 +- electron/cua-connection.test.mjs | 2 + electron/cua-linux-runtime.cjs | 579 +++++++++++++++++++++++++ electron/cua-linux-runtime.test.mjs | 272 ++++++++++++ electron/cua-linux.cjs | 2 +- electron/cua.mjs | 49 +++ electron/main.mjs | 21 +- electron/preload.cjs | 11 + package.json | 2 +- src/components/DesktopCapabilities.tsx | 6 +- src/lib/desktop.ts | 7 + src/types/ogb.d.ts | 22 + 14 files changed, 1046 insertions(+), 12 deletions(-) create mode 100644 electron/cua-linux-runtime.cjs create mode 100644 electron/cua-linux-runtime.test.mjs diff --git a/electron/capabilities.cjs b/electron/capabilities.cjs index 685807d1..2551138b 100644 --- a/electron/capabilities.cjs +++ b/electron/capabilities.cjs @@ -21,9 +21,17 @@ function linuxSession(platform, env) { } function localComputerReady(platform, connection) { + if (platform === "darwin") { + return connection?.mode === "embedded" || connection?.mode === "standalone"; + } return ( - platform === "darwin" && - (connection?.mode === "embedded" || connection?.mode === "standalone") + platform === "linux" && + connection?.schemaVersion === 1 && + connection?.mode === "linux-x11-supervised" && + connection?.platform === "linux" && + connection?.session === "x11" && + connection?.enabled === true && + connection?.status === "ready" ); } @@ -72,15 +80,30 @@ function desktopCapabilities({ }, localComputer: { available: localAvailable, - support: localAvailable ? "supported" : "unsupported", + support: localAvailable && hostPlatform === "linux" ? "limited" : localAvailable ? "supported" : "unsupported", + enabled: connectionEnabled(hostPlatform, localConnection), + status: localAvailable ? "ready" : localConnection?.status ?? "unavailable", + ...(typeof localConnection?.message === "string" ? { message: localConnection.message } : {}), + ...(typeof localConnection?.driver?.path === "string" + ? { driverPath: localConnection.driver.path } + : {}), + ...(typeof localConnection?.driver?.version === "string" + ? { driverVersion: localConnection.driver.version } + : {}), ...(!localAvailable ? { reasonCode: - hostPlatform === "darwin" ? "cua-driver-unavailable" : "unsupported-platform", + localConnection?.reasonCode ?? + (hostPlatform === "darwin" ? "cua-driver-unavailable" : "unsupported-platform"), } : {}), }, }; } -module.exports = { desktopCapabilities, linuxSession, localComputerReady }; +function connectionEnabled(platform, connection) { + if (platform === "darwin") return localComputerReady(platform, connection); + return platform === "linux" && connection?.enabled === true; +} + +module.exports = { connectionEnabled, desktopCapabilities, linuxSession, localComputerReady }; diff --git a/electron/capabilities.test.mjs b/electron/capabilities.test.mjs index b7239762..bf40b779 100644 --- a/electron/capabilities.test.mjs +++ b/electron/capabilities.test.mjs @@ -17,7 +17,7 @@ describe("desktop capabilities", () => { windowChrome: "mac-inset", screenPreview: { available: true, interaction: "direct" }, dictation: { available: true, engine: "apple-speech", onDevice: true }, - localComputer: { available: true, support: "supported" }, + localComputer: { available: true, support: "supported", enabled: true, status: "ready" }, }); }); @@ -74,4 +74,32 @@ describe("desktop capabilities", () => { expect(localComputerReady("darwin", { mode: "unavailable" })).toBe(false); expect(localComputerReady("darwin", { mode: "standalone" })).toBe(true); }); + + it("enables limited Linux control only for the complete supervised X11 contract", () => { + const connection = { + schemaVersion: 1, + mode: "linux-x11-supervised", + platform: "linux", + session: "x11", + enabled: true, + status: "ready", + driver: { path: "/home/test/.local/bin/cua-driver", version: "0.19.3" }, + }; + expect( + desktopCapabilities({ + platform: "linux", + env: { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }, + localConnection: connection, + }).localComputer, + ).toMatchObject({ + available: true, + support: "limited", + enabled: true, + status: "ready", + driverVersion: "0.19.3", + }); + expect(localComputerReady("linux", { ...connection, session: "wayland" })).toBe(false); + expect(localComputerReady("linux", { ...connection, status: "starting" })).toBe(false); + expect(localComputerReady("linux", { ...connection, schemaVersion: 2 })).toBe(false); + }); }); diff --git a/electron/cua-connection.cjs b/electron/cua-connection.cjs index ed16944b..099bb312 100644 --- a/electron/cua-connection.cjs +++ b/electron/cua-connection.cjs @@ -20,11 +20,31 @@ function createCuaConnectionStore({ fileSystem.mkdirSync(userData, { recursive: true }); const descriptorPath = path.join(userData, "cua-connection.json"); const temporaryPath = `${descriptorPath}.${processId}.${temporaryId()}.tmp`; + let handle; try { - fileSystem.writeFileSync(temporaryPath, JSON.stringify(next, null, 2)); + try { + fileSystem.chmodSync(userData, 0o700); + } catch { + // Windows does not expose meaningful POSIX directory modes. + } + handle = fileSystem.openSync(temporaryPath, "wx", 0o600); + fileSystem.writeFileSync(handle, `${JSON.stringify(next, null, 2)}\n`, "utf8"); + fileSystem.fsyncSync(handle); + fileSystem.closeSync(handle); + handle = undefined; fileSystem.renameSync(temporaryPath, descriptorPath); + try { + fileSystem.chmodSync(descriptorPath, 0o600); + } catch { + // Windows does not expose meaningful POSIX file modes. + } } catch (error) { + if (handle !== undefined) { + try { + fileSystem.closeSync(handle); + } catch {} + } try { fileSystem.unlinkSync(temporaryPath); } catch { diff --git a/electron/cua-connection.test.mjs b/electron/cua-connection.test.mjs index 245d0d02..fb292ef7 100644 --- a/electron/cua-connection.test.mjs +++ b/electron/cua-connection.test.mjs @@ -39,6 +39,8 @@ describe("CUA connection persistence", () => { expect( fs.existsSync(path.join(userData, "cua-connection.json.123.test.tmp")), ).toBe(false); + expect(fs.statSync(path.join(userData, "cua-connection.json")).mode & 0o777).toBe(0o600); + expect(fs.statSync(userData).mode & 0o777).toBe(0o700); } finally { rmSync(userData, { recursive: true, force: true }); } diff --git a/electron/cua-linux-runtime.cjs b/electron/cua-linux-runtime.cjs new file mode 100644 index 00000000..30bc0178 --- /dev/null +++ b/electron/cua-linux-runtime.cjs @@ -0,0 +1,579 @@ +const { spawn } = require("node:child_process"); +const { randomUUID } = require("node:crypto"); +const fs = require("node:fs"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { + CERTIFIED_DRIVER_VERSION, + CERTIFIED_MANIFEST_SCHEMA, + desktopCommandEnvironment, + inspectLinuxCuaDriver, + validateDriverCandidate, +} = require("./cua-linux.cjs"); + +const CONNECTION_SCHEMA_VERSION = 1; +const SETTINGS_SCHEMA_VERSION = 1; +const HOST_BUNDLE_ID = "com.openmausbot.app"; +const CERTIFIED_CONTRACT_VERSION = "0.6.0"; +const CERTIFIED_TOOLS_LIST_SCHEMA_VERSION = "1"; +const CERTIFIED_CAPABILITY_VERSION = "1"; +const CERTIFIED_MCP_PROTOCOL_VERSION = "2025-06-18"; +const REQUIRED_TOOLS = ["click", "get_window_state", "list_apps", "type_text"]; + +function ensurePrivateDirectory(directory, fileSystem = fs, currentUid = process.getuid?.() ?? os.userInfo().uid) { + fileSystem.mkdirSync(directory, { recursive: true, mode: 0o700 }); + const stat = fileSystem.lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw Object.assign(new Error(`Private CUA path is not a directory: ${directory}`), { + code: "unsafe-runtime-directory", + }); + } + if (stat.uid !== currentUid && stat.uid !== 0) { + throw Object.assign(new Error(`Private CUA directory has an unexpected owner: ${directory}`), { + code: "unsafe-runtime-directory", + }); + } + fileSystem.chmodSync(directory, 0o700); + return directory; +} + +function writePrivateJson(file, value, { + fileSystem = fs, + temporaryId = randomUUID, + processId = process.pid, +} = {}) { + const directory = ensurePrivateDirectory(path.dirname(file), fileSystem); + const temporary = path.join(directory, `.${path.basename(file)}.${processId}.${temporaryId()}.tmp`); + let descriptor; + try { + descriptor = fileSystem.openSync(temporary, "wx", 0o600); + fileSystem.writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + fileSystem.fsyncSync(descriptor); + fileSystem.closeSync(descriptor); + descriptor = undefined; + fileSystem.renameSync(temporary, file); + fileSystem.chmodSync(file, 0o600); + const directoryHandle = fileSystem.openSync(directory, "r"); + try { + fileSystem.fsyncSync(directoryHandle); + } finally { + fileSystem.closeSync(directoryHandle); + } + } catch (error) { + if (descriptor !== undefined) { + try { + fileSystem.closeSync(descriptor); + } catch {} + } + try { + fileSystem.unlinkSync(temporary); + } catch {} + throw error; + } +} + +function createLinuxCuaPreferenceStore({ getUserData, fileSystem = fs } = {}) { + const preferencePath = () => path.join(getUserData(), "cua-local-control.json"); + return Object.freeze({ + read() { + try { + const stat = fileSystem.lstatSync(preferencePath()); + if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) return false; + const value = JSON.parse(fileSystem.readFileSync(preferencePath(), "utf8")); + return ( + value?.schemaVersion === SETTINGS_SCHEMA_VERSION && + value?.linuxLocalControlEnabled === true && + Object.keys(value).every((key) => + ["schemaVersion", "linuxLocalControlEnabled"].includes(key), + ) + ); + } catch { + return false; + } + }, + write(enabled) { + writePrivateJson( + preferencePath(), + { schemaVersion: SETTINGS_SCHEMA_VERSION, linuxLocalControlEnabled: Boolean(enabled) }, + { fileSystem }, + ); + }, + }); +} + +function requestSocket(socketPath, request, { timeoutMs = 1_000, maxBytes = 256 * 1024 } = {}) { + return new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + let settled = false; + let data = Buffer.alloc(0); + const finish = (fn, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + fn(value); + }; + socket.once("connect", () => socket.write(`${JSON.stringify(request)}\n`)); + socket.on("data", (chunk) => { + if (data.length + chunk.length > maxBytes) { + finish(reject, Object.assign(new Error("Cua Driver handshake was too large."), { code: "handshake-too-large" })); + return; + } + data = Buffer.concat([data, chunk]); + const newline = data.indexOf(0x0a); + if (newline === -1) return; + try { + finish(resolve, JSON.parse(data.subarray(0, newline).toString("utf8"))); + } catch { + finish(reject, Object.assign(new Error("Cua Driver returned an invalid handshake."), { code: "invalid-handshake" })); + } + }); + socket.once("error", (error) => finish(reject, error)); + const timer = setTimeout( + () => finish(reject, Object.assign(new Error("Cua Driver handshake timed out."), { code: "handshake-timeout" })), + timeoutMs, + ); + timer.unref?.(); + }); +} + +function validateDaemonMetadata(response, { childPid } = {}) { + const metadata = response?.ok === true ? response.result : null; + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + throw Object.assign(new Error("Cua Driver daemon identity could not be verified."), { + code: "invalid-daemon-metadata", + }); + } + const expected = { + driver_version: CERTIFIED_DRIVER_VERSION, + contract_version: CERTIFIED_CONTRACT_VERSION, + tools_list_schema_version: CERTIFIED_TOOLS_LIST_SCHEMA_VERSION, + capability_version: CERTIFIED_CAPABILITY_VERSION, + mcp_protocol_version: CERTIFIED_MCP_PROTOCOL_VERSION, + embedded: true, + host_bundle_id: HOST_BUNDLE_ID, + }; + for (const [key, value] of Object.entries(expected)) { + if (metadata[key] !== value) { + throw Object.assign(new Error(`Cua Driver daemon reported an incompatible ${key}.`), { + code: "incompatible-daemon", + }); + } + } + if (!Number.isInteger(metadata.pid) || metadata.pid <= 0 || (childPid && metadata.pid !== childPid)) { + throw Object.assign(new Error("Cua Driver daemon PID does not match the owned process."), { + code: "invalid-daemon-metadata", + }); + } + return metadata; +} + +function validateToolSurface(response) { + const tools = response?.ok === true && Array.isArray(response.result) ? response.result : null; + if (!tools) { + throw Object.assign(new Error("Cua Driver tool surface could not be verified."), { + code: "invalid-tool-surface", + }); + } + const names = new Set(tools.map((tool) => tool?.name).filter((name) => typeof name === "string")); + const missing = REQUIRED_TOOLS.filter((name) => !names.has(name)); + if (missing.length) { + throw Object.assign(new Error(`Cua Driver is missing required tools: ${missing.join(", ")}.`), { + code: "incompatible-tool-surface", + }); + } + return [...names].sort(); +} + +async function probePrivateDaemon(socketPath, { + childPid, + timeoutMs = 10_000, + request = requestSocket, +} = {}) { + const deadline = Date.now() + timeoutMs; + let lastError = null; + while (Date.now() < deadline) { + try { + const metadata = validateDaemonMetadata( + await request(socketPath, { method: "metadata" }), + { childPid }, + ); + const tools = validateToolSurface(await request(socketPath, { method: "list" })); + return { metadata, tools }; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 75)); + } + } + throw Object.assign(new Error(lastError?.message ?? "Cua Driver daemon did not become ready."), { + code: lastError?.code ?? "daemon-start-timeout", + }); +} + +function waitForChildExit(child, timeoutMs) { + if (child.exitCode !== null && child.exitCode !== undefined) return Promise.resolve(true); + return new Promise((resolve) => { + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + child.once("exit", () => finish(true)); + const timer = setTimeout(() => finish(false), timeoutMs); + timer.unref?.(); + }); +} + +async function stopOwnedChild(child) { + if (!child) return; + try { + child.stdin?.end(); + } catch {} + if (await waitForChildExit(child, 2_000)) return; + try { + child.kill("SIGTERM"); + } catch {} + if (await waitForChildExit(child, 1_000)) return; + try { + child.kill("SIGKILL"); + } catch {} + await waitForChildExit(child, 500); +} + +function publicRuntimeStatus(connection) { + return { + enabled: connection.enabled === true, + status: connection.status ?? (connection.mode === "linux-x11-supervised" ? "ready" : "unavailable"), + reasonCode: connection.reasonCode, + message: connection.message ?? connection.reason, + driverPath: connection.driver?.path, + driverVersion: connection.driver?.version, + warnings: connection.doctorWarnings ?? [], + }; +} + +function createLinuxCuaRuntime({ + getUserData, + connectionStore, + preferenceStore = createLinuxCuaPreferenceStore({ getUserData }), + platform = process.platform, + env = process.env, + inspect = inspectLinuxCuaDriver, + spawnProcess = spawn, + probe = probePrivateDaemon, + identifier = randomUUID, + processId = process.pid, + onChange = () => {}, +} = {}) { + let active = null; + let startPromise = null; + let enabled = false; + let quitting = false; + let connection = { + schemaVersion: CONNECTION_SCHEMA_VERSION, + mode: "unavailable", + platform: "linux", + session: String(env.XDG_SESSION_TYPE ?? "unknown").toLowerCase(), + enabled: false, + status: "disabled", + reasonCode: "opt-in-required", + message: "Local control is off until you enable the beta.", + ownerPid: processId, + }; + + const publish = (next) => { + connection = connectionStore.persist(next); + onChange(connection); + return connection; + }; + + const unavailable = (status, reasonCode, message, extra = {}) => + publish({ + schemaVersion: CONNECTION_SCHEMA_VERSION, + mode: "unavailable", + platform: "linux", + session: String(env.XDG_SESSION_TYPE ?? "unknown").toLowerCase(), + enabled, + status, + reasonCode, + message, + ownerPid: processId, + ...extra, + }); + + const runtimeRoot = () => { + const userData = getUserData(); + const configured = env.XDG_RUNTIME_DIR; + let base = userData; + if (configured && path.isAbsolute(configured)) { + try { + const stat = fs.lstatSync(configured); + const currentUid = process.getuid?.() ?? os.userInfo().uid; + if ( + stat.isDirectory() && + !stat.isSymbolicLink() && + stat.uid === currentUid && + (stat.mode & 0o077) === 0 + ) { + base = configured; + } + } catch {} + } + return ensurePrivateDirectory(path.join(base, "openmausbot-cua")); + }; + + const cleanupRuntimeFiles = (owned) => { + if (!owned?.runtimeDirectory) return; + for (const file of [owned.socketPath, owned.pidFile]) { + try { + fs.unlinkSync(file); + } catch (error) { + if (error?.code !== "ENOENT") break; + } + } + try { + fs.rmdirSync(owned.runtimeDirectory); + } catch {} + }; + + const markUnexpectedExit = (owned, code, signal) => { + if (active !== owned || owned.stopping || quitting) return; + active = null; + cleanupRuntimeFiles(owned); + unavailable( + "error", + "daemon-exited", + "Cua Driver stopped unexpectedly. Try again before using this computer.", + { generation: owned.generation, exitCode: code, exitSignal: signal }, + ); + }; + + const start = async () => { + if (platform !== "linux") return connection; + if (!enabled) { + return unavailable( + "disabled", + "opt-in-required", + "Local control is off until you enable the beta.", + ); + } + if (active?.ready) return connection; + if (startPromise) return startPromise; + + startPromise = (async () => { + unavailable("checking", "checking-driver", "Checking Cua Driver and the Xorg session…"); + const inspected = await inspect({ platform, env }); + if (inspected.status !== "ready") { + return unavailable("error", inspected.reasonCode, inspected.message, { + ...(inspected.path + ? { driver: { path: inspected.path, version: inspected.driverVersion } } + : {}), + doctorProbes: inspected.probes ?? [], + }); + } + if (!enabled || quitting) return connection; + + const revalidated = validateDriverCandidate(inspected.path); + if (revalidated.status !== "found" || revalidated.path !== inspected.path) { + return unavailable( + "error", + "driver-changed", + "Cua Driver changed after validation. Check the installation and try again.", + ); + } + + const generation = identifier(); + const root = runtimeRoot(); + const runtimeDirectory = path.join(root, `${processId}-${generation.slice(0, 12)}`); + ensurePrivateDirectory(runtimeDirectory); + const socketPath = path.join(runtimeDirectory, "driver.sock"); + const pidFile = path.join(runtimeDirectory, "driver.pid"); + if (Buffer.byteLength(socketPath) > 100) { + return unavailable( + "error", + "socket-path-too-long", + "The private Cua Driver socket path is too long for this Linux installation.", + ); + } + + const childEnv = desktopCommandEnvironment(env, { + CUA_DRIVER_EMBEDDED: "1", + CUA_DRIVER_HOST_BUNDLE_ID: HOST_BUNDLE_ID, + CUA_DRIVER_PARENT_LIVENESS_STDIN: "1", + }); + const args = [ + "serve", + "--embedded", + "--socket", + socketPath, + "--pid-file", + pidFile, + "--permission-mode", + "standard", + ]; + const child = spawnProcess(inspected.path, args, { + env: childEnv, + shell: false, + stdio: ["pipe", "ignore", "pipe"], + windowsHide: true, + }); + const owned = { + child, + generation, + runtimeDirectory, + socketPath, + pidFile, + ready: false, + stopping: false, + }; + active = owned; + child.stderr?.on("data", () => {}); + child.once("exit", (code, signal) => markUnexpectedExit(owned, code, signal)); + child.once("error", (error) => markUnexpectedExit(owned, null, error?.code ?? "spawn-error")); + unavailable("starting", "starting-daemon", "Starting the private Cua Driver runtime…", { + generation, + driver: { path: inspected.path, version: inspected.driverVersion }, + }); + + try { + const handshake = await probe(socketPath, { childPid: child.pid }); + if (active !== owned || owned.stopping || !enabled) { + await stopOwnedChild(child); + cleanupRuntimeFiles(owned); + return connection; + } + owned.ready = true; + return publish({ + schemaVersion: CONNECTION_SCHEMA_VERSION, + mode: "linux-x11-supervised", + platform: "linux", + session: "x11", + enabled: true, + status: "ready", + ownerPid: processId, + generation, + driver: { + path: inspected.path, + version: inspected.driverVersion, + manifestSchema: inspected.manifestSchema, + }, + daemon: { + socketPath, + pid: handshake.metadata.pid, + contractVersion: handshake.metadata.contract_version, + toolsListSchemaVersion: handshake.metadata.tools_list_schema_version, + capabilityVersion: handshake.metadata.capability_version, + mcpProtocolVersion: handshake.metadata.mcp_protocol_version, + }, + mcp: { + command: inspected.path, + args: ["mcp", "--embedded", "--socket", socketPath], + env: { + CUA_DRIVER_EMBEDDED: "1", + CUA_DRIVER_HOST_BUNDLE_ID: HOST_BUNDLE_ID, + CUA_DRIVER_RS_UPDATE_CHECK: "false", + }, + }, + toolNames: handshake.tools, + doctorWarnings: inspected.doctor.warnings, + }); + } catch (error) { + owned.stopping = true; + const stillOwned = active === owned; + if (stillOwned) active = null; + await stopOwnedChild(child); + cleanupRuntimeFiles(owned); + if (!stillOwned || !enabled || quitting) return connection; + return unavailable( + "error", + error?.code ?? "daemon-start-failed", + error?.message ?? "Cua Driver could not start.", + { generation, driver: { path: inspected.path, version: inspected.driverVersion } }, + ); + } + })().finally(() => { + startPromise = null; + }); + return startPromise; + }; + + const stop = async ({ disable = false, quit = false } = {}) => { + if (disable) { + enabled = false; + preferenceStore.write(false); + } + if (quit) quitting = true; + unavailable( + disable ? "disabled" : "stopped", + disable ? "opt-in-required" : quit ? "app-stopped" : "runtime-stopped", + disable ? "Local control is disabled." : "Local control is not running.", + active ? { generation: active.generation } : {}, + ); + const owned = active; + if (owned) { + owned.stopping = true; + active = null; + await stopOwnedChild(owned.child); + cleanupRuntimeFiles(owned); + } + return connection; + }; + + return Object.freeze({ + async initialize() { + enabled = preferenceStore.read(); + return enabled + ? start() + : unavailable( + "disabled", + "opt-in-required", + "Local control is off until you enable the beta.", + ); + }, + async enable() { + enabled = true; + preferenceStore.write(true); + return start(); + }, + async retry() { + if (!enabled) return connection; + await stop(); + return start(); + }, + async disable() { + return stop({ disable: true }); + }, + async shutdown() { + return stop({ quit: true }); + }, + getConnection() { + return connection; + }, + getStatus() { + return publicRuntimeStatus(connection); + }, + }); +} + +module.exports = { + CERTIFIED_CAPABILITY_VERSION, + CERTIFIED_CONTRACT_VERSION, + CERTIFIED_MCP_PROTOCOL_VERSION, + CERTIFIED_TOOLS_LIST_SCHEMA_VERSION, + CONNECTION_SCHEMA_VERSION, + HOST_BUNDLE_ID, + REQUIRED_TOOLS, + createLinuxCuaPreferenceStore, + createLinuxCuaRuntime, + ensurePrivateDirectory, + probePrivateDaemon, + publicRuntimeStatus, + requestSocket, + stopOwnedChild, + validateDaemonMetadata, + validateToolSurface, + writePrivateJson, +}; diff --git a/electron/cua-linux-runtime.test.mjs b/electron/cua-linux-runtime.test.mjs new file mode 100644 index 00000000..bff0ae70 --- /dev/null +++ b/electron/cua-linux-runtime.test.mjs @@ -0,0 +1,272 @@ +import { EventEmitter } from "node:events"; +import { createRequire } from "node:module"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const require = createRequire(import.meta.url); +const { createCuaConnectionStore } = require("./cua-connection.cjs"); +const { + createLinuxCuaPreferenceStore, + createLinuxCuaRuntime, + validateDaemonMetadata, + validateToolSurface, + writePrivateJson, +} = require("./cua-linux-runtime.cjs"); + +const temporaryDirectories = []; + +function temporaryDirectory() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "openmausbot-cua-runtime-")); + temporaryDirectories.push(directory); + return directory; +} + +function executable(directory) { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + const binary = path.join(directory, "cua-driver"); + fs.writeFileSync(binary, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + return binary; +} + +function fakeChild(pid = 4321) { + const child = new EventEmitter(); + child.pid = pid; + child.exitCode = null; + child.stderr = new EventEmitter(); + child.stdin = { + end: vi.fn(() => { + if (child.exitCode !== null) return; + child.exitCode = 0; + queueMicrotask(() => child.emit("exit", 0, null)); + }), + }; + child.kill = vi.fn((signal) => { + if (child.exitCode !== null) return true; + child.exitCode = signal === "SIGKILL" ? 137 : 0; + queueMicrotask(() => child.emit("exit", child.exitCode, signal)); + return true; + }); + child.crash = () => { + child.exitCode = 1; + child.emit("exit", 1, null); + }; + return child; +} + +function handshake(pid = 4321) { + return { + metadata: { + driver_version: "0.19.3", + contract_version: "0.6.0", + tools_list_schema_version: "1", + capability_version: "1", + mcp_protocol_version: "2025-06-18", + pid, + embedded: true, + host_bundle_id: "com.openmausbot.app", + }, + tools: ["click", "get_window_state", "list_apps", "type_text"], + }; +} + +function harness({ preferenceEnabled = false } = {}) { + const userData = temporaryDirectory(); + const runtimeRoot = path.join(userData, "session"); + fs.mkdirSync(runtimeRoot, { mode: 0o700 }); + const binary = executable(path.join(userData, "driver")); + const connectionStore = createCuaConnectionStore({ getUserData: () => userData }); + const preferenceStore = createLinuxCuaPreferenceStore({ getUserData: () => userData }); + if (preferenceEnabled) preferenceStore.write(true); + const child = fakeChild(); + const inspect = vi.fn(async () => ({ + status: "ready", + path: binary, + source: "environment", + driverVersion: "0.19.3", + manifestSchema: "1", + mcp: { command: binary, args: ["mcp"] }, + doctor: { ok: true, probes: [], warnings: [] }, + })); + const spawnProcess = vi.fn(() => child); + const probe = vi.fn(async () => handshake(child.pid)); + const changes = []; + const runtime = createLinuxCuaRuntime({ + getUserData: () => userData, + connectionStore, + preferenceStore, + platform: "linux", + env: { + HOME: userData, + PATH: "/usr/bin", + DISPLAY: ":0", + XDG_SESSION_TYPE: "x11", + XDG_RUNTIME_DIR: runtimeRoot, + OPENAI_API_KEY: "must-not-leak", + }, + inspect, + spawnProcess, + probe, + identifier: () => "01234567-89ab-cdef-0123-456789abcdef", + processId: 1234, + onChange: (connection) => changes.push(connection), + }); + return { + binary, + changes, + child, + connectionStore, + inspect, + preferenceStore, + probe, + runtime, + spawnProcess, + userData, + }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("Linux CUA opt-in and lifecycle", () => { + it("does not inspect or execute a driver before explicit opt-in", async () => { + const context = harness(); + await context.runtime.initialize(); + expect(context.inspect).not.toHaveBeenCalled(); + expect(context.spawnProcess).not.toHaveBeenCalled(); + expect(context.runtime.getStatus()).toMatchObject({ + enabled: false, + status: "disabled", + reasonCode: "opt-in-required", + }); + }); + + it("coalesces starts, verifies a private daemon, and publishes a strict ready descriptor", async () => { + const context = harness(); + const [first, second] = await Promise.all([context.runtime.enable(), context.runtime.enable()]); + expect(first).toEqual(second); + expect(context.inspect).toHaveBeenCalledTimes(1); + expect(context.spawnProcess).toHaveBeenCalledTimes(1); + expect(context.spawnProcess).toHaveBeenCalledWith( + context.binary, + expect.arrayContaining(["serve", "--embedded", "--socket", "--permission-mode", "standard"]), + expect.objectContaining({ shell: false, stdio: ["pipe", "ignore", "pipe"] }), + ); + const spawnOptions = context.spawnProcess.mock.calls[0][2]; + expect(spawnOptions.env).toMatchObject({ + CUA_DRIVER_EMBEDDED: "1", + CUA_DRIVER_PARENT_LIVENESS_STDIN: "1", + CUA_DRIVER_HOST_BUNDLE_ID: "com.openmausbot.app", + }); + expect(spawnOptions.env.OPENAI_API_KEY).toBeUndefined(); + expect(context.probe).toHaveBeenCalledWith(expect.stringMatching(/driver\.sock$/), { + childPid: context.child.pid, + }); + expect(context.runtime.getConnection()).toMatchObject({ + schemaVersion: 1, + mode: "linux-x11-supervised", + platform: "linux", + session: "x11", + enabled: true, + status: "ready", + ownerPid: 1234, + generation: "01234567-89ab-cdef-0123-456789abcdef", + driver: { path: context.binary, version: "0.19.3", manifestSchema: "1" }, + daemon: { pid: 4321, contractVersion: "0.6.0" }, + mcp: { + command: context.binary, + args: ["mcp", "--embedded", "--socket", expect.stringMatching(/driver\.sock$/)], + }, + }); + const descriptor = path.join(context.userData, "cua-connection.json"); + expect(fs.statSync(descriptor).mode & 0o777).toBe(0o600); + expect(fs.statSync(context.userData).mode & 0o777).toBe(0o700); + }); + + it("invalidates readiness immediately when the owned daemon exits", async () => { + const context = harness(); + await context.runtime.enable(); + context.child.crash(); + expect(context.runtime.getConnection()).toMatchObject({ + mode: "unavailable", + status: "error", + reasonCode: "daemon-exited", + generation: "01234567-89ab-cdef-0123-456789abcdef", + }); + expect( + JSON.parse(fs.readFileSync(path.join(context.userData, "cua-connection.json"), "utf8")), + ).toMatchObject({ mode: "unavailable", reasonCode: "daemon-exited" }); + }); + + it("closes the parent-liveness pipe on shutdown without clearing durable opt-in", async () => { + const context = harness(); + await context.runtime.enable(); + await context.runtime.shutdown(); + expect(context.child.stdin.end).toHaveBeenCalledOnce(); + expect(context.child.kill).not.toHaveBeenCalled(); + expect(context.preferenceStore.read()).toBe(true); + expect(context.runtime.getConnection()).toMatchObject({ + mode: "unavailable", + status: "stopped", + reasonCode: "app-stopped", + }); + }); + + it("starts on launch only after a durable prior opt-in and supports explicit disable", async () => { + const context = harness({ preferenceEnabled: true }); + await context.runtime.initialize(); + expect(context.runtime.getConnection().mode).toBe("linux-x11-supervised"); + await context.runtime.disable(); + expect(context.preferenceStore.read()).toBe(false); + expect(context.runtime.getStatus()).toMatchObject({ enabled: false, status: "disabled" }); + }); +}); + +describe("Linux CUA private data", () => { + it("uses strict preference schema and private atomic files", () => { + const userData = temporaryDirectory(); + const store = createLinuxCuaPreferenceStore({ getUserData: () => userData }); + store.write(true); + const file = path.join(userData, "cua-local-control.json"); + expect(store.read()).toBe(true); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + fs.writeFileSync(file, JSON.stringify({ schemaVersion: 1, linuxLocalControlEnabled: true, extra: true }), { + mode: 0o600, + }); + expect(store.read()).toBe(false); + }); + + it("does not follow a symlink when creating private state", () => { + const root = temporaryDirectory(); + const target = path.join(root, "target.json"); + const link = path.join(root, "state.json"); + fs.writeFileSync(target, "untouched", { mode: 0o600 }); + fs.symlinkSync(target, link); + writePrivateJson(link, { ok: true }); + expect(fs.readFileSync(target, "utf8")).toBe("untouched"); + expect(JSON.parse(fs.readFileSync(link, "utf8"))).toEqual({ ok: true }); + }); +}); + +describe("Linux CUA handshake validation", () => { + it("pins metadata to the certified child and contract", () => { + const valid = { ok: true, result: handshake(99).metadata }; + expect(validateDaemonMetadata(valid, { childPid: 99 })).toEqual(valid.result); + expect(() => validateDaemonMetadata(valid, { childPid: 100 })).toThrow(/PID/); + expect(() => + validateDaemonMetadata({ ok: true, result: { ...valid.result, contract_version: "9" } }), + ).toThrow(/contract_version/); + }); + + it("requires the inspect and mutation tool surface", () => { + const tools = handshake().tools.map((name) => ({ name })); + expect(validateToolSurface({ ok: true, result: tools })).toEqual([...handshake().tools].sort()); + expect(() => + validateToolSurface({ ok: true, result: tools.filter((tool) => tool.name !== "type_text") }), + ).toThrow(/type_text/); + }); +}); diff --git a/electron/cua-linux.cjs b/electron/cua-linux.cjs index 56d290fd..ccc9f771 100644 --- a/electron/cua-linux.cjs +++ b/electron/cua-linux.cjs @@ -363,7 +363,7 @@ function validateDoctor(report) { if (display?.status !== "ok" || !display.message.startsWith("X11 ")) { throw commandFailure("x11-unavailable", "Cua Driver did not confirm an Xorg display.", { probes }); } - if (x11?.status !== "ok") { + if (!x11 || x11.status === "err") { throw commandFailure("x11-unavailable", "Cua Driver could not verify the Xorg session.", { probes }); } if (atSpi?.status !== "ok") { diff --git a/electron/cua.mjs b/electron/cua.mjs index 2dabcf75..477c9f55 100644 --- a/electron/cua.mjs +++ b/electron/cua.mjs @@ -24,6 +24,7 @@ import { pathToFileURL } from "node:url"; const require = createRequire(import.meta.url); const { createCuaConnectionStore } = require("./cua-connection.cjs"); +const { createLinuxCuaRuntime } = require("./cua-linux-runtime.cjs"); const INSTALLED_DRIVER = "/Applications/CuaDriver.app/Contents/MacOS/cua-driver"; const STANDALONE_SOCKET = path.join( @@ -33,10 +34,27 @@ const STANDALONE_SOCKET = path.join( const HOST_BUNDLE_ID = "com.openmausbot.app"; let embeddedHost = null; // EmbeddedCuaDriverHost | null +let linuxRuntime = null; +let stateListener = () => {}; const connectionStore = createCuaConnectionStore({ getUserData: () => app.getPath("userData"), }); +function ensureLinuxRuntime() { + if (!linuxRuntime) { + linuxRuntime = createLinuxCuaRuntime({ + getUserData: () => app.getPath("userData"), + connectionStore, + onChange: (connection) => stateListener(connection), + }); + } + return linuxRuntime; +} + +export function setCuaStateListener(listener) { + stateListener = typeof listener === "function" ? listener : () => {}; +} + export function resolveDriverBinary() { if (process.env.CUA_DRIVER_PATH) return process.env.CUA_DRIVER_PATH; if (app.isPackaged) { @@ -105,6 +123,7 @@ async function startEmbedded(binary) { } export async function startCua() { + if (process.platform === "linux") return ensureLinuxRuntime().initialize(); const binary = resolveDriverBinary(); if (!binary) { return connectionStore.persist({ @@ -161,6 +180,10 @@ export function cuaPermissionsStatus() { } export async function stopCua() { + if (linuxRuntime) { + await linuxRuntime.shutdown(); + return; + } if (embeddedHost) { try { await embeddedHost.stop(); @@ -178,4 +201,30 @@ export async function stopCua() { export function registerCuaIpc() { ipcMain.handle("cua:connection", () => connectionStore.get()); ipcMain.handle("cua:permissions", () => cuaPermissionsStatus()); + ipcMain.handle("cua:linux-status", () => + process.platform === "linux" + ? ensureLinuxRuntime().getStatus() + : { enabled: false, status: "unavailable", reasonCode: "unsupported-platform" }, + ); + ipcMain.handle("cua:linux-enable", async () => { + if (process.platform !== "linux") { + return { enabled: false, status: "unavailable", reasonCode: "unsupported-platform" }; + } + await ensureLinuxRuntime().enable(); + return ensureLinuxRuntime().getStatus(); + }); + ipcMain.handle("cua:linux-disable", async () => { + if (process.platform !== "linux") { + return { enabled: false, status: "unavailable", reasonCode: "unsupported-platform" }; + } + await ensureLinuxRuntime().disable(); + return ensureLinuxRuntime().getStatus(); + }); + ipcMain.handle("cua:linux-retry", async () => { + if (process.platform !== "linux") { + return { enabled: false, status: "unavailable", reasonCode: "unsupported-platform" }; + } + await ensureLinuxRuntime().retry(); + return ensureLinuxRuntime().getStatus(); + }); } diff --git a/electron/main.mjs b/electron/main.mjs index 7865e5e9..7324ff68 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -3,7 +3,7 @@ import { createRequire } from "node:module"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { startCua, stopCua, registerCuaIpc } from "./cua.mjs"; +import { startCua, stopCua, registerCuaIpc, setCuaStateListener } from "./cua.mjs"; import { finishSpeech, startSpeech, stopSpeech } from "./speech.mjs"; import { openBlankTerminal } from "./terminal-launch.mjs"; import { startUpdater, registerUpdaterIpc } from "./updater.mjs"; @@ -311,6 +311,23 @@ ipcMain.handle("desktop:capabilities", async () => }), ); +async function broadcastDesktopCapabilities() { + const capabilities = desktopCapabilities({ + platform: process.platform, + env: process.env, + packaged: app.isPackaged, + localConnection: await cuaReady, + }); + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send("desktop:capabilities-changed", capabilities); + } +} + +setCuaStateListener((connection) => { + cuaReady = Promise.resolve(connection); + void broadcastDesktopCapabilities(); +}); + app.whenReady().then(async () => { if (process.platform === "darwin") app.dock.setIcon(APP_ICON); // Display capture remains user-initiated. The renderer first sends a @@ -362,7 +379,7 @@ app.whenReady().then(async () => { // connection descriptor on first render. Never blocks window creation on // failure — computer use degrades to "unavailable", the rest still works. cuaReady = - process.platform === "darwin" + process.platform === "darwin" || process.platform === "linux" ? startCua().catch((e) => { console.error("[cua] start failed:", e); return { mode: "unavailable", reason: String(e) }; diff --git a/electron/preload.cjs b/electron/preload.cjs index a7dfbaca..b6b88397 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -6,6 +6,17 @@ contextBridge.exposeInMainWorld("ogb", { /** Host platform ("darwin" | "win32" | "linux") — for platform-aware UI. */ platform: process.platform, getCapabilities: () => ipcRenderer.invoke("desktop:capabilities"), + onCapabilitiesChanged: (cb) => { + const handler = (_event, capabilities) => cb(capabilities); + ipcRenderer.on("desktop:capabilities-changed", handler); + return () => ipcRenderer.removeListener("desktop:capabilities-changed", handler); + }, + localControl: { + status: () => ipcRenderer.invoke("cua:linux-status"), + enable: () => ipcRenderer.invoke("cua:linux-enable"), + disable: () => ipcRenderer.invoke("cua:linux-disable"), + retry: () => ipcRenderer.invoke("cua:linux-retry"), + }, /** Arms exactly one display-media request from the current renderer frame. */ beginScreenPreviewIntent: () => ipcRenderer.sendSync("screen:preview-intent"), /** One frame of this computer's screen as a data: URL when supported. */ diff --git a/package.json b/package.json index 595fe0ab..259f0b43 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "test:watch": "vitest", "test:cua": "pnpm build:cua && node scripts/smoke-cua.mjs", "test:cua-container": "node scripts/smoke-cua-container.mjs", - "check:electron": "node --check electron/main.mjs && node --check electron/terminal-launch.mjs && node --check electron/preload.cjs && node --check electron/capabilities.cjs && node --check electron/cua-connection.cjs && node --check electron/cua-linux.cjs && node --check electron/cua.mjs && node --check electron/screen-preview.cjs && node --check electron/speech.mjs", + "check:electron": "node --check electron/main.mjs && node --check electron/terminal-launch.mjs && node --check electron/preload.cjs && node --check electron/capabilities.cjs && node --check electron/cua-connection.cjs && node --check electron/cua-linux.cjs && node --check electron/cua-linux-runtime.cjs && node --check electron/cua.mjs && node --check electron/screen-preview.cjs && node --check electron/speech.mjs", "preview": "vite preview", "build:server": "tsc -p tsconfig.server.build.json", "build:speech": "node electron/build-speech-helper.mjs", diff --git a/src/components/DesktopCapabilities.tsx b/src/components/DesktopCapabilities.tsx index 260b1e73..42622f2b 100644 --- a/src/components/DesktopCapabilities.tsx +++ b/src/components/DesktopCapabilities.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; -import { initialDesktopCapabilities, loadDesktopCapabilities } from "@/lib/desktop"; +import { cacheDesktopCapabilities, initialDesktopCapabilities, loadDesktopCapabilities } from "@/lib/desktop"; type DesktopState = { capabilities: DesktopCapabilities; @@ -19,11 +19,15 @@ export function DesktopCapabilitiesProvider({ children }: { children: ReactNode useEffect(() => { let alive = true; + const unsubscribe = window.ogb?.onCapabilitiesChanged?.((capabilities) => { + if (alive) setState({ capabilities: cacheDesktopCapabilities(capabilities), ready: true }); + }); void loadDesktopCapabilities().then((capabilities) => { if (alive) setState({ capabilities, ready: true }); }); return () => { alive = false; + unsubscribe?.(); }; }, []); diff --git a/src/lib/desktop.ts b/src/lib/desktop.ts index 341aa1d3..db8ed949 100644 --- a/src/lib/desktop.ts +++ b/src/lib/desktop.ts @@ -20,6 +20,8 @@ const browserCapabilities: DesktopCapabilities = { localComputer: { available: false, support: "unsupported", + enabled: false, + status: "unavailable", reasonCode: "desktop-app-required", }, }; @@ -61,3 +63,8 @@ export async function loadDesktopCapabilities(): Promise { } return cached; } + +export function cacheDesktopCapabilities(capabilities: DesktopCapabilities): DesktopCapabilities { + cached = capabilities; + return capabilities; +} diff --git a/src/types/ogb.d.ts b/src/types/ogb.d.ts index b62d1788..1b0593e9 100644 --- a/src/types/ogb.d.ts +++ b/src/types/ogb.d.ts @@ -24,7 +24,12 @@ declare global { localComputer: { available: boolean; support: "supported" | "limited" | "unsupported"; + enabled: boolean; + status: "disabled" | "checking" | "starting" | "ready" | "error" | "stopped" | "unavailable"; reasonCode?: string; + message?: string; + driverPath?: string; + driverVersion?: string; }; }; @@ -32,6 +37,13 @@ declare global { ogb?: { platform: NodeJS.Platform; getCapabilities(): Promise; + onCapabilitiesChanged(cb: (capabilities: DesktopCapabilities) => void): () => void; + localControl: { + status(): Promise; + enable(): Promise; + disable(): Promise; + retry(): Promise; + }; /** Arms one user-initiated display capture request from this frame. */ beginScreenPreviewIntent(): boolean; screenFrame(): Promise; @@ -72,6 +84,16 @@ declare global { } } +export interface LinuxLocalControlStatus { + enabled: boolean; + status: "disabled" | "checking" | "starting" | "ready" | "error" | "stopped" | "unavailable"; + reasonCode?: string; + message?: string; + driverPath?: string; + driverVersion?: string; + warnings?: Array<{ label: string; status: string; message: string; detail?: string }>; +} + export interface UpdaterState { status: "idle" | "checking" | "available" | "downloading" | "downloaded" | "error"; version?: string; From 646c52d2bc462e0d551cef6d50180d998a4a39d8 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Thu, 13 Aug 2026 19:30:09 -0300 Subject: [PATCH 08/32] isolate Linux CUA routing and approvals --- server/auto-approve.test.ts | 17 +++ server/auto-approve.ts | 19 ++- server/contracts.ts | 19 ++- server/drivers/acp/acp.test.ts | 82 ++++++++++- server/drivers/acp/core.ts | 13 +- server/drivers/claude.test.ts | 80 ++++++++++- server/drivers/claude.ts | 27 +++- server/harness/registry.ts | 3 +- server/index.ts | 51 +++++-- server/local-computer.test.ts | 157 +++++++++++++++++---- server/local-computer.ts | 251 ++++++++++++++++++++++++++++++--- server/local-routing.test.ts | 41 ++++++ server/local-routing.ts | 15 ++ server/store.ts | 2 + server/testing/fake-acp-cli.ts | 7 +- src/state/store.tsx | 3 +- 16 files changed, 709 insertions(+), 78 deletions(-) create mode 100644 server/local-routing.test.ts create mode 100644 server/local-routing.ts diff --git a/server/auto-approve.test.ts b/server/auto-approve.test.ts index a4bdad7a..85445191 100644 --- a/server/auto-approve.test.ts +++ b/server/auto-approve.test.ts @@ -74,6 +74,13 @@ describe("approvalKey", () => { expect(approvalKey("mcp__ogb__computer_batch", "click 5,5")).toBe("mcp__ogb__computer_batch"); }); + it("names local and cloud grants in different scopes", () => { + expect(approvalKey("mcp__computer__click", "click", "local-computer")).toBe( + "local-computer:mcp__computer__click", + ); + expect(approvalKey("mcp__computer__click", "click")).toBe("mcp__computer__click"); + }); + it("grants one program, not the whole shell", () => { const bot = { alwaysAllow: [approvalKey("Bash", "git status")] }; expect(autoDecision(bot, "Bash", "git log --oneline")).toBeTruthy(); @@ -104,4 +111,14 @@ describe("autoDecision", () => { it("never lets always-allow override the destructive guard", () => { expect(autoDecision({ alwaysAllow: ["Bash"] }, "Bash", "sudo rm -rf /var")).toBeNull(); }); + + it("never delegates a local-computer request to auto or remembered grants", () => { + const bot = { + autoApprove: true, + alwaysAllow: ["mcp__computer__click", "local-computer:mcp__computer__click"], + }; + expect( + autoDecision(bot, "mcp__computer__click", "Click the Submit button", "local-computer"), + ).toBeNull(); + }); }); diff --git a/server/auto-approve.ts b/server/auto-approve.ts index 1ea34052..c1ff7941 100644 --- a/server/auto-approve.ts +++ b/server/auto-approve.ts @@ -49,15 +49,16 @@ export function looksDestructive(text: string): boolean { * client so the two sides can never disagree about what was granted. */ const COMMAND_TOOLS = new Set(["bash", "shell", "execute", "run_command", "computer_exec", "terminal"]); -export function approvalKey(tool: string, summary: string): string { +export function approvalKey(tool: string, summary: string, scope?: "local-computer"): string { const bare = tool.replace(/^mcp__[^_]+__/, "").toLowerCase(); - if (!COMMAND_TOOLS.has(bare)) return tool; + if (!COMMAND_TOOLS.has(bare)) return scope ? `${scope}:${tool}` : tool; // first bare word of the command, skipping env assignments and sudo const words = summary.trim().split(/\s+/); let i = 0; while (i < words.length && (/^[A-Z_][A-Z0-9_]*=/.test(words[i]) || words[i] === "sudo")) i += 1; const program = (words[i] ?? "").split("/").pop()?.replace(/[^\w.-]/g, "") ?? ""; - return program ? `${tool}:${program}` : tool; + const key = program ? `${tool}:${program}` : tool; + return scope ? `${scope}:${key}` : key; } export interface AutoApprover { @@ -68,11 +69,19 @@ export interface AutoApprover { /** Why this request may be answered without the human, or null to ask. * The returned string becomes the chip in the transcript, so an * auto-approved action is never invisible. */ -export function autoDecision(bot: AutoApprover, tool: string, summary: string): string | null { +export function autoDecision( + bot: AutoApprover, + tool: string, + summary: string, + scope?: "local-computer", +): string | null { + // The user's active desktop is never delegated to bot auto mode or a + // remembered cloud/tool grant in the Linux beta. + if (scope === "local-computer") return null; // the guards come first, so an "always allow" can never widen into them if (looksDestructive(summary) || looksDestructive(tool)) return null; if (looksSensitive(summary)) return null; - const key = approvalKey(tool, summary); + const key = approvalKey(tool, summary, scope); if (bot.alwaysAllow?.includes(key)) return `auto-approved ${key} (always allowed)`; if (bot.autoApprove) return `auto-approved ${tool}`; return null; diff --git a/server/contracts.ts b/server/contracts.ts index 02579043..2c10b96d 100644 --- a/server/contracts.ts +++ b/server/contracts.ts @@ -73,8 +73,9 @@ export type RuntimeEvent = RuntimeEventBase & tool: string; summary: string; choices?: string[]; + approvalScope?: "local-computer"; } - | { type: "request.resolved"; behavior: string; source: string } + | { type: "request.resolved"; behavior: string; source: string; approvalScope?: "local-computer" } | { type: "thread.token-usage.updated"; input: number; output: number } // `setup: true` marks a failure the user fixes by installing or // configuring something, not by retrying — the UI offers setup instead. @@ -102,8 +103,17 @@ export interface SendTurnInput { composio?: { url?: string; key: string }; /** Cloud computer, reached through OpenMausBot's REST-to-MCP adapter. */ computer?: { kind?: "box"; boxId: string; token: string }; - /** Direct stdio connection to a Cua Driver MCP server (host or sandbox). */ - localComputer?: { command: string; args: string[]; env: Record }; + /** Direct stdio connection to a Cua Driver MCP server. `scope` is set + * only for the user's host desktop; an isolated Local VM intentionally + * omits it so host-only approval rules cannot change VM semantics. */ + localComputer?: { + command: string; + args: string[]; + env: Record; + platform?: "darwin" | "linux" | "win32"; + generation?: string; + scope?: "local-computer"; + }; /** Peer-agent comms: an MCP proxy (list_bots / ask_bot) that routes back * through the harness so this bot can message other bots. The harness * owns turns, permissions, and recursion limits; the proxy only forwards. */ @@ -129,6 +139,9 @@ export interface ProviderAdapter { * told it has a computer whose tools its driver cannot mount — it * burns turns hunting for tools that aren't there. */ computerMcp?: boolean; + /** True only when local MCP calls can reach the human approval channel. + * Full-auto/bypass provider instances must leave this false. */ + localComputerMcp?: boolean; }; sendTurn(input: SendTurnInput): Promise; interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise; diff --git a/server/drivers/acp/acp.test.ts b/server/drivers/acp/acp.test.ts index bde8a3ed..f140a676 100644 --- a/server/drivers/acp/acp.test.ts +++ b/server/drivers/acp/acp.test.ts @@ -41,6 +41,33 @@ describe("ACP decodeConfig", () => { expect(GrokAgentDriver.decodeConfig({ fullAuto: "yes" }).fullAuto).toBe(false); expect(GrokAgentDriver.decodeConfig({ fullAuto: true }).fullAuto).toBe(true); }); + + it("does not advertise or accept local CUA in full-auto mode", async () => { + const fullAuto = await GrokAgentDriver.create({ + instanceId: "grok-full-auto", + displayName: "Grok Full Auto", + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: true }, + }); + expect(fullAuto.adapter.capabilities.localComputerMcp).toBe(false); + await expect( + fullAuto.adapter.sendTurn({ + threadId: "t-full-auto-local", + text: "click", + integrations: { + localComputer: { + command: "/cua-driver", + args: ["mcp"], + env: {}, + platform: "linux", + scope: "local-computer", + }, + }, + }), + ).rejects.toThrow(/interactive provider approvals/); + await fullAuto.dispose(); + }); }); describe("ACP turns (fake CLI)", () => { @@ -117,15 +144,64 @@ describe("ACP turns (fake CLI)", () => { expect(seen.env.XAI_API_KEY).toBeUndefined(); }); + it("mounts local CUA only on an approval-capable ACP instance", async () => { + await create(); + const dump = join(scratch, "local-dump.json"); + process.env.FAKE_ACP_DUMP = dump; + await instance.adapter.sendTurn({ + threadId: "t-local", + text: "inspect", + integrations: { + localComputer: { + command: "/opt/cua driver/cua-driver", + args: ["mcp", "--embedded", "--socket", "/run/user/1000/driver.sock"], + env: { CUA_DRIVER_EMBEDDED: "1" }, + platform: "linux", + generation: "generation-1", + scope: "local-computer", + }, + }, + }); + await recorder.until((event) => event.type === "turn.completed"); + const seen = JSON.parse(readFileSync(dump, "utf8")); + expect(seen.mcpServers).toContainEqual({ + name: "computer", + command: "/opt/cua driver/cua-driver", + args: ["mcp", "--embedded", "--socket", "/run/user/1000/driver.sock"], + env: [{ name: "CUA_DRIVER_EMBEDDED", value: "1" }], + }); + expect(instance.adapter.capabilities.localComputerMcp).toBe(true); + }); + it("surfaces a permission ask as request.opened and completes once allowed", async () => { await create(GrokAgentDriver, "permission"); - await instance.adapter.sendTurn({ threadId: "t-perm", text: "go" }); + await instance.adapter.sendTurn({ + threadId: "t-perm", + text: "go", + integrations: { + localComputer: { + command: "/cua-driver", + args: ["mcp"], + env: {}, + platform: "linux", + scope: "local-computer", + }, + }, + }); const opened = await recorder.until((e) => e.type === "request.opened"); - expect(opened).toMatchObject({ requestType: "permission", tool: "shell" }); + expect(opened).toMatchObject({ + requestType: "permission", + tool: "shell", + approvalScope: "local-computer", + }); await instance.adapter.respondToRequest("t-perm", (opened as any).requestId, { behavior: "allow" }); const resolved = await recorder.until((e) => e.type === "request.resolved"); - expect(resolved).toMatchObject({ behavior: "allow", source: "user" }); + expect(resolved).toMatchObject({ + behavior: "allow", + source: "user", + approvalScope: "local-computer", + }); const done = await recorder.until((e) => e.type === "turn.completed"); expect(done).toMatchObject({ ok: true }); }); diff --git a/server/drivers/acp/core.ts b/server/drivers/acp/core.ts index 0e6b3e14..36441937 100644 --- a/server/drivers/acp/core.ts +++ b/server/drivers/acp/core.ts @@ -178,6 +178,10 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver const sendTurn = async (turn: SendTurnInput) => { const { threadId } = turn; if (active.has(threadId)) throw new Error("a turn is already running on this thread"); + const controlsHost = turn.integrations?.localComputer?.scope === "local-computer"; + if (controlsHost && config.fullAuto) { + throw new Error("local computer control requires interactive provider approvals"); + } const turnId = newId(); const cwd = turn.cwd ?? config.workspace ?? homedir(); const env = childEnv(); @@ -289,6 +293,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver requestId, behavior: optionId && behavior === "allow" ? "allow" : "deny", source: optionId ? "user" : "system", + approvalScope: controlsHost ? "local-computer" : undefined, }); }; const timer = setTimeout(() => { @@ -304,6 +309,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver requestType: "permission", tool, summary, + approvalScope: controlsHost ? "local-computer" : undefined, }); }; @@ -522,7 +528,12 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver snapshot, adapter: { provider: DRIVER_KIND, - capabilities: { sessionModelSwitch: "unsupported", agentsMcp: true, computerMcp: true }, + capabilities: { + sessionModelSwitch: "unsupported", + agentsMcp: true, + computerMcp: true, + localComputerMcp: !config.fullAuto, + }, sendTurn, interruptTurn: async (threadId) => active.get(threadId)?.interrupt(), respondToRequest: async (threadId, requestId, decision) => { diff --git a/server/drivers/claude.test.ts b/server/drivers/claude.test.ts index 38ba9271..a79df004 100644 --- a/server/drivers/claude.test.ts +++ b/server/drivers/claude.test.ts @@ -39,6 +39,33 @@ describe("ClaudeDriver.decodeConfig", () => { it.skipIf(process.platform !== "win32")("names permission pipes per harness process", () => { expect(permissionSocketPath("thread-abc")).toBe(`\\\\.\\pipe\\openmausbot-perm-${process.pid}-thread-a`); }); + + it("does not advertise or accept local CUA in bypassPermissions mode", async () => { + const bypass = await ClaudeDriver.create({ + instanceId: "claude-bypass", + displayName: "Claude Bypass", + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, permissionMode: "bypassPermissions" }, + }); + expect(bypass.adapter.capabilities.localComputerMcp).toBe(false); + await expect( + bypass.adapter.sendTurn({ + threadId: "t-bypass-local", + text: "click", + integrations: { + localComputer: { + command: "/cua-driver", + args: ["mcp"], + env: {}, + platform: "linux", + scope: "local-computer", + }, + }, + }), + ).rejects.toThrow(/interactive approval broker/); + await bypass.dispose(); + }); }); describe("ClaudeDriver turns (fake CLI)", () => { @@ -164,6 +191,38 @@ describe("ClaudeDriver turns (fake CLI)", () => { expect(allowed).toContain("mcp__agents"); }); + it("mounts local CUA without pre-allowing its computer namespace", async () => { + await create(); + const dump = join(scratch, "local-dump.json"); + process.env.FAKE_CLAUDE_DUMP = dump; + await instance.adapter.sendTurn({ + threadId: "t-local", + text: "inspect the desktop", + integrations: { + localComputer: { + command: "/opt/cua driver/cua-driver", + args: ["mcp", "--embedded", "--socket", "/run/user/1000/driver.sock"], + env: { CUA_DRIVER_EMBEDDED: "1" }, + platform: "linux", + generation: "generation-1", + scope: "local-computer", + }, + }, + }); + await recorder.until((event) => event.type === "turn.completed"); + + const seen = JSON.parse(readFileSync(dump, "utf8")); + const mcpConfig = JSON.parse(seen.argv[seen.argv.indexOf("--mcp-config") + 1]); + expect(mcpConfig.mcpServers.computer).toEqual({ + command: "/opt/cua driver/cua-driver", + args: ["mcp", "--embedded", "--socket", "/run/user/1000/driver.sock"], + env: { CUA_DRIVER_EMBEDDED: "1" }, + }); + const allowed = seen.argv[seen.argv.indexOf("--allowedTools") + 1]; + expect(allowed).not.toContain("mcp__computer"); + expect(instance.adapter.capabilities.localComputerMcp).toBe(true); + }); + it("resumes with --resume when a cursor exists and reports that session id", async () => { await create(); const dump = join(scratch, "dump.json"); @@ -233,7 +292,19 @@ describe("ClaudeDriver turns (fake CLI)", () => { it("brokers a permission ask into request.opened and answers over the socket", async () => { await create("hang"); - await instance.adapter.sendTurn({ threadId: "t-perm-abc", text: "go" }); + await instance.adapter.sendTurn({ + threadId: "t-perm-abc", + text: "go", + integrations: { + localComputer: { + command: "/cua-driver", + args: ["mcp"], + env: {}, + platform: "linux", + scope: "local-computer", + }, + }, + }); await recorder.until((e) => e.type === "session.started"); // connect as the MCP proxy would and raise an ask — unix socket on @@ -259,12 +330,17 @@ describe("ClaudeDriver turns (fake CLI)", () => { tool: "Bash", summary: "rm -rf scratch", requestId: "ask-1", + approvalScope: "local-computer", }); await instance.adapter.respondToRequest("t-perm-abc", "ask-1", { behavior: "allow" }); expect(await answered).toMatchObject({ behavior: "allow" }); const resolved = await recorder.until((e) => e.type === "request.resolved"); - expect(resolved).toMatchObject({ behavior: "allow", source: "user" }); + expect(resolved).toMatchObject({ + behavior: "allow", + source: "user", + approvalScope: "local-computer", + }); conn.end(); await instance.adapter.interruptTurn("t-perm-abc"); diff --git a/server/drivers/claude.ts b/server/drivers/claude.ts index 798d0e19..b8c1ee7c 100644 --- a/server/drivers/claude.ts +++ b/server/drivers/claude.ts @@ -239,6 +239,10 @@ export const ClaudeDriver: ProviderDriver = { const sendTurn = async (turn: SendTurnInput) => { const { threadId } = turn; if (active.has(threadId)) throw new Error("a turn is already running on this thread"); + const controlsHost = turn.integrations?.localComputer?.scope === "local-computer"; + if (controlsHost && config.permissionMode === "bypassPermissions") { + throw new Error("local computer control requires the interactive approval broker"); + } const turnId = newId(); const sessionId = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null; const newSessionId = sessionId ? null : newId(); @@ -278,11 +282,15 @@ export const ClaudeDriver: ProviderDriver = { }; allowed.push("mcp__computer"); } else if (turn.integrations?.localComputer) { - // A direct Cua Driver MCP connection. This can be the Electron-owned - // host daemon or the isolated Local VM; the agent sees the same - // "computer" server either way. - mcpServers.computer = { ...turn.integrations.localComputer }; - allowed.push("mcp__computer"); + const local = turn.integrations.localComputer; + mcpServers.computer = { + command: local.command, + args: local.args, + env: local.env, + }; + // The isolated Local VM preserves the established pre-allow behavior. + // Host tools always route through OpenMausBot's permission broker. + if (!controlsHost) allowed.push("mcp__computer"); } // peer-agent comms (list_bots/ask_bot) — the harness builds the whole // spawn contract (command/args/env incl. the boot token) in @@ -308,6 +316,7 @@ export const ClaudeDriver: ProviderDriver = { requestType: ask.kind, tool: ask.tool, summary: askSummary(ask), + approvalScope: controlsHost ? "local-computer" : undefined, choices: Array.isArray(ask.input?.choices) ? (ask.input.choices as string[]).slice(0, 5) : undefined, }), onResolve: (resolved) => @@ -317,6 +326,7 @@ export const ClaudeDriver: ProviderDriver = { requestId: resolved.id, behavior: resolved.behavior, source: resolved.source, + approvalScope: controlsHost ? "local-computer" : undefined, }), }); args.push("--permission-prompt-tool", "mcp__ogb__approve"); @@ -494,7 +504,12 @@ export const ClaudeDriver: ProviderDriver = { snapshot, adapter: { provider: DRIVER_KIND, - capabilities: { sessionModelSwitch: "in-session", agentsMcp: true, computerMcp: true }, + capabilities: { + sessionModelSwitch: "in-session", + agentsMcp: true, + computerMcp: true, + localComputerMcp: config.permissionMode !== "bypassPermissions", + }, sendTurn, interruptTurn: async (threadId) => active.get(threadId)?.stop(), respondToRequest: async (threadId, requestId, decision) => { diff --git a/server/harness/registry.ts b/server/harness/registry.ts index 8f3c5716..4ac7193d 100644 --- a/server/harness/registry.ts +++ b/server/harness/registry.ts @@ -96,7 +96,7 @@ export class ProviderRegistry { displayName: entry.shadow.displayName ?? entry.shadow.driverKind, snapshot: { state: "unavailable", reason: entry.shadow.reason } satisfies ProviderSnapshot, models: { default: "", options: [] }, - capabilities: { computerMcp: false, agentsMcp: false }, + capabilities: { computerMcp: false, agentsMcp: false, localComputerMcp: false }, // an unknown driver has no driver record, hence no install path install: this.driversByKind.get(entry.shadow.driverKind)?.install, }; @@ -117,6 +117,7 @@ export class ProviderRegistry { capabilities: { computerMcp: inst.adapter.capabilities.computerMcp === true, agentsMcp: inst.adapter.capabilities.agentsMcp === true, + localComputerMcp: inst.adapter.capabilities.localComputerMcp === true, }, install: this.driversByKind.get(inst.driverKind)?.install, }; diff --git a/server/index.ts b/server/index.ts index 70c7daf5..188b1941 100644 --- a/server/index.ts +++ b/server/index.ts @@ -31,6 +31,7 @@ import * as tts from "./tts/index.ts"; import { narrateTool, toUtterances } from "./tts/speech-text.ts"; import { readCuaConnection } from "./local-computer.ts"; import { RoutineManager, type RoutineRunOn } from "./routines.ts"; +import { shouldMountLocalComputer } from "./local-routing.ts"; const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799); const STATIC_DIR = process.env.OMB_STATIC_DIR || null; @@ -243,7 +244,7 @@ bus.subscribe((event: RuntimeEvent) => { // looks destructive stops even in auto mode. const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined); const settled = permission && asker && event.requestId - ? autoDecision(asker, event.tool, event.summary) + ? autoDecision(asker, event.tool, event.summary, event.approvalScope) : null; if (settled && asker && event.requestId) { const instance = event.providerInstanceId @@ -276,8 +277,14 @@ bus.subscribe((event: RuntimeEvent) => { options: ["Allow", "Deny"], requestId, tool, - allowKey: approvalKey(tool, summary), - held: "Auto mode couldn't answer this one.", + allowKey: event.approvalScope + ? undefined + : approvalKey(tool, summary, event.approvalScope), + held: + event.approvalScope === "local-computer" + ? "Local computer actions always require your approval in this beta." + : "Auto mode couldn't answer this one.", + approvalScope: event.approvalScope, }, }); askMessageByRequest.set(`${event.threadId}:${requestId}`, card.id); @@ -289,16 +296,30 @@ bus.subscribe((event: RuntimeEvent) => { role: "bot", kind: "options", card: { - title: permission ? "Approval needed" : "Your bot has a question", + title: + permission && event.approvalScope === "local-computer" + ? "Local computer approval" + : permission + ? "Approval needed" + : "Your bot has a question", subtitle: event.summary, options: event.choices?.length ? event.choices : permission ? ["Allow", "Deny"] : [], requestId: event.requestId, tool: permission ? event.tool : undefined, // the exact grant "always allow" would remember, decided here so // client and server can never derive it differently - allowKey: permission ? approvalKey(event.tool, event.summary) : undefined, + allowKey: + permission && !event.approvalScope + ? approvalKey(event.tool, event.summary, event.approvalScope) + : undefined, // in auto mode a card can only mean the guard stopped it — say so - held: permission && asker?.autoApprove ? "This looked destructive, so auto mode stopped to ask." : undefined, + held: + permission && event.approvalScope === "local-computer" + ? "Local computer actions always require your approval in this beta." + : permission && asker?.autoApprove + ? "This looked destructive, so auto mode stopped to ask." + : undefined, + approvalScope: event.approvalScope, }, }); if (event.requestId) askMessageByRequest.set(`${event.threadId}:${event.requestId}`, message.id); @@ -526,6 +547,7 @@ async function startTurn( const wants = opts?.runOn === "cloud" ? "cloud" : bot.computer; // cloud routine overrides the MAUS default const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true; const mountsCloudComputer = mountsComputerMcp || instance.driverKind === "boxAgent"; + const mountsLocalComputer = instance.adapter.capabilities.localComputerMcp === true; let previewBoxId: string | null = null; let computerKind: "box" | "vm" | "local" | null = null; @@ -546,7 +568,11 @@ async function startTurn( integrations.localComputer = containerComputerMcp(localVm.runtime); computerKind = "vm"; } else if (wants === "local") { - if (!mountsComputerMcp) { + if (!shouldMountLocalComputer({ + requested: "local", + hostPlatform: process.platform, + providerSupportsLocal: mountsLocalComputer, + })) { throw new Error("this model engine cannot control this computer — choose Claude or an ACP engine, or select another destination"); } const cua = readCuaConnection(); @@ -594,7 +620,16 @@ async function startTurn( // Auto-only host fallback. Electron owns cua-driver/TCC attribution; // the harness only reads its already-running connection descriptor. - if (!integrations.computer && !integrations.localComputer && wants === undefined && mountsComputerMcp) { + if ( + !integrations.computer && + !integrations.localComputer && + wants === undefined && + shouldMountLocalComputer({ + requested: undefined, + hostPlatform: process.platform, + providerSupportsLocal: mountsLocalComputer, + }) + ) { const cua = readCuaConnection(); if (cua) { integrations.localComputer = cua; diff --git a/server/local-computer.test.ts b/server/local-computer.test.ts index 8116e72d..f20153e7 100644 --- a/server/local-computer.test.ts +++ b/server/local-computer.test.ts @@ -1,13 +1,129 @@ -import { mkdirSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { readCuaConnection } from "./local-computer.ts"; +import { + decodeLinuxDescriptor, + readCuaConnection, + validateLinuxDescriptorRuntime, +} from "./local-computer.ts"; + +function linuxDescriptor(userData: string) { + const binary = join(userData, "cua-driver"); + const socket = join(userData, "runtime", "driver.sock"); + writeFileSync(binary, "fake", { mode: 0o700 }); + return { + schemaVersion: 1, + mode: "linux-x11-supervised", + platform: "linux", + session: "x11", + enabled: true, + status: "ready", + ownerPid: process.pid, + generation: "01234567-89ab-cdef-0123-456789abcdef", + driver: { path: binary, version: "0.19.3", manifestSchema: "1" }, + daemon: { + socketPath: socket, + pid: process.pid, + contractVersion: "0.6.0", + toolsListSchemaVersion: "1", + capabilityVersion: "1", + mcpProtocolVersion: "2025-06-18", + }, + mcp: { + command: binary, + args: ["mcp", "--embedded", "--socket", socket], + env: { + CUA_DRIVER_EMBEDDED: "1", + CUA_DRIVER_HOST_BUNDLE_ID: "com.openmausbot.app", + CUA_DRIVER_RS_UPDATE_CHECK: "false", + }, + }, + toolNames: ["click", "get_window_state", "list_apps", "type_text"], + doctorWarnings: [], + }; +} + +function privateUserData(name: string) { + const userData = join(process.env.HOME!, name); + mkdirSync(userData, { recursive: true, mode: 0o700 }); + chmodSync(userData, 0o700); + return userData; +} describe("local computer descriptor", () => { - it("fails closed on Linux even when a valid-looking descriptor exists", () => { - const userData = join(process.env.HOME!, "linux-user-data"); - mkdirSync(userData, { recursive: true }); + it("accepts only the exact certified Linux X11 descriptor", () => { + const userData = privateUserData("linux-user-data"); + const descriptor = linuxDescriptor(userData); + writeFileSync(join(userData, "cua-connection.json"), JSON.stringify(descriptor), { mode: 0o600 }); + + expect( + readCuaConnection({ + platform: "linux", + userData, + validateLinuxRuntime: () => true, + }), + ).toEqual({ + command: descriptor.driver.path, + args: descriptor.mcp.args, + env: descriptor.mcp.env, + platform: "linux", + generation: descriptor.generation, + scope: "local-computer", + }); + }); + + it("rejects unknown fields, stale modes, arbitrary argv, and incomplete tool surfaces", () => { + const userData = privateUserData("linux-invalid-user-data"); + const descriptor = linuxDescriptor(userData); + expect(decodeLinuxDescriptor({ ...descriptor, unexpected: true })).toBeNull(); + expect(decodeLinuxDescriptor({ ...descriptor, status: "starting" })).toBeNull(); + expect( + decodeLinuxDescriptor({ + ...descriptor, + mcp: { ...descriptor.mcp, args: ["mcp", "--socket", descriptor.daemon.socketPath, "--evil"] }, + }), + ).toBeNull(); + expect(decodeLinuxDescriptor({ ...descriptor, toolNames: ["list_apps"] })).toBeNull(); + }); + + it("fails closed when runtime ownership or liveness validation fails", () => { + const userData = privateUserData("linux-stale-user-data"); + const descriptor = linuxDescriptor(userData); + writeFileSync(join(userData, "cua-connection.json"), JSON.stringify(descriptor), { mode: 0o600 }); + expect( + readCuaConnection({ platform: "linux", userData, validateLinuxRuntime: () => false }), + ).toBeNull(); + }); + + it.skipIf(process.platform === "win32")( + "validates private descriptor, executable, socket, and live owned processes", + async () => { + const userData = privateUserData("linux-runtime-security"); + const runtimeDirectory = join(userData, "runtime"); + mkdirSync(runtimeDirectory, { mode: 0o700 }); + const descriptor = linuxDescriptor(userData); + const file = join(userData, "cua-connection.json"); + writeFileSync(file, JSON.stringify(descriptor), { mode: 0o600 }); + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(descriptor.daemon.socketPath, resolve); + }); + try { + chmodSync(descriptor.daemon.socketPath, 0o600); + expect(validateLinuxDescriptorRuntime(file, descriptor)).toBe(true); + chmodSync(file, 0o644); + expect(validateLinuxDescriptorRuntime(file, descriptor)).toBe(false); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }, + ); + + it("still rejects an old embedded-looking Linux descriptor", () => { + const userData = privateUserData("linux-forged-user-data"); writeFileSync( join(userData, "cua-connection.json"), JSON.stringify({ @@ -16,14 +132,13 @@ describe("local computer descriptor", () => { mcpArgs: ["mcp", "--embedded"], mcpEnv: { CUA_DRIVER_EMBEDDED: "1" }, }), + { mode: 0o600 }, ); - expect(readCuaConnection({ platform: "linux", userData })).toBeNull(); }); - it("reads and validates an exact platform userData descriptor", () => { - const userData = join(process.env.HOME!, "windows-user-data"); - mkdirSync(userData, { recursive: true }); + it("preserves the selected Windows descriptor contract", () => { + const userData = privateUserData("windows-user-data"); writeFileSync( join(userData, "cua-connection.json"), JSON.stringify({ @@ -33,37 +148,21 @@ describe("local computer descriptor", () => { mcpEnv: { CUA_DRIVER_EMBEDDED: "1" }, }), ); - expect(readCuaConnection({ platform: "win32", userData })).toEqual({ command: "C:\\cua-driver.exe", args: ["mcp"], env: { CUA_DRIVER_EMBEDDED: "1" }, + platform: "win32", + scope: "local-computer", }); }); - it("rejects malformed argv and environment values", () => { - const userData = join(process.env.HOME!, "invalid-user-data"); - mkdirSync(userData, { recursive: true }); + it("rejects malformed legacy argv and environment values", () => { + const userData = privateUserData("invalid-user-data"); writeFileSync( join(userData, "cua-connection.json"), JSON.stringify({ mode: "embedded", mcpCommand: "cua-driver", mcpArgs: "mcp" }), ); - - expect(readCuaConnection({ platform: "win32", userData })).toBeNull(); - }); - - it("rejects an array environment descriptor", () => { - const userData = join(process.env.HOME!, "array-environment-user-data"); - mkdirSync(userData, { recursive: true }); - writeFileSync( - join(userData, "cua-connection.json"), - JSON.stringify({ - mode: "embedded", - mcpCommand: "cua-driver", - mcpEnv: ["CUA_DRIVER_EMBEDDED=1"], - }), - ); - expect(readCuaConnection({ platform: "win32", userData })).toBeNull(); }); }); diff --git a/server/local-computer.ts b/server/local-computer.ts index 0cf25c62..0611f59c 100644 --- a/server/local-computer.ts +++ b/server/local-computer.ts @@ -1,22 +1,46 @@ -import { readFileSync } from "node:fs"; +import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs"; +import type { Stats } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; + +const REQUIRED_LINUX_TOOLS = ["click", "get_window_state", "list_apps", "type_text"]; export type LocalComputerConnection = { command: string; args: string[]; env: Record; + platform: "darwin" | "linux" | "win32"; + generation?: string; + scope: "local-computer"; }; -type ConnectionDescriptor = { +type LegacyConnectionDescriptor = { mode?: string; mcpCommand?: unknown; mcpArgs?: unknown; mcpEnv?: unknown; }; -function decodeDescriptor(value: ConnectionDescriptor): LocalComputerConnection | null { - if (!value || value.mode === "unavailable" || typeof value.mcpCommand !== "string") return null; +type LinuxConnectionDescriptor = Record; + +function exactKeys(value: Record, keys: readonly string[]): boolean { + const expected = new Set(keys); + return Object.keys(value).length === expected.size && Object.keys(value).every((key) => expected.has(key)); +} + +function legacyPlatform(platform: NodeJS.Platform): "darwin" | "win32" | null { + if (platform === "darwin" || platform === "win32") return platform; + return null; +} + +function decodeLegacyDescriptor( + value: LegacyConnectionDescriptor, + platform: NodeJS.Platform, +): LocalComputerConnection | null { + const supportedPlatform = legacyPlatform(platform); + if (!supportedPlatform || !value || value.mode === "unavailable" || typeof value.mcpCommand !== "string") { + return null; + } if (value.mcpArgs !== undefined && !Array.isArray(value.mcpArgs)) return null; if ( value.mcpEnv !== undefined && @@ -24,48 +48,239 @@ function decodeDescriptor(value: ConnectionDescriptor): LocalComputerConnection ) { return null; } - const args = value.mcpArgs ?? ["mcp"]; if (!args.every((arg) => typeof arg === "string")) return null; - const env = value.mcpEnv ?? {}; if (!Object.values(env).every((entry) => typeof entry === "string")) return null; - return { command: value.mcpCommand, args, env: env as Record, + platform: supportedPlatform, + scope: "local-computer", }; } +export function decodeLinuxDescriptor(value: LinuxConnectionDescriptor): LocalComputerConnection | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + if ( + !exactKeys(value, [ + "schemaVersion", + "mode", + "platform", + "session", + "enabled", + "status", + "ownerPid", + "generation", + "driver", + "daemon", + "mcp", + "toolNames", + "doctorWarnings", + ]) || + value.schemaVersion !== 1 || + value.mode !== "linux-x11-supervised" || + value.platform !== "linux" || + value.session !== "x11" || + value.enabled !== true || + value.status !== "ready" || + !Number.isInteger(value.ownerPid) || + (value.ownerPid as number) <= 0 || + typeof value.generation !== "string" || + !/^[0-9a-f-]{32,64}$/i.test(value.generation) + ) { + return null; + } + + const driver = value.driver as Record; + const daemon = value.daemon as Record; + const mcp = value.mcp as Record; + if ( + !driver || + !daemon || + !mcp || + Array.isArray(driver) || + Array.isArray(daemon) || + Array.isArray(mcp) || + !exactKeys(driver, ["path", "version", "manifestSchema"]) || + !exactKeys(daemon, [ + "socketPath", + "pid", + "contractVersion", + "toolsListSchemaVersion", + "capabilityVersion", + "mcpProtocolVersion", + ]) || + !exactKeys(mcp, ["command", "args", "env"]) + ) { + return null; + } + if ( + typeof driver.path !== "string" || + !isAbsolute(driver.path) || + driver.version !== "0.19.3" || + driver.manifestSchema !== "1" || + typeof daemon.socketPath !== "string" || + !isAbsolute(daemon.socketPath) || + !Number.isInteger(daemon.pid) || + (daemon.pid as number) <= 0 || + daemon.contractVersion !== "0.6.0" || + daemon.toolsListSchemaVersion !== "1" || + daemon.capabilityVersion !== "1" || + daemon.mcpProtocolVersion !== "2025-06-18" || + mcp.command !== driver.path || + !Array.isArray(mcp.args) || + mcp.args.length !== 4 || + mcp.args[0] !== "mcp" || + mcp.args[1] !== "--embedded" || + mcp.args[2] !== "--socket" || + mcp.args[3] !== daemon.socketPath || + !mcp.env || + typeof mcp.env !== "object" || + Array.isArray(mcp.env) || + !exactKeys(mcp.env as Record, [ + "CUA_DRIVER_EMBEDDED", + "CUA_DRIVER_HOST_BUNDLE_ID", + "CUA_DRIVER_RS_UPDATE_CHECK", + ]) || + (mcp.env as Record).CUA_DRIVER_EMBEDDED !== "1" || + (mcp.env as Record).CUA_DRIVER_HOST_BUNDLE_ID !== "com.openmausbot.app" || + (mcp.env as Record).CUA_DRIVER_RS_UPDATE_CHECK !== "false" + ) { + return null; + } + + if ( + !Array.isArray(value.toolNames) || + value.toolNames.some((name) => typeof name !== "string") || + REQUIRED_LINUX_TOOLS.some((name) => !(value.toolNames as string[]).includes(name)) || + !Array.isArray(value.doctorWarnings) + ) { + return null; + } + for (const warning of value.doctorWarnings) { + if ( + !warning || + typeof warning !== "object" || + Array.isArray(warning) || + ![3, 4].includes(Object.keys(warning).length) || + !Object.keys(warning).every((key) => ["label", "status", "message", "detail"].includes(key)) || + typeof warning.label !== "string" || + warning.status !== "warn" || + typeof warning.message !== "string" || + (warning.detail !== undefined && typeof warning.detail !== "string") + ) { + return null; + } + } + + return { + command: driver.path, + args: [...(mcp.args as string[])], + env: { ...(mcp.env as Record) }, + platform: "linux", + generation: value.generation, + scope: "local-computer", + }; +} + +function processAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function ownedPrivate(stat: Stats, uid: number): boolean { + return (stat.uid === uid || stat.uid === 0) && (stat.mode & 0o077) === 0; +} + +export function validateLinuxDescriptorRuntime( + descriptorFile: string, + raw: LinuxConnectionDescriptor, + { + uid = process.getuid?.() ?? -1, + isProcessAlive = processAlive, + }: { uid?: number; isProcessAlive?: (pid: number) => boolean } = {}, +): boolean { + try { + const descriptorStat = lstatSync(descriptorFile); + const descriptorDirectoryStat = lstatSync(dirname(descriptorFile)); + if ( + !descriptorStat.isFile() || + descriptorStat.isSymbolicLink() || + !ownedPrivate(descriptorStat, uid) || + !descriptorDirectoryStat.isDirectory() || + descriptorDirectoryStat.isSymbolicLink() || + !ownedPrivate(descriptorDirectoryStat, uid) + ) { + return false; + } + + const driver = raw.driver as Record; + const daemon = raw.daemon as Record; + const binaryPath = driver.path as string; + const socketPath = daemon.socketPath as string; + const binaryStat = statSync(binaryPath); + const socketStat = lstatSync(socketPath); + const socketDirectoryStat = lstatSync(dirname(socketPath)); + if ( + realpathSync(binaryPath) !== binaryPath || + !binaryStat.isFile() || + (binaryStat.uid !== uid && binaryStat.uid !== 0) || + (binaryStat.mode & 0o111) === 0 || + (binaryStat.mode & 0o022) !== 0 || + !socketStat.isSocket() || + socketStat.isSymbolicLink() || + !ownedPrivate(socketStat, uid) || + !socketDirectoryStat.isDirectory() || + socketDirectoryStat.isSymbolicLink() || + !ownedPrivate(socketDirectoryStat, uid) || + !isProcessAlive(raw.ownerPid as number) || + !isProcessAlive(daemon.pid as number) + ) { + return false; + } + return true; + } catch { + return false; + } +} + export function readCuaConnection({ platform = process.platform, userData = process.env.OMB_USER_DATA, home = homedir(), + validateLinuxRuntime = validateLinuxDescriptorRuntime, }: { platform?: NodeJS.Platform; userData?: string; home?: string; + validateLinuxRuntime?: (file: string, raw: LinuxConnectionDescriptor) => boolean; } = {}): LocalComputerConnection | null { - // Linux local automation is deliberately outside the Ubuntu baseline. - // Ignore even a forged or stale descriptor until the CUA follow-up adds - // session-aware readiness and end-to-end evidence. - if (platform === "linux") return null; - const candidates = userData ? [join(userData, "cua-connection.json")] : []; if (platform === "darwin") { // Legacy/dev fallback. Packaged Electron passes its exact userData path. - for (const dir of ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]) { - candidates.push(join(home, "Library", "Application Support", dir, "cua-connection.json")); + for (const directory of ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]) { + candidates.push(join(home, "Library", "Application Support", directory, "cua-connection.json")); } } for (const file of [...new Set(candidates)]) { try { - const decoded = decodeDescriptor(JSON.parse(readFileSync(file, "utf8"))); - if (decoded) return decoded; + const raw = JSON.parse(readFileSync(file, "utf8")); + if (platform === "linux") { + const decoded = decodeLinuxDescriptor(raw); + if (decoded && validateLinuxRuntime(file, raw)) return decoded; + } else { + const decoded = decodeLegacyDescriptor(raw, platform); + if (decoded) return decoded; + } } catch { - // Missing, invalid, or stale descriptors are simply unavailable. + // Missing, invalid, tampered, or stale descriptors are unavailable. } } return null; diff --git a/server/local-routing.test.ts b/server/local-routing.test.ts new file mode 100644 index 00000000..d74f30ab --- /dev/null +++ b/server/local-routing.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { shouldMountLocalComputer } from "./local-routing.ts"; + +describe("local computer routing", () => { + it("never lets Linux Auto fall back to the user's desktop", () => { + expect( + shouldMountLocalComputer({ + requested: undefined, + hostPlatform: "linux", + providerSupportsLocal: true, + }), + ).toBe(false); + }); + + it("requires an explicit local selection and an approval-capable provider on Linux", () => { + expect( + shouldMountLocalComputer({ + requested: "local", + hostPlatform: "linux", + providerSupportsLocal: true, + }), + ).toBe(true); + expect( + shouldMountLocalComputer({ + requested: "local", + hostPlatform: "linux", + providerSupportsLocal: false, + }), + ).toBe(false); + }); + + it("preserves the established macOS Auto fallback", () => { + expect( + shouldMountLocalComputer({ + requested: undefined, + hostPlatform: "darwin", + providerSupportsLocal: true, + }), + ).toBe(true); + }); +}); diff --git a/server/local-routing.ts b/server/local-routing.ts new file mode 100644 index 00000000..db010a6a --- /dev/null +++ b/server/local-routing.ts @@ -0,0 +1,15 @@ +export function shouldMountLocalComputer({ + requested, + hostPlatform = process.platform, + providerSupportsLocal, +}: { + requested: "cloud" | "local" | "off" | undefined; + hostPlatform?: NodeJS.Platform; + providerSupportsLocal: boolean; +}): boolean { + if (!providerSupportsLocal) return false; + if (requested === "local") return true; + // Preserve the established macOS Auto behavior. Linux local control is a + // beta and can only be selected explicitly per bot. + return requested === undefined && hostPlatform === "darwin"; +} diff --git a/server/store.ts b/server/store.ts index 1530e41d..171239c1 100644 --- a/server/store.ts +++ b/server/store.ts @@ -44,6 +44,8 @@ export interface OptionCardData { held?: string; /** the narrow grant "always allow" remembers, e.g. "Bash:git" */ allowKey?: string; + /** Local actions never share remembered grants with cloud/tool approvals. */ + approvalScope?: "local-computer"; } export interface Message { diff --git a/server/testing/fake-acp-cli.ts b/server/testing/fake-acp-cli.ts index c89526d2..b9d5224b 100755 --- a/server/testing/fake-acp-cli.ts +++ b/server/testing/fake-acp-cli.ts @@ -18,12 +18,13 @@ import { writeFileSync } from "node:fs"; const mode = process.env.FAKE_ACP_MODE ?? "happy"; const argv = process.argv.slice(2); +const dumpState: Record = { argv, env: process.env }; if (argv.includes("--version")) { console.log("fake-acp 1.0.0"); process.exit(0); } if (process.env.FAKE_ACP_DUMP) { - writeFileSync(process.env.FAKE_ACP_DUMP, JSON.stringify({ argv, env: process.env }, null, 2)); + writeFileSync(process.env.FAKE_ACP_DUMP, JSON.stringify(dumpState, null, 2)); } const out = (obj: unknown) => process.stdout.write(JSON.stringify(obj) + "\n"); @@ -135,6 +136,10 @@ function handle(msg: any) { break; case "session/new": { const servers: McpEntry[] = Array.isArray(msg.params?.mcpServers) ? msg.params.mcpServers : []; + if (process.env.FAKE_ACP_DUMP) { + dumpState.mcpServers = servers; + writeFileSync(process.env.FAKE_ACP_DUMP, JSON.stringify(dumpState, null, 2)); + } agentsMcp = servers.find((s: any) => s?.name === "agents") ?? null; result(msg.id, { sessionId: "fake-acp-session" }); break; diff --git a/src/state/store.tsx b/src/state/store.tsx index 175851bf..78d2b272 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -34,6 +34,7 @@ export interface OptionCardData { held?: string; /** the narrow grant "always allow" remembers, e.g. "Bash:git" */ allowKey?: string; + approvalScope?: "local-computer"; } export interface Message { @@ -190,7 +191,7 @@ export interface InstanceInfo { version?: string | null; }; models: { default: string; options: Array<{ id: string; label: string }> }; - capabilities?: { computerMcp?: boolean; agentsMcp?: boolean }; + capabilities?: { computerMcp?: boolean; agentsMcp?: boolean; localComputerMcp?: boolean }; install?: EngineInstall; } From 6b9b71580714f3f252ec6555ec776393b9aaa6ef Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Thu, 13 Aug 2026 19:35:00 -0300 Subject: [PATCH 09/32] add explicit Linux local control UX --- server/index.test.ts | 19 ++++ server/index.ts | 25 +++++ src/components/ComputerPanel.tsx | 77 ++++++++------ src/components/LinuxLocalControl.tsx | 149 +++++++++++++++++++++++++++ src/components/SettingsPanel.tsx | 17 ++- src/lib/local-computer.test.ts | 22 ++++ src/lib/local-computer.ts | 41 ++++++++ src/state/store.tsx | 6 +- 8 files changed, 323 insertions(+), 33 deletions(-) create mode 100644 src/components/LinuxLocalControl.tsx create mode 100644 src/lib/local-computer.test.ts create mode 100644 src/lib/local-computer.ts diff --git a/server/index.test.ts b/server/index.test.ts index d8ffab44..9830d413 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -182,6 +182,25 @@ describe("harness HTTP API", () => { expect(after.body.bots.find((b: { id: string }) => b.id === bot.id)).toBeUndefined(); }); + it("turns off bot Auto mode when local computer beta is selected", async () => { + const created = await api("POST", "/api/bots"); + const bot = created.body.bot; + expect((await api("PATCH", `/api/bots/${bot.id}`, { autoApprove: true })).body.bot.autoApprove).toBe( + true, + ); + const local = await api("PATCH", `/api/bots/${bot.id}`, { computer: "local" }); + expect(local.body.bot).toMatchObject({ computer: "local", autoApprove: false }); + const rejected = await api("PATCH", `/api/bots/${bot.id}`, { autoApprove: true }); + expect(rejected.status).toBe(400); + expect(rejected.body.error).toContain("local computer beta"); + await api("DELETE", `/api/bots/${bot.id}`); + }); + + it("offers an idempotent stop boundary for active local turns", async () => { + const stopped = await api("POST", "/api/local-computer/interrupt"); + expect(stopped).toEqual({ status: 200, body: { ok: true } }); + }); + it("persists an answered onboarding card", async () => { const { body } = await api("GET", "/api/bots"); const bot = body.bots[0]; diff --git a/server/index.ts b/server/index.ts index 188b1941..c4bdef62 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1237,6 +1237,7 @@ const server = createServer(async (req, res) => { m = path.match(/^\/api\/bots\/([\w-]+)$/); if (m && method === "PATCH") { const body = await readBody(req); + const existingBot = store.bot(m[1]); const patch: Record = {}; for (const key of ["name", "title", "description", "notifications", "modelSelection", "unread", "computer", "color", "mascotExpression", "pinned", "hidden", "speakReplies", "voice"] as const) { if (body[key] !== undefined) patch[key] = body[key]; @@ -1259,6 +1260,9 @@ const server = createServer(async (req, res) => { // still answer .includes() — with substring matches, not tool names if (body.autoApprove !== undefined) { if (typeof body.autoApprove !== "boolean") return json(res, 400, { error: "autoApprove must be true or false" }); + if (body.autoApprove === true && existingBot?.computer === "local") { + return json(res, 400, { error: "Auto mode is unavailable while this bot uses the local computer beta" }); + } patch.autoApprove = body.autoApprove; } if (body.alwaysAllow !== undefined) { @@ -1267,6 +1271,15 @@ const server = createServer(async (req, res) => { } patch.alwaysAllow = [...new Set(body.alwaysAllow as string[])].slice(0, 200); } + if (body.computer === "local" && existingBot?.autoApprove) { + patch.autoApprove = false; + } + if (existingBot?.computer === "local" && body.computer !== undefined && body.computer !== "local") { + await registry + .get(existingBot.modelSelection.instanceId) + ?.adapter.interruptTurn(existingBot.threadId) + .catch(() => {}); + } const bot = store.patchBot(m[1], patch); if (!bot) return json(res, 404, { error: "no such bot" }); const chiefChanges = @@ -1281,6 +1294,18 @@ const server = createServer(async (req, res) => { for (const changedBot of changed.values()) broadcast({ kind: "bot", bot: changedBot }); return json(res, 200, { bot }); } + + if (method === "POST" && path === "/api/local-computer/interrupt") { + await Promise.allSettled( + store.bots + .filter((bot) => bot.computer === "local") + .map((bot) => + registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(bot.threadId), + ) + .filter((turn): turn is Promise => Boolean(turn)), + ); + return json(res, 200, { ok: true }); + } m = path.match(/^\/api\/bots\/([\w-]+)$/); if (m && method === "DELETE") { const bot = store.bot(m[1]); diff --git a/src/components/ComputerPanel.tsx b/src/components/ComputerPanel.tsx index 6412370b..31b831a0 100644 --- a/src/components/ComputerPanel.tsx +++ b/src/components/ComputerPanel.tsx @@ -1,9 +1,9 @@ // The bot's computer, in the right-side slot. Where it runs decides the // whole flow: cloud → provision the box on open (idempotent) and preview -// via SSE frames or a ~4s screenshot poll; local ("This computer") → frames -// come from the Electron main process (desktopCapturer over the preload -// bridge — box endpoints are never touched); off → parked. Auto (unset) -// prefers the cloud box when one exists, else local inside the app. +// via SSE frames or a ~4s screenshot poll. macOS local mode keeps the legacy +// in-panel capture. Linux local mode is an automation readiness state and its +// separate preview remains explicitly user-initiated. Auto never selects a +// Linux user's desktop. import { useEffect, useRef, useState } from "react"; import { CalendarDays, @@ -24,6 +24,12 @@ import { cn } from "@/lib/cn"; import { useDesktopCapabilities } from "./DesktopCapabilities"; import { RoutineEditor } from "./RoutinesPage"; import { LocalScreenPreview } from "./LocalScreenPreview"; +import { LinuxLocalControl } from "./LinuxLocalControl"; +import { + instanceSupportsLocalComputer, + linuxAutoDescription, + localComputerDisabledReason, +} from "@/lib/local-computer"; async function api(path: string, init?: RequestInit): Promise { const res = await fetch(path, { headers: { "content-type": "application/json" }, ...init }); @@ -76,6 +82,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) { const { state, dispatch } = useStore(); const { capabilities, ready: capabilitiesReady } = useDesktopCapabilities(); const localAvailable = capabilities.localComputer.available; + const isLinux = capabilities.host.platform === "linux"; + const providerSupportsLocal = instanceSupportsLocalComputer(state.instances, bot); + const localSelectable = localAvailable && providerSupportsLocal; + const localDisabledReason = localComputerDisabledReason({ capabilities, providerSupportsLocal }); const [phase, setPhase] = useState("checking"); const [boxState, setBoxState] = useState(null); const [polledFrame, setPolledFrame] = useState<{ png: string; mime: string } | null>(null); @@ -133,8 +143,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) { return; } if (bot.computer === "local") { - if (!computerToolSupported) setError("This model engine cannot control this computer. Choose Claude or an ACP engine."); - setPhase(capabilitiesReady && localAvailable && computerToolSupported ? "local" : "local-unavailable"); + if (!providerSupportsLocal) { + setError("This model engine cannot control this computer. Choose Claude or an ACP engine."); + } + setPhase(capabilitiesReady && localSelectable ? "local" : "local-unavailable"); return; } if (bot.computer === "vm") { @@ -171,7 +183,8 @@ export function ComputerPanel({ bot }: { bot: Bot }) { api(`/api/bots/${bot.id}/computer`) .then((status) => { if (!alive) return; - const autoLocal = bot.computer !== "cloud" && capabilitiesReady && localAvailable && computerToolSupported; + const autoLocal = + !isLinux && bot.computer !== "cloud" && capabilitiesReady && localSelectable; if (!status.configured) { setPhase(autoLocal ? "local" : "unconfigured"); return; @@ -195,7 +208,17 @@ export function ComputerPanel({ bot }: { bot: Bot }) { return () => { alive = false; }; - }, [bot.id, bot.computer, retry, capabilitiesReady, localAvailable, vmSupported, computerToolSupported, cloudSupported]); + }, [ + bot.id, + bot.computer, + retry, + capabilitiesReady, + localSelectable, + isLinux, + providerSupportsLocal, + vmSupported, + cloudSupported, + ]); // cloud preview: SSE frames win while the bot works; otherwise poll const live = state.screens[bot.id]; @@ -256,7 +279,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { // the user denied — surface the Settings repair path instead of spinning. const [localMisses, setLocalMisses] = useState(0); useEffect(() => { - if (phase !== "local" || !window.ogb) return; + if (phase !== "local" || !window.ogb || isLinux) return; let alive = true; setLocalMisses(0); const shoot = async () => { @@ -274,7 +297,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { alive = false; clearInterval(timer); }; - }, [phase]); + }, [phase, isLinux]); const lastScreenMessage = [...bot.messages].reverse().find((m) => m.kind === "screen" && m.png); const cloudFrame = @@ -284,7 +307,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { const frameSrc = phase === "vm" ? vmFrame - : phase === "local" + : phase === "local" && !isLinux ? localFrame : phase === "ready" || phase === "starting" ? cloudFrame && `data:${cloudFrame.mime};base64,${cloudFrame.png}` @@ -312,12 +335,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { checking: "Checking…", starting: "Starting your bot's computer…", unconfigured: "No cloud computer configured", - "local-unavailable": - capabilities.host.platform === "linux" - ? "Local computer control isn't available on Linux yet. Use a cloud box instead." - : capabilities.host.label === "Browser" - ? "Local computer control requires the desktop app." - : "CUA Driver isn't ready for local computer control.", + "local-unavailable": localDisabledReason ?? "Local computer control isn't ready.", "vm-unavailable": "The Local VM isn't available for this bot", off: "This bot's computer is off", error: "Couldn't reach the computer", @@ -355,7 +373,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {`${bot.name}'s ) : (
- {phase === "checking" || phase === "starting" || phase === "local" || phase === "vm" ? ( + {phase === "checking" || phase === "starting" || phase === "vm" || (phase === "local" && !isLinux) ? ( ) : phase === "off" ? ( @@ -368,12 +386,14 @@ export function ComputerPanel({ bot }: { bot: Bot }) { : phase === "vm" ? "Capturing the Local VM screen…" : phase === "local" - ? localMisses >= 3 + ? isLinux + ? "Ready for approved bot actions. Start the separate preview below when you want to watch the screen." + : localMisses >= 3 ? "No frames yet — the preview needs Screen Recording permission. After granting, relaunch the app." : "Capturing this computer's screen…" : emptyState[phase]} - {phase === "local" && localMisses >= 3 && ( + {phase === "local" && !isLinux && localMisses >= 3 && ( + ) : ( + <> + {!ready && ( + + )} + + + )} +
+ + + + ); +} diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 30539455..d79975e3 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -10,6 +10,8 @@ import { } from "@/lib/mascot"; import { ModelPicker } from "./ModelPicker"; import { cn } from "@/lib/cn"; +import { useDesktopCapabilities } from "./DesktopCapabilities"; +import { instanceSupportsLocalComputer, localComputerDisabledReason } from "@/lib/local-computer"; function Field({ label, @@ -33,6 +35,10 @@ export function SettingsPanel({ bot }: { bot: Bot }) { const { state, dispatch } = useStore(); const [voices, setVoices] = useState>([]); const [voicesLoading, setVoicesLoading] = useState(false); + const { capabilities } = useDesktopCapabilities(); + const providerSupportsLocal = instanceSupportsLocalComputer(state.instances, bot); + const localSelectable = capabilities.localComputer.available && providerSupportsLocal; + const localDisabledReason = localComputerDisabledReason({ capabilities, providerSupportsLocal }); const patch = ( p: Partial< Pick< @@ -250,16 +256,19 @@ export function SettingsPanel({ bot }: { bot: Bot }) { {(["cloud", "local", "off"] as const).map((mode, i) => ( ))}
@@ -269,7 +278,9 @@ export function SettingsPanel({ bot }: { bot: Bot }) {
Auto mode
- {bot.autoApprove + {bot.computer === "local" + ? "Local computer actions always require your approval in this beta." + : bot.autoApprove ? "Keeps going on its own — you'll still be asked about anything destructive, and about questions it asks you." : "Approve each action yourself. Turn on to let this bot keep working without stopping to ask."}
@@ -278,9 +289,11 @@ export function SettingsPanel({ bot }: { bot: Bot }) { role="switch" aria-checked={Boolean(bot.autoApprove)} aria-label="Auto mode" + disabled={bot.computer === "local"} onClick={() => patch({ autoApprove: !bot.autoApprove })} className={cn( "relative h-[26px] w-[44px] shrink-0 rounded-full transition-colors", + bot.computer === "local" && "cursor-not-allowed opacity-40", bot.autoApprove ? "bg-accent" : "bg-raised", )} > diff --git a/src/lib/local-computer.test.ts b/src/lib/local-computer.test.ts new file mode 100644 index 00000000..0d50baf2 --- /dev/null +++ b/src/lib/local-computer.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { instanceSupportsLocalComputer, linuxAutoDescription } from "./local-computer"; + +describe("local computer UI eligibility", () => { + it("requires the selected instance to advertise approval-capable local MCP", () => { + const bot = { modelSelection: { instanceId: "claude", model: "test" } }; + const instances = [ + { + instanceId: "claude", + capabilities: { localComputerMcp: true }, + }, + ] as any; + expect(instanceSupportsLocalComputer(instances, bot as any)).toBe(true); + expect(instanceSupportsLocalComputer([{ ...instances[0], capabilities: {} }] as any, bot as any)).toBe( + false, + ); + }); + + it("states that Linux Auto never selects this computer", () => { + expect(linuxAutoDescription()).toContain("otherwise computer use stays off"); + }); +}); diff --git a/src/lib/local-computer.ts b/src/lib/local-computer.ts new file mode 100644 index 00000000..bfc892af --- /dev/null +++ b/src/lib/local-computer.ts @@ -0,0 +1,41 @@ +import type { Bot, InstanceInfo } from "@/state/store"; + +export function instanceSupportsLocalComputer( + instances: InstanceInfo[], + bot: Pick, +): boolean { + return ( + instances.find((instance) => instance.instanceId === bot.modelSelection.instanceId)?.capabilities + ?.localComputerMcp === true + ); +} + +export function localComputerDisabledReason({ + capabilities, + providerSupportsLocal, +}: { + capabilities: DesktopCapabilities; + providerSupportsLocal: boolean; +}): string | null { + if (!providerSupportsLocal) { + return "The selected provider cannot request approvals for local computer actions."; + } + if (capabilities.localComputer.available) return null; + if (capabilities.host.platform === "linux") { + if (capabilities.localComputer.reasonCode === "wayland-unsupported") { + return "Local control requires a GNOME on Xorg session. Wayland preview remains available."; + } + if (!capabilities.localComputer.enabled) { + return "Enable the local control beta and complete the Cua Driver checks first."; + } + return capabilities.localComputer.message ?? "Cua Driver is not ready for local control."; + } + if (capabilities.host.label === "Browser") { + return "Local computer control requires the desktop app."; + } + return "CUA Driver is not ready for local computer control."; +} + +export function linuxAutoDescription(): string { + return "Auto uses a cloud box when one is configured; otherwise computer use stays off."; +} diff --git a/src/state/store.tsx b/src/state/store.tsx index 78d2b272..74572cd9 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -603,7 +603,11 @@ function reducer(state: AppState, action: Action): AppState { ), } : animated; - return updateBot(next, action.botId, (b) => ({ ...b, ...action.patch })); + return updateBot(next, action.botId, (b) => ({ + ...b, + ...action.patch, + ...(action.patch.computer === "local" ? { autoApprove: false } : {}), + })); } case "threadActive": { const bot = state.bots.find((b) => b.threadId === action.threadId); From 796e8114c118e444096d2189092172ffce77642b Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Thu, 13 Aug 2026 19:41:39 -0300 Subject: [PATCH 10/32] document and smoke-test Linux CUA beta --- CONTRIBUTING.md | 8 +- README.md | 18 ++-- docs/computer-use-integration.md | 7 ++ docs/linux-desktop.md | 81 ++++++++++++++--- electron/main.mjs | 38 +++++++- scripts/smoke-linux-package.mjs | 143 +++++++++++++++++++++++++++++-- 6 files changed, 262 insertions(+), 33 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bbf715ad..00682317 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,8 +101,12 @@ The SPI in [`server/contracts.ts`](server/contracts.ts) is deliberately small. A - Renderer code must consume the desktop capability contract rather than infer support from Electron, the user agent, or the presence of a preload bridge. Screen preview, dictation, and local control are independent capabilities. -- Test Ubuntu platform claims on a real GNOME session. Xvfb proves packaging and lifecycle, not Wayland - portal behavior or local computer control. +- Test Ubuntu platform claims on a real GNOME session. Xvfb proves packaging and fake-driver orchestration, not + Wayland portal behavior or real CUA inspection/input delivery. +- Linux local control must remain explicit: global opt-in plus per-bot **This computer**. Linux Auto, provider + full-auto/bypass modes, remembered grants, and cloud approvals must never authorize the user's desktop. +- Keep user-installed CUA discovery shell-free and pin accepted manifest/driver contracts. Do not add a bundled + binary, automatic installer/update, Wayland mutation, or default-daemon ownership to the Xorg beta. - **Never build command strings for a shell.** No `shell: true`, no spawning through `cmd.exe` with quoted strings — model names, personas, and MCP config JSON travel through argv, and cmd.exe metacharacter expansion is a real injection class. On Windows, resolve `.cmd` shims to their JS diff --git a/README.md b/README.md index 263b357e..d6f6da82 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ already have: — your existing logins and subscriptions, no new accounts, no proxy in the middle. - **Local first.** One small harness server on `127.0.0.1` owns every agent process. Transcripts, keys, and events live in `~/.openmausbot`, not a cloud. -- **Agents with hands.** Each bot can get a real computer — a cloud Linux desktop it drives while you watch - live, or your own Mac — plus 500+ apps through Composio Connect. +- **Agents with hands.** Each bot can use a cloud Linux desktop, an isolated Local VM, or your own computer, + plus 500+ apps through Composio Connect. Host control is available on macOS and as an explicit Ubuntu Xorg beta. ## Features @@ -172,7 +172,7 @@ flowchart LR | API | `server/index.ts` | Bots, turns, approvals, model catalog, computer lifecycle, connectors, config — HTTP + SSE. | | Voice | `server/tts/` | ElevenLabs, bring your own key. Runs on the harness so the key never reaches the UI; markdown is rewritten into something worth hearing before it is spoken. | | App | `src/` | The chat shell. Server-backed store, one reducer, zero client-side transports. | -| Desktop | `electron/` | macOS, Windows, and Ubuntu shells with an embedded harness and explicit platform capabilities; Apple speech, local screen capture, and the current CUA bridge remain macOS-only. | +| Desktop | `electron/` | macOS, Windows, and Ubuntu shells with an embedded harness and platform capabilities; Apple speech stays macOS-only, while user-installed CUA can enable the Ubuntu Xorg local-control beta. | ## Quick start @@ -217,13 +217,15 @@ pnpm package:linux # Ubuntu x64: .deb + AppImage; no Swift required | Packaged app, embedded harness, local agent CLIs | Supported | Beta | Beta | | Composio and Box/cloud computers | Supported | Beta | Beta | | Explicit preview-only local screen capture | Supported | Beta | Beta | -| Bot control of this computer | Supported | Planned | Planned after compositor validation | +| Bot control of this computer | Supported | Beta: opt-in, Cua 0.19.3 | Planned after compositor validation | | Native on-device dictation | Supported | Planned | Planned | -The Linux preview is user-initiated and never enables local bot control or Auto routing. Unavailable native -features fail closed without blocking chat or cloud features. Linux local computer control, Wayland automation, -dictation, and ARM64 are tracked in -[#29](https://github.com/milind-soni/OpenMausBot/issues/29) and are not claimed by the baseline package. +The Linux preview is user-initiated and never enables local bot control or Auto routing. The Xorg control beta +requires a separately installed Cua Driver 0.19.3, explicit app opt-in, and an explicit per-bot **This computer** +selection; every local action asks for approval. Wayland control stays disabled. Unavailable native features fail +closed without blocking chat or cloud features. See the [Ubuntu Desktop guide](docs/linux-desktop.md) and +tracking issues [#29](https://github.com/milind-soni/OpenMausBot/issues/29) and +[#79](https://github.com/milind-soni/OpenMausBot/issues/79). These credentials are optional — local chat works without them. Paste a key once in **App Settings** (gear in the sidebar footer) when you want to enable its integration: diff --git a/docs/computer-use-integration.md b/docs/computer-use-integration.md index 66b03161..98d09765 100644 --- a/docs/computer-use-integration.md +++ b/docs/computer-use-integration.md @@ -38,6 +38,13 @@ No cliclick, no robotjs/nut.js, no Python computer-server, no fallbacks.** Everything that touches the user's screen/mouse/keyboard goes through the bundled `cua-driver` binary. Alternatives evaluated and rejected: +The Ubuntu GNOME/Xorg beta is an intentional staged exception to the +zero-install packaging statement: it uses the same official CUA provider but +requires user-installed Cua Driver 0.19.3 while supply-chain bundling remains +Phase 5 of [#29](https://github.com/milind-soni/OpenMausBot/issues/29). Electron +still owns a private embedded daemon/socket, and the harness only receives the +validated MCP proxy contract. See [#79](https://github.com/milind-soni/OpenMausBot/issues/79). + | Option | Verdict | | --- | --- | | cua `computer-server` (Python/FastAPI) | ✗ 200MB+ frozen Python, second TCC prompt under wrong identity | diff --git a/docs/linux-desktop.md b/docs/linux-desktop.md index 5e53e93f..f2d00166 100644 --- a/docs/linux-desktop.md +++ b/docs/linux-desktop.md @@ -12,11 +12,13 @@ installed builds do not require Node, pnpm, Swift, or a terminal at runtime. - External documentation and OAuth links in the default browser. - An explicit, view-only local screen preview on GNOME Xorg and GNOME Wayland. The Wayland path uses the native portal chooser and keeps the selected PipeWire stream open until the user stops sharing. +- An explicit local-computer control beta on GNOME/Xorg with user-installed Cua Driver 0.19.3 and an + approval-capable Claude or ACP provider. -The local preview does **not** give the bot control of this computer. Linux dictation and local computer -control remain unavailable and fail closed in the Electron, server, and UI layers. Use a Cloud box when a bot -needs a computer it can act on. Xorg computer control, Wayland automation, bundled CUA, dictation, and ARM64 -are follow-ups in [issue #29](https://github.com/milind-soni/OpenMausBot/issues/29). +The local preview does **not** give the bot control of this computer by itself. Local control is a separate, +off-by-default Xorg beta. Wayland control, bundled CUA, Linux dictation, and ARM64 remain unavailable and fail +closed; follow their progress in [issue #29](https://github.com/milind-soni/OpenMausBot/issues/29). The Xorg beta +is tracked in [issue #79](https://github.com/milind-soni/OpenMausBot/issues/79). ## Build packages @@ -123,9 +125,46 @@ Cancelling or ending Wayland sharing returns to a calm **Try again** state and n automatically. OpenMausBot does not capture screen audio, remember the selected monitor after restart, or offer an **Open Settings** action on Linux. -Local computer control remains disabled on both session types in this beta. Future Xorg support will require a -validated `cua-driver`; Wayland support will remain disabled until the exact GNOME/Mutter action surface has -real capture, input, scaling, permission, and lifecycle evidence. +Local computer control is available only on Xorg after the separate opt-in below. Wayland support remains +disabled until the exact GNOME/Mutter action surface has real capture, input, scaling, permission, and lifecycle +evidence. + +## Enable local control on Xorg + +This beta deliberately uses a user-installed driver. OpenMausBot does not bundle, download, update, or stop a +global Cua daemon. The certified contract is **Cua Driver 0.19.3**, manifest schema `1`, on Ubuntu 24.04 x64 +GNOME/Xorg. + +Install Cua Driver from its [official installation guide](https://cua.ai/docs/how-to-guides/driver/install): + +```sh +/bin/bash -c "$(curl -fsSL https://cua.ai/driver/install.sh)" +cua-driver --version +cua-driver manifest --pretty +cua-driver doctor --json +``` + +Confirm the version is `0.19.3` and sign into a GNOME on Xorg session. Then: + +1. Open a bot's **Computer** panel. +2. In **Local control**, choose **Enable local control (Beta)** and review the warning. +3. Wait until the card shows **Ready**, including the verified driver path and version. +4. Select **This computer** for that bot. Enabling the global capability never assigns a bot automatically. + +Linux **Auto** never falls back to the user's desktop. **This computer** is available only when the current +provider advertises an interactive approval channel. Claude `bypassPermissions`, ACP full-auto, Codex's current +app-server adapter, Wayland/headless sessions, missing diagnostics, and stale/crashed runtimes fail closed. + +OpenMausBot starts one private embedded daemon with a private socket for its own app generation. It never touches +Cua's default/global daemon. Disabling local control or quitting stops the owned daemon and active proxies. + +The driver uses Cua's `standard` permission mode. Cua routine actions are promptless at the driver layer, while +OpenMausBot requires its own **Allow** or **Deny** decision before every local action. Bot Auto mode, persistent +**Always allow** grants, and cloud-computer approvals cannot authorize the local desktop in this beta. + +Cua Driver has content-free telemetry and an update check enabled by default. OpenMausBot disables the update +check only for children it starts and does not change the user's persisted telemetry preference. Review or change +that preference with the [official telemetry documentation](https://cua.ai/docs/reference/cua-driver/telemetry). ## Validate a package change @@ -138,10 +177,11 @@ node scripts/verify-linux-package.mjs dbus-run-session -- xvfb-run -a node scripts/smoke-linux-package.mjs ``` -The verifier checks `.deb` metadata, desktop identity, resources, artifact permissions, and the absence of -unsupported native binaries. The smoke test launches the unpacked production app without `--no-sandbox`, -validates the renderer/preload capabilities and embedded health endpoint, then proves clean shutdown. It is not -a substitute for manual testing on a real GNOME Xorg and Wayland desktop. +The verifier checks `.deb` metadata, desktop identity, resources, artifact permissions, and that no Cua executable +was bundled. The smoke test launches the unpacked production app without `--no-sandbox`, validates the +renderer/preload and embedded health endpoint, then uses a fake user-installed driver to prove diagnostics, +private-daemon readiness, crash invalidation, explicit retry, and clean shutdown. It is not a substitute for real +GNOME Xorg action evidence or real GNOME Wayland portal evidence. ## Troubleshooting @@ -153,8 +193,23 @@ considered for automatic discovery. ### A bot needs computer tools -Choose **Cloud box** in the Computer panel and add a Box token in App Settings. **This computer** is disabled on -Linux until local CUA control is implemented and validated. +On Wayland, choose **Cloud box** and add a Box token in App Settings. On Xorg, either use a cloud box or complete +the local-control opt-in above. A missing or unsupported provider keeps **This computer** disabled with an +explanation. + +### Local control is not ready + +Run the certified probes in a terminal launched inside the same GNOME/Xorg session: + +```sh +echo "$XDG_SESSION_TYPE" # must be x11 +cua-driver --version # must be 0.19.3 for this beta +cua-driver doctor --json +``` + +Repair any display, session bus, or AT-SPI diagnostic before choosing **Try again**. If the path shown in the app +is unexpected, close OpenMausBot and launch it with an absolute `CUA_DRIVER_PATH`. An invalid explicit override +fails without silently selecting another executable. ### Screen preview does not start diff --git a/electron/main.mjs b/electron/main.mjs index 7324ff68..6e196ce5 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -187,7 +187,22 @@ function createWindow() { const result = await win.webContents.executeJavaScript(` (async () => { if (!window.ogb?.getCapabilities) throw new Error("desktop preload bridge is unavailable"); - const [capabilities, healthResponse] = await Promise.all([ + let crashPromise = null; + if (${JSON.stringify(process.env.OMB_SMOKE_CUA === "1")}) { + crashPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe?.(); + reject(new Error("timed out waiting for CUA crash invalidation")); + }, 10000); + const unsubscribe = window.ogb.onCapabilitiesChanged((next) => { + if (next.localComputer.reasonCode !== "daemon-exited") return; + clearTimeout(timeout); + unsubscribe(); + resolve(next.localComputer.reasonCode); + }); + }); + } + const [initialCapabilities, healthResponse] = await Promise.all([ window.ogb.getCapabilities(), fetch("/api/health"), ]); @@ -195,7 +210,26 @@ function createWindow() { throw new Error(\`health request failed: \${healthResponse.status} \${healthResponse.statusText}\`); } const health = await healthResponse.json(); - return { capabilities, health, location: window.location.href, title: document.title }; + let capabilities = initialCapabilities; + let cuaCrashReason = null; + let cuaRetryStatus = null; + if (crashPromise) { + if (!initialCapabilities.localComputer.available) { + throw new Error("CUA was not ready before the simulated crash"); + } + cuaCrashReason = await crashPromise; + cuaRetryStatus = await window.ogb.localControl.retry(); + capabilities = await window.ogb.getCapabilities(); + } + return { + initialCapabilities, + capabilities, + cuaCrashReason, + cuaRetryStatus, + health, + location: window.location.href, + title: document.title, + }; })() `); const expectedLocation = `http://127.0.0.1:${SERVER_PORT}/`; diff --git a/scripts/smoke-linux-package.mjs b/scripts/smoke-linux-package.mjs index 6382f54e..69968890 100644 --- a/scripts/smoke-linux-package.mjs +++ b/scripts/smoke-linux-package.mjs @@ -1,5 +1,13 @@ import { spawn } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -13,15 +21,102 @@ if (!existsSync(executable)) throw new Error(`[smoke-linux-package] missing exec const sandbox = mkdtempSync(path.join(tmpdir(), "omb-linux-smoke-")); const home = path.join(sandbox, "home"); const xdgConfig = path.join(sandbox, "config"); -const marker = path.join(sandbox, "cua-was-executed"); +const xdgRuntime = path.join(sandbox, "runtime"); +const marker = path.join(sandbox, "cua-invocations.ndjson"); +const fakeState = path.join(sandbox, "cua-serve-count"); const sentinel = path.join(sandbox, "cua-driver"); mkdirSync(path.join(home, ".openmausbot"), { recursive: true }); mkdirSync(xdgConfig, { recursive: true }); +mkdirSync(xdgRuntime, { recursive: true, mode: 0o700 }); +chmodSync(xdgRuntime, 0o700); writeFileSync( path.join(home, ".openmausbot", "config.json"), JSON.stringify({ instances: { ghost: { driver: "not-a-real-driver", displayName: "Ghost" } } }), ); -writeFileSync(sentinel, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nexit 99\n`); +for (const appName of ["openmausbot", "OpenMausBot"]) { + const userData = path.join(xdgConfig, appName); + mkdirSync(userData, { recursive: true, mode: 0o700 }); + chmodSync(userData, 0o700); + writeFileSync( + path.join(userData, "cua-local-control.json"), + JSON.stringify({ schemaVersion: 1, linuxLocalControlEnabled: true }), + { mode: 0o600 }, + ); +} +writeFileSync( + sentinel, + `#!${process.execPath} +const { appendFileSync, chmodSync, existsSync, readFileSync, realpathSync, unlinkSync, writeFileSync } = require("node:fs"); +const net = require("node:net"); +const marker = ${JSON.stringify(marker)}; +const state = ${JSON.stringify(fakeState)}; +const args = process.argv.slice(2); +appendFileSync(marker, JSON.stringify({ pid: process.pid, args }) + "\\n"); +const after = (flag) => { const index = args.indexOf(flag); return index === -1 ? null : args[index + 1]; }; +if (args.includes("--version")) { + process.stdout.write("cua-driver 0.19.3\\n"); + process.exit(0); +} +if (args[0] === "manifest") { + const binary = realpathSync(process.argv[1]); + process.stdout.write(JSON.stringify({ + schema_version: "1", + binary_version: "0.19.3", + binary_path: binary, + mcp_invocation: { command: binary, args: ["mcp"] }, + }) + "\\n"); + process.exit(0); +} +if (args[0] === "doctor" && args.includes("--json")) { + process.stdout.write(JSON.stringify({ ok: true, probes: [ + { label: "binary", status: "ok", message: "cua-driver 0.19.3" }, + { label: "display server", status: "ok", message: "X11 (DISPLAY=:99)" }, + { label: "X11 connection", status: "warn", message: "no top-level windows in Xvfb" }, + { label: "AT-SPI", status: "ok", message: "fixture bus available" }, + ] }) + "\\n"); + process.exit(0); +} +if (args[0] !== "serve") process.exit(64); +const socketPath = after("--socket"); +const pidFile = after("--pid-file"); +if (!socketPath || !pidFile || !args.includes("--embedded") || after("--permission-mode") !== "standard") { + process.exit(64); +} +const count = existsSync(state) ? Number(readFileSync(state, "utf8")) + 1 : 1; +writeFileSync(state, String(count)); +writeFileSync(pidFile, String(process.pid), { mode: 0o600 }); +const metadata = { + driver_version: "0.19.3", + contract_version: "0.6.0", + tools_list_schema_version: "1", + capability_version: "1", + mcp_protocol_version: "2025-06-18", + pid: process.pid, + embedded: true, + host_bundle_id: "com.openmausbot.app", +}; +const tools = ["click", "get_window_state", "list_apps", "type_text"].map((name) => ({ name })); +const server = net.createServer((socket) => { + let input = ""; + socket.on("data", (chunk) => { + input += chunk; + const newline = input.indexOf("\\n"); + if (newline === -1) return; + const request = JSON.parse(input.slice(0, newline)); + const result = request.method === "metadata" ? metadata : request.method === "list" ? tools : null; + socket.end(JSON.stringify(result ? { ok: true, result } : { ok: false, error: "unknown" }) + "\\n"); + if (count === 1 && request.method === "list") setTimeout(() => server.close(() => process.exit(17)), 5000); + }); +}); +server.listen(socketPath, () => chmodSync(socketPath, 0o600)); +const shutdown = () => server.close(() => { + for (const file of [socketPath, pidFile]) { try { unlinkSync(file); } catch {} } + process.exit(0); +}); +process.stdin.on("end", shutdown); +process.on("SIGTERM", shutdown); +`, +); chmodSync(sentinel, 0o755); let output = ""; @@ -33,8 +128,10 @@ const child = spawn(executable, [], { ...process.env, HOME: home, XDG_CONFIG_HOME: xdgConfig, + XDG_RUNTIME_DIR: xdgRuntime, CUA_DRIVER_PATH: sentinel, OMB_SMOKE_TEST: "1", + OMB_SMOKE_CUA: "1", }, stdio: ["ignore", "pipe", "pipe"], }); @@ -84,7 +181,16 @@ async function stopProcess() { try { const result = await until(async () => smokeResult, "the packaged renderer smoke result"); - const { capabilities, displayMediaRequests, health, location, title } = result; + const { + capabilities, + cuaCrashReason, + cuaRetryStatus, + displayMediaRequests, + health, + initialCapabilities, + location, + title, + } = result; if (health?.app !== "openmausbot" || health.static !== true) { throw new Error(`unexpected embedded health response: ${JSON.stringify(health)}`); } @@ -95,16 +201,37 @@ try { throw new Error("X11 screen preview capability was not available"); } if (capabilities.dictation.available) throw new Error("dictation must be unavailable on Linux"); - if (capabilities.localComputer.available) throw new Error("local control must be unavailable on Linux"); + if (!initialCapabilities.localComputer.available) throw new Error("initial Linux CUA runtime was not ready"); + if (initialCapabilities.localComputer.support !== "limited") throw new Error("Linux CUA was not marked beta/limited"); + if (cuaCrashReason !== "daemon-exited") throw new Error("daemon crash did not invalidate local control"); + if (cuaRetryStatus?.status !== "ready" || !capabilities.localComputer.available) { + throw new Error("explicit CUA retry did not create a ready generation"); + } if (displayMediaRequests !== 0) throw new Error("launch triggered display capture without user intent"); - if (existsSync(marker)) throw new Error("Linux executed the CUA sentinel"); await waitForExit(); const staleHealth = await fetch(new URL("/api/health", location)).catch(() => null); if (staleHealth?.ok) throw new Error("embedded harness remained reachable after Electron quit"); - if (existsSync(marker)) throw new Error("Linux executed the CUA sentinel during shutdown"); + const invocations = readFileSync(marker, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const commands = invocations.map((entry) => entry.args.join(" ")); + for (const expected of ["--version", "manifest", "doctor --json"]) { + if (!commands.some((command) => command === expected)) throw new Error(`missing CUA probe: ${expected}`); + } + const daemons = invocations.filter((entry) => entry.args[0] === "serve"); + if (daemons.length !== 2) throw new Error(`expected crash + retry daemon generations, found ${daemons.length}`); + for (const daemon of daemons) { + try { + process.kill(daemon.pid, 0); + throw new Error(`owned CUA daemon remained alive after quit: ${daemon.pid}`); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + } - console.log("[smoke-linux-package] OK: renderer, capabilities, embedded harness, and shutdown"); + console.log("[smoke-linux-package] OK: renderer, private CUA crash/retry, harness, and shutdown"); } finally { await stopProcess(); if (process.env.OMB_KEEP_SMOKE_DIR !== "1") rmSync(sandbox, { recursive: true, force: true }); From fc1b433b2872576a57fc1371a913df94c9241e69 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Thu, 13 Aug 2026 20:15:27 -0300 Subject: [PATCH 11/32] harden certified Linux CUA handoff --- electron/cua-linux-runtime.cjs | 41 +++- electron/cua-linux-runtime.test.mjs | 71 +++++-- electron/cua-linux.cjs | 298 +++++++++++++++++++++++++--- electron/cua-linux.test.mjs | 199 ++++++++++++++++++- scripts/smoke-linux-package.mjs | 3 +- server/local-computer.test.ts | 27 ++- server/local-computer.ts | 48 ++++- 7 files changed, 625 insertions(+), 62 deletions(-) diff --git a/electron/cua-linux-runtime.cjs b/electron/cua-linux-runtime.cjs index 30bc0178..966a4ddb 100644 --- a/electron/cua-linux-runtime.cjs +++ b/electron/cua-linux-runtime.cjs @@ -9,6 +9,7 @@ const { CERTIFIED_MANIFEST_SCHEMA, desktopCommandEnvironment, inspectLinuxCuaDriver, + sameDriverFileIdentity, validateDriverCandidate, } = require("./cua-linux.cjs"); @@ -170,12 +171,20 @@ function validateDaemonMetadata(response, { childPid } = {}) { } function validateToolSurface(response) { - const tools = response?.ok === true && Array.isArray(response.result) ? response.result : null; - if (!tools) { + const manifest = response?.ok === true ? response.result : null; + if ( + !manifest || + typeof manifest !== "object" || + Array.isArray(manifest) || + manifest.schema_version !== CERTIFIED_TOOLS_LIST_SCHEMA_VERSION || + manifest.capability_version !== CERTIFIED_CAPABILITY_VERSION || + !Array.isArray(manifest.tools) + ) { throw Object.assign(new Error("Cua Driver tool surface could not be verified."), { code: "invalid-tool-surface", }); } + const tools = manifest.tools; const names = new Set(tools.map((tool) => tool?.name).filter((name) => typeof name === "string")); const missing = REQUIRED_TOOLS.filter((name) => !names.has(name)); if (missing.length) { @@ -376,15 +385,6 @@ function createLinuxCuaRuntime({ } if (!enabled || quitting) return connection; - const revalidated = validateDriverCandidate(inspected.path); - if (revalidated.status !== "found" || revalidated.path !== inspected.path) { - return unavailable( - "error", - "driver-changed", - "Cua Driver changed after validation. Check the installation and try again.", - ); - } - const generation = identifier(); const root = runtimeRoot(); const runtimeDirectory = path.join(root, `${processId}-${generation.slice(0, 12)}`); @@ -414,6 +414,22 @@ function createLinuxCuaRuntime({ "--permission-mode", "standard", ]; + // Pin the exact file inspected above, not merely its path. Keep this + // directly adjacent to spawn so an update/replacement during doctor or + // runtime preparation fails closed instead of launching uninspected code. + const revalidated = validateDriverCandidate(inspected.path); + if ( + revalidated.status !== "found" || + revalidated.path !== inspected.path || + !sameDriverFileIdentity(inspected.fileIdentity, revalidated.fileIdentity) + ) { + cleanupRuntimeFiles({ runtimeDirectory, socketPath, pidFile }); + return unavailable( + "error", + "driver-changed", + "Cua Driver changed after validation. Check the installation and try again.", + ); + } const child = spawnProcess(inspected.path, args, { env: childEnv, shell: false, @@ -459,6 +475,9 @@ function createLinuxCuaRuntime({ path: inspected.path, version: inspected.driverVersion, manifestSchema: inspected.manifestSchema, + // Private descriptor-only pin. The renderer consumes getStatus(), + // which deliberately omits this internal file identity. + fileIdentity: inspected.fileIdentity, }, daemon: { socketPath, diff --git a/electron/cua-linux-runtime.test.mjs b/electron/cua-linux-runtime.test.mjs index bff0ae70..9fffee66 100644 --- a/electron/cua-linux-runtime.test.mjs +++ b/electron/cua-linux-runtime.test.mjs @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const require = createRequire(import.meta.url); const { createCuaConnectionStore } = require("./cua-connection.cjs"); +const { validateDriverCandidate } = require("./cua-linux.cjs"); const { createLinuxCuaPreferenceStore, createLinuxCuaRuntime, @@ -71,7 +72,7 @@ function handshake(pid = 4321) { }; } -function harness({ preferenceEnabled = false } = {}) { +function harness({ preferenceEnabled = false, afterIdentityCaptured } = {}) { const userData = temporaryDirectory(); const runtimeRoot = path.join(userData, "session"); fs.mkdirSync(runtimeRoot, { mode: 0o700 }); @@ -80,15 +81,20 @@ function harness({ preferenceEnabled = false } = {}) { const preferenceStore = createLinuxCuaPreferenceStore({ getUserData: () => userData }); if (preferenceEnabled) preferenceStore.write(true); const child = fakeChild(); - const inspect = vi.fn(async () => ({ - status: "ready", - path: binary, - source: "environment", - driverVersion: "0.19.3", - manifestSchema: "1", - mcp: { command: binary, args: ["mcp"] }, - doctor: { ok: true, probes: [], warnings: [] }, - })); + const fileIdentity = validateDriverCandidate(binary).fileIdentity; + const inspect = vi.fn(async () => { + afterIdentityCaptured?.(binary); + return { + status: "ready", + path: binary, + fileIdentity, + source: "environment", + driverVersion: "0.19.3", + manifestSchema: "1", + mcp: { command: binary, args: ["mcp"] }, + doctor: { ok: true, probes: [], warnings: [] }, + }; + }); const spawnProcess = vi.fn(() => child); const probe = vi.fn(async () => handshake(child.pid)); const changes = []; @@ -175,7 +181,12 @@ describe("Linux CUA opt-in and lifecycle", () => { status: "ready", ownerPid: 1234, generation: "01234567-89ab-cdef-0123-456789abcdef", - driver: { path: context.binary, version: "0.19.3", manifestSchema: "1" }, + driver: { + path: context.binary, + version: "0.19.3", + manifestSchema: "1", + fileIdentity: validateDriverCandidate(context.binary).fileIdentity, + }, daemon: { pid: 4321, contractVersion: "0.6.0" }, mcp: { command: context.binary, @@ -185,6 +196,30 @@ describe("Linux CUA opt-in and lifecycle", () => { const descriptor = path.join(context.userData, "cua-connection.json"); expect(fs.statSync(descriptor).mode & 0o777).toBe(0o600); expect(fs.statSync(context.userData).mode & 0o777).toBe(0o700); + expect(JSON.parse(fs.readFileSync(descriptor, "utf8"))).toMatchObject({ + driver: { fileIdentity: validateDriverCandidate(context.binary).fileIdentity }, + }); + expect(context.runtime.getStatus()).not.toHaveProperty("fileIdentity"); + expect(context.runtime.getStatus()).not.toHaveProperty("driver.fileIdentity"); + }); + + it("refuses to spawn when the inspected executable identity changes", async () => { + const context = harness({ + afterIdentityCaptured(binary) { + fs.appendFileSync(binary, "# changed after inspection\n"); + }, + }); + await context.runtime.enable(); + expect(context.spawnProcess).not.toHaveBeenCalled(); + expect(context.probe).not.toHaveBeenCalled(); + expect(context.runtime.getConnection()).toMatchObject({ + mode: "unavailable", + status: "error", + reasonCode: "driver-changed", + }); + expect(fs.readFileSync(path.join(context.userData, "cua-connection.json"), "utf8")).not.toContain( + "fileIdentity", + ); }); it("invalidates readiness immediately when the owned daemon exits", async () => { @@ -264,9 +299,19 @@ describe("Linux CUA handshake validation", () => { it("requires the inspect and mutation tool surface", () => { const tools = handshake().tools.map((name) => ({ name })); - expect(validateToolSurface({ ok: true, result: tools })).toEqual([...handshake().tools].sort()); + const manifest = { schema_version: "1", capability_version: "1", tools }; + expect(validateToolSurface({ ok: true, result: manifest })).toEqual([...handshake().tools].sort()); expect(() => - validateToolSurface({ ok: true, result: tools.filter((tool) => tool.name !== "type_text") }), + validateToolSurface({ + ok: true, + result: { + ...manifest, + tools: tools.filter((tool) => tool.name !== "type_text"), + }, + }), ).toThrow(/type_text/); + expect(() => + validateToolSurface({ ok: true, result: { ...manifest, capability_version: "2" } }), + ).toThrow(/could not be verified/); }); }); diff --git a/electron/cua-linux.cjs b/electron/cua-linux.cjs index ccc9f771..0bc31473 100644 --- a/electron/cua-linux.cjs +++ b/electron/cua-linux.cjs @@ -1,4 +1,4 @@ -const { spawn } = require("node:child_process"); +const { spawn, spawnSync } = require("node:child_process"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); @@ -7,6 +7,19 @@ const CERTIFIED_DRIVER_VERSION = "0.19.3"; const CERTIFIED_MANIFEST_SCHEMA = "1"; const DEFAULT_TIMEOUT_MS = 8_000; const DEFAULT_MAX_OUTPUT_BYTES = 512 * 1024; +const GETENT_BINARY = "/usr/bin/getent"; +const GETENT_TIMEOUT_MS = 1_500; +const GETENT_MAX_OUTPUT_BYTES = 256 * 1024; +const DRIVER_FILE_IDENTITY_KEYS = Object.freeze([ + "dev", + "ino", + "uid", + "gid", + "mode", + "size", + "mtimeNs", + "ctimeNs", +]); const SESSION_ENV_KEYS = new Set([ "AT_SPI_BUS", @@ -161,30 +174,216 @@ function pathComponents(target) { } function safeOwner(stat, currentUid) { - return stat.uid === currentUid || stat.uid === 0; + const uid = Number(stat.uid); + return uid === currentUid || uid === 0; +} + +function driverFileIdentityFromStat(stat) { + return Object.freeze({ + dev: String(stat.dev), + ino: String(stat.ino), + uid: String(stat.uid), + gid: String(stat.gid), + mode: String(stat.mode), + size: String(stat.size), + mtimeNs: String(stat.mtimeNs), + ctimeNs: String(stat.ctimeNs), + }); +} + +function captureDriverFileIdentity(target, fileSystem = fs) { + return driverFileIdentityFromStat(fileSystem.statSync(target, { bigint: true })); +} + +function sameDriverFileIdentity(expected, actual) { + if (!expected || !actual || typeof expected !== "object" || typeof actual !== "object") { + return false; + } + const expectedKeys = Object.keys(expected).sort(); + const actualKeys = Object.keys(actual).sort(); + const requiredKeys = [...DRIVER_FILE_IDENTITY_KEYS].sort(); + if ( + expectedKeys.length !== requiredKeys.length || + actualKeys.length !== requiredKeys.length || + !requiredKeys.every((key, index) => expectedKeys[index] === key && actualKeys[index] === key) + ) { + return false; + } + return DRIVER_FILE_IDENTITY_KEYS.every( + (key) => typeof expected[key] === "string" && expected[key] === actual[key], + ); } -function validatePathComponents(target, currentUid) { +function runGetent(args, { + spawnCommand = spawnSync, + timeoutMs = GETENT_TIMEOUT_MS, + maxOutputBytes = GETENT_MAX_OUTPUT_BYTES, +} = {}) { + const result = spawnCommand(GETENT_BINARY, args, { + encoding: "utf8", + env: { LANG: "C", LC_ALL: "C" }, + maxBuffer: maxOutputBytes, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + timeout: timeoutMs, + windowsHide: true, + }); + if (result?.error || result?.status !== 0 || typeof result?.stdout !== "string") { + return { + ok: false, + reason: result?.error?.code === "ETIMEDOUT" ? "lookup-timeout" : "lookup-failed", + }; + } + if (Buffer.byteLength(result.stdout) > maxOutputBytes) { + return { ok: false, reason: "lookup-output-too-large" }; + } + return { ok: true, stdout: result.stdout }; +} + +function privatePrimaryGroup(identity, { getent = runGetent } = {}) { + if ( + !Number.isSafeInteger(identity?.uid) || + !Number.isSafeInteger(identity?.gid) || + typeof identity?.username !== "string" || + !identity.username + ) { + return { exclusive: false, reason: "identity-unavailable" }; + } + + const groupResult = getent(["group", String(identity.gid)]); + if (!groupResult?.ok) { + return { exclusive: false, reason: groupResult?.reason ?? "lookup-failed" }; + } + + const groupLines = groupResult.stdout.split(/\r?\n/).filter(Boolean); + const groupRecords = groupLines.map((line) => line.split(":")); + if ( + groupRecords.length !== 1 || + groupRecords[0].length !== 4 || + !/^\d+$/.test(groupRecords[0][2]) || + Number(groupRecords[0][2]) !== identity.gid || + groupRecords[0][0] !== identity.username + ) { + return { exclusive: false, reason: "primary-group-not-private" }; + } + const explicitMembers = groupRecords[0][3].split(",").filter(Boolean); + if (explicitMembers.some((member) => member !== identity.username)) { + return { exclusive: false, reason: "primary-group-shared" }; + } + + const passwdResult = getent(["passwd"]); + if (!passwdResult?.ok) { + return { exclusive: false, reason: passwdResult?.reason ?? "lookup-failed" }; + } + const passwdLines = passwdResult.stdout.split(/\r?\n/).filter(Boolean); + const passwdRecords = passwdLines.map((line) => line.split(":")); + if ( + passwdRecords.length === 0 || + passwdRecords.some( + (fields) => + fields.length !== 7 || !/^\d+$/.test(fields[2]) || !/^\d+$/.test(fields[3]), + ) + ) { + return { exclusive: false, reason: "lookup-malformed" }; + } + const primaryMembers = passwdRecords + .filter((fields) => Number(fields[3]) === identity.gid) + .map((fields) => ({ username: fields[0], uid: Number(fields[2]) })); + if ( + primaryMembers.length !== 1 || + primaryMembers[0].username !== identity.username || + primaryMembers[0].uid !== identity.uid + ) { + return { exclusive: false, reason: "primary-group-shared" }; + } + return { exclusive: true, gid: identity.gid, name: identity.username }; +} + +function driverIdentity({ currentUid, currentGid, currentUsername } = {}) { + let info = {}; + try { + info = os.userInfo(); + } catch { + // Group-writable paths will fail closed when identity cannot be proven. + } + return { + uid: currentUid ?? process.getuid?.() ?? info.uid, + gid: currentGid ?? process.getgid?.() ?? info.gid, + username: currentUsername ?? info.username, + }; +} + +function createPermissionCheck(identity, lookupPrivateGroup) { + let groupProof; + const groupWriteAllowed = (stat) => { + if (Number(stat.uid) !== identity.uid || Number(stat.gid) !== identity.gid) return false; + if (groupProof === undefined) { + try { + groupProof = lookupPrivateGroup(identity); + } catch { + groupProof = { exclusive: false, reason: "lookup-failed" }; + } + } + return ( + groupProof?.exclusive === true && + groupProof.gid === identity.gid && + groupProof.name === identity.username + ); + }; + const failure = (component, stat) => { + const worldWritable = (Number(stat.mode) & 0o002) !== 0; + return unavailable( + "unsafe-driver-permissions", + worldWritable + ? `Cua Driver path is world-writable: ${component}` + : `Cua Driver path is group-writable and its group could not be proven private: ${component}`, + { + affectedPaths: [component], + ...(groupProof?.reason ? { permissionReason: groupProof.reason } : {}), + }, + ); + }; + return { failure, groupWriteAllowed }; +} + +function writablePermissionError(component, stat, permissionCheck, { allowRootSticky = false } = {}) { + const mode = Number(stat.mode); + const rootOwnedStickyDirectory = + allowRootSticky && stat.isDirectory() && Number(stat.uid) === 0 && (mode & 0o1000) !== 0; + if (rootOwnedStickyDirectory) return null; + if ((mode & 0o002) !== 0) return permissionCheck.failure(component, stat); + if ((mode & 0o020) !== 0 && !permissionCheck.groupWriteAllowed(stat)) { + return permissionCheck.failure(component, stat); + } + return null; +} + +function validatePathComponents(target, currentUid, permissionCheck) { for (const component of pathComponents(path.dirname(target))) { const stat = fs.lstatSync(component); if (!safeOwner(stat, currentUid)) { return unavailable( "unsafe-driver-owner", `Cua Driver path component is owned by an unexpected user: ${component}`, + { affectedPaths: [component] }, ); } - const rootOwnedStickyDirectory = stat.isDirectory() && stat.uid === 0 && (stat.mode & 0o1000) !== 0; - if ((stat.mode & 0o022) !== 0 && !rootOwnedStickyDirectory) { - return unavailable( - "unsafe-driver-permissions", - `Cua Driver path component is group- or world-writable: ${component}`, - ); - } + const permissionError = writablePermissionError(component, stat, permissionCheck, { + allowRootSticky: true, + }); + if (permissionError) return permissionError; } return null; } -function validateDriverCandidate(candidate, { currentUid = process.getuid?.() ?? os.userInfo().uid } = {}) { +function validateDriverCandidate(candidate, { + currentUid, + currentGid, + currentUsername, + lookupPrivateGroup = privatePrimaryGroup, +} = {}) { + const identity = driverIdentity({ currentUid, currentGid, currentUsername }); + const permissionCheck = createPermissionCheck(identity, lookupPrivateGroup); if (!path.isAbsolute(candidate)) { return unavailable("driver-path-not-absolute", "Cua Driver path must be absolute.", { candidate, @@ -194,10 +393,15 @@ function validateDriverCandidate(candidate, { currentUid = process.getuid?.() ?? let linkStat; let canonicalPath; let targetStat; + let fileIdentity; try { linkStat = fs.lstatSync(candidate); canonicalPath = fs.realpathSync(candidate); - targetStat = fs.statSync(canonicalPath); + // This single stat is the authority for type, owner, mode, and the + // identity later pinned by the runtime. A second stat at the end proves + // the file did not change while parent permissions were being checked. + targetStat = fs.statSync(canonicalPath, { bigint: true }); + fileIdentity = driverFileIdentityFromStat(targetStat); } catch (error) { return unavailable("driver-not-found", `Cua Driver was not found at ${candidate}.`, { candidate, @@ -205,11 +409,11 @@ function validateDriverCandidate(candidate, { currentUid = process.getuid?.() ?? }); } - if (!safeOwner(linkStat, currentUid) || !safeOwner(targetStat, currentUid)) { + if (!safeOwner(linkStat, identity.uid) || !safeOwner(targetStat, identity.uid)) { return unavailable( "unsafe-driver-owner", "Cua Driver must be owned by the current user or root.", - { candidate, canonicalPath }, + { candidate, canonicalPath, affectedPaths: [canonicalPath] }, ); } if (!targetStat.isFile()) { @@ -218,24 +422,19 @@ function validateDriverCandidate(candidate, { currentUid = process.getuid?.() ?? canonicalPath, }); } - if ((targetStat.mode & 0o111) === 0) { + if ((Number(targetStat.mode) & 0o111) === 0) { return unavailable("driver-not-executable", "Cua Driver is not executable.", { candidate, canonicalPath, }); } - if ((targetStat.mode & 0o022) !== 0) { - return unavailable( - "unsafe-driver-permissions", - "Cua Driver must not be group- or world-writable.", - { candidate, canonicalPath }, - ); - } + const targetPermissionError = writablePermissionError(canonicalPath, targetStat, permissionCheck); + if (targetPermissionError) return { ...targetPermissionError, candidate, canonicalPath }; try { - const lexicalError = validatePathComponents(candidate, currentUid); + const lexicalError = validatePathComponents(candidate, identity.uid, permissionCheck); if (lexicalError) return { ...lexicalError, candidate, canonicalPath }; - const canonicalError = validatePathComponents(canonicalPath, currentUid); + const canonicalError = validatePathComponents(canonicalPath, identity.uid, permissionCheck); if (canonicalError) return { ...canonicalError, candidate, canonicalPath }; fs.accessSync(canonicalPath, fs.constants.X_OK); } catch (error) { @@ -246,19 +445,43 @@ function validateDriverCandidate(candidate, { currentUid = process.getuid?.() ?? }); } - return { status: "found", path: canonicalPath }; + try { + const finalIdentity = captureDriverFileIdentity(canonicalPath); + if (!sameDriverFileIdentity(fileIdentity, finalIdentity)) { + return unavailable("driver-changed", "Cua Driver changed while it was being validated.", { + candidate, + canonicalPath, + }); + } + } catch (error) { + return unavailable("driver-not-found", "Cua Driver changed while it was being validated.", { + candidate, + canonicalPath, + cause: error?.code, + }); + } + + return { status: "found", path: canonicalPath, fileIdentity }; } -function discoverLinuxCuaDriver({ env = process.env, homeDir = os.homedir(), currentUid } = {}) { +function discoverLinuxCuaDriver({ + env = process.env, + homeDir = os.homedir(), + currentUid, + currentGid, + currentUsername, + lookupPrivateGroup, +} = {}) { + const validationOptions = { currentUid, currentGid, currentUsername, lookupPrivateGroup }; const explicit = env.CUA_DRIVER_PATH; if (explicit) { - const result = validateDriverCandidate(explicit, { currentUid }); + const result = validateDriverCandidate(explicit, validationOptions); return result.status === "found" ? { ...result, source: "environment" } : result; } const localCandidate = path.join(homeDir, ".local", "bin", "cua-driver"); if (fs.existsSync(localCandidate)) { - const result = validateDriverCandidate(localCandidate, { currentUid }); + const result = validateDriverCandidate(localCandidate, validationOptions); if (result.status === "found") return { ...result, source: "user-local" }; return result; } @@ -267,7 +490,7 @@ function discoverLinuxCuaDriver({ env = process.env, homeDir = os.homedir(), cur for (const directory of sanitizePath(env.PATH).split(path.delimiter).filter(Boolean)) { const candidate = path.join(directory, "cua-driver"); if (!fs.existsSync(candidate)) continue; - const result = validateDriverCandidate(candidate, { currentUid }); + const result = validateDriverCandidate(candidate, validationOptions); if (result.status === "found") return { ...result, source: "path" }; firstUnsafe ??= result; } @@ -379,6 +602,9 @@ async function inspectLinuxCuaDriver({ env = process.env, homeDir = os.homedir(), currentUid, + currentGid, + currentUsername, + lookupPrivateGroup, run = runCuaCommand, } = {}) { const session = String(env.XDG_SESSION_TYPE ?? "").toLowerCase(); @@ -397,7 +623,14 @@ async function inspectLinuxCuaDriver({ return unavailable("display-unavailable", "Local control requires an active Xorg display."); } - const discovered = discoverLinuxCuaDriver({ env, homeDir, currentUid }); + const discovered = discoverLinuxCuaDriver({ + env, + homeDir, + currentUid, + currentGid, + currentUsername, + lookupPrivateGroup, + }); if (discovered.status !== "found") return discovered; const commandEnv = desktopCommandEnvironment(env); @@ -436,6 +669,7 @@ async function inspectLinuxCuaDriver({ status: "ready", path: discovered.path, source: discovered.source, + fileIdentity: discovered.fileIdentity, driverVersion, manifestSchema: manifest.schema_version, mcp, @@ -454,11 +688,15 @@ async function inspectLinuxCuaDriver({ module.exports = { CERTIFIED_DRIVER_VERSION, CERTIFIED_MANIFEST_SCHEMA, + captureDriverFileIdentity, desktopCommandEnvironment, discoverLinuxCuaDriver, inspectLinuxCuaDriver, parseVersion, + privatePrimaryGroup, + runGetent, runCuaCommand, + sameDriverFileIdentity, sanitizePath, validateDoctor, validateDriverCandidate, diff --git a/electron/cua-linux.test.mjs b/electron/cua-linux.test.mjs index 706c2147..eca0cbe9 100644 --- a/electron/cua-linux.test.mjs +++ b/electron/cua-linux.test.mjs @@ -9,7 +9,10 @@ const { desktopCommandEnvironment, discoverLinuxCuaDriver, inspectLinuxCuaDriver, + privatePrimaryGroup, runCuaCommand, + runGetent, + sameDriverFileIdentity, sanitizePath, validateDriverCandidate, } = require("./cua-linux.cjs"); @@ -87,10 +90,16 @@ describe("Linux CUA discovery", () => { fs.mkdirSync(localBin, { recursive: true, mode: 0o700 }); fs.symlinkSync(release, path.join(localBin, "cua-driver")); - expect(discoverLinuxCuaDriver({ env: { PATH: "" }, homeDir: root })).toEqual({ + expect(discoverLinuxCuaDriver({ env: { PATH: "" }, homeDir: root })).toMatchObject({ status: "found", path: release, source: "user-local", + fileIdentity: expect.objectContaining({ + dev: expect.any(String), + ino: expect.any(String), + mtimeNs: expect.any(String), + ctimeNs: expect.any(String), + }), }); }); @@ -100,22 +109,203 @@ describe("Linux CUA discovery", () => { const binary = executable(safeDirectory); const value = ["", ".", "relative/bin", safeDirectory, safeDirectory].join(path.delimiter); expect(sanitizePath(value)).toBe(safeDirectory); - expect(discoverLinuxCuaDriver({ env: { PATH: value }, homeDir: path.join(root, "home") })).toEqual({ + expect(discoverLinuxCuaDriver({ env: { PATH: value }, homeDir: path.join(root, "home") })).toMatchObject({ status: "found", path: binary, source: "path", }); }); - it("rejects a group-writable executable", () => { + it("accepts the official 0775 layout only for a proven user-private primary group", () => { + const root = temporaryDirectory(); + const releaseDirectory = path.join(root, ".cua-driver", "packages", "releases", "0.19.3"); + const release = executable(releaseDirectory); + const localBin = path.join(root, ".local", "bin"); + fs.mkdirSync(localBin, { recursive: true, mode: 0o775 }); + fs.symlinkSync(release, path.join(localBin, "cua-driver")); + for (const component of [ + path.join(root, ".local"), + localBin, + path.join(root, ".cua-driver"), + path.join(root, ".cua-driver", "packages"), + path.join(root, ".cua-driver", "packages", "releases"), + releaseDirectory, + release, + ]) { + fs.chmodSync(component, 0o775); + } + const identity = os.userInfo(); + const lookupPrivateGroup = vi.fn(() => ({ + exclusive: true, + gid: identity.gid, + name: identity.username, + })); + + expect( + discoverLinuxCuaDriver({ + env: { PATH: "" }, + homeDir: root, + currentUid: identity.uid, + currentGid: identity.gid, + currentUsername: identity.username, + lookupPrivateGroup, + }), + ).toMatchObject({ status: "found", path: release, source: "user-local" }); + expect(lookupPrivateGroup).toHaveBeenCalledTimes(1); + }); + + it("rejects a group-writable executable when the group is shared or unverifiable", () => { const root = temporaryDirectory(); const binary = executable(path.join(root, "bin")); fs.chmodSync(binary, 0o720); - expect(validateDriverCandidate(binary)).toMatchObject({ + expect( + validateDriverCandidate(binary, { + lookupPrivateGroup: () => ({ exclusive: false, reason: "primary-group-shared" }), + }), + ).toMatchObject({ status: "unavailable", reasonCode: "unsafe-driver-permissions", + affectedPaths: [binary], + permissionReason: "primary-group-shared", }); }); + + it("contains a failed group lookup and keeps the exact affected path", () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + fs.chmodSync(binary, 0o720); + expect( + validateDriverCandidate(binary, { + lookupPrivateGroup: () => { + throw new Error("NSS unavailable"); + }, + }), + ).toMatchObject({ + status: "unavailable", + reasonCode: "unsafe-driver-permissions", + affectedPaths: [binary], + permissionReason: "lookup-failed", + }); + }); + + it("always rejects world-writable paths and reports the exact component", () => { + const root = temporaryDirectory(); + const directory = path.join(root, "world-writable"); + const binary = executable(directory); + fs.chmodSync(directory, 0o707); + expect( + validateDriverCandidate(binary, { + lookupPrivateGroup: () => { + throw new Error("world-write must not query group membership"); + }, + }), + ).toMatchObject({ + status: "unavailable", + reasonCode: "unsafe-driver-permissions", + affectedPaths: [directory], + }); + }); + + it("does not need group lookup for ordinary 0755 paths", () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + fs.chmodSync(path.dirname(binary), 0o755); + fs.chmodSync(binary, 0o755); + const lookupPrivateGroup = vi.fn(() => ({ exclusive: false, reason: "lookup-failed" })); + expect(validateDriverCandidate(binary, { lookupPrivateGroup })).toMatchObject({ + status: "found", + path: binary, + }); + expect(lookupPrivateGroup).not.toHaveBeenCalled(); + }); + + it("captures a strict file identity and detects metadata or content changes", () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + const first = validateDriverCandidate(binary); + const unchanged = validateDriverCandidate(binary); + expect(first).toMatchObject({ status: "found", fileIdentity: expect.any(Object) }); + expect(sameDriverFileIdentity(first.fileIdentity, unchanged.fileIdentity)).toBe(true); + + fs.appendFileSync(binary, "# replacement\n"); + const changed = validateDriverCandidate(binary); + expect(changed.status).toBe("found"); + expect(sameDriverFileIdentity(first.fileIdentity, changed.fileIdentity)).toBe(false); + expect(sameDriverFileIdentity({}, {})).toBe(false); + }); +}); + +describe("Linux private primary group proof", () => { + const identity = { uid: 1000, gid: 1000, username: "kesleydev" }; + const getent = ({ group = "kesleydev:x:1000:", passwd = "kesleydev:x:1000:1000::/home/kesleydev:/bin/bash" } = {}) => + vi.fn((args) => ({ + ok: true, + stdout: args[0] === "group" ? `${group}\n` : `${passwd}\n`, + })); + + it("accepts an exclusive user-private primary group", () => { + expect(privatePrimaryGroup(identity, { getent: getent() })).toEqual({ + exclusive: true, + gid: 1000, + name: "kesleydev", + }); + }); + + it("rejects explicit supplementary members and another primary-GID account", () => { + expect( + privatePrimaryGroup(identity, { + getent: getent({ group: "kesleydev:x:1000:other" }), + }), + ).toMatchObject({ exclusive: false, reason: "primary-group-shared" }); + expect( + privatePrimaryGroup(identity, { + getent: getent({ + passwd: [ + "kesleydev:x:1000:1000::/home/kesleydev:/bin/bash", + "other:x:1001:1000::/home/other:/bin/bash", + ].join("\n"), + }), + }), + ).toMatchObject({ exclusive: false, reason: "primary-group-shared" }); + }); + + it("fails closed when NSS lookup cannot prove membership", () => { + expect( + privatePrimaryGroup(identity, { + getent: vi.fn(() => ({ ok: false, reason: "lookup-timeout" })), + }), + ).toEqual({ exclusive: false, reason: "lookup-timeout" }); + }); + + it("fails closed on malformed NSS enumeration", () => { + expect( + privatePrimaryGroup(identity, { + getent: getent({ + passwd: [ + "kesleydev:x:1000:1000::/home/kesleydev:/bin/bash", + "malformed-entry", + ].join("\n"), + }), + }), + ).toEqual({ exclusive: false, reason: "lookup-malformed" }); + }); + + it("uses absolute getent argv without a shell and with bounded resources", () => { + const spawnCommand = vi.fn(() => ({ status: 0, stdout: "kesleydev:x:1000:\n", stderr: "" })); + expect(runGetent(["group", "1000"], { spawnCommand })).toEqual({ + ok: true, + stdout: "kesleydev:x:1000:\n", + }); + expect(spawnCommand).toHaveBeenCalledWith( + "/usr/bin/getent", + ["group", "1000"], + expect.objectContaining({ + maxBuffer: expect.any(Number), + shell: false, + timeout: expect.any(Number), + }), + ); + }); }); describe("Linux CUA diagnostics", () => { @@ -141,6 +331,7 @@ describe("Linux CUA diagnostics", () => { expect(result).toMatchObject({ status: "ready", path: binary, + fileIdentity: validateDriverCandidate(binary).fileIdentity, driverVersion: "0.19.3", manifestSchema: "1", mcp: { command: binary, args: ["mcp"] }, diff --git a/scripts/smoke-linux-package.mjs b/scripts/smoke-linux-package.mjs index 69968890..f0f6c987 100644 --- a/scripts/smoke-linux-package.mjs +++ b/scripts/smoke-linux-package.mjs @@ -96,6 +96,7 @@ const metadata = { host_bundle_id: "com.openmausbot.app", }; const tools = ["click", "get_window_state", "list_apps", "type_text"].map((name) => ({ name })); +const toolManifest = { schema_version: "1", capability_version: "1", tools }; const server = net.createServer((socket) => { let input = ""; socket.on("data", (chunk) => { @@ -103,7 +104,7 @@ const server = net.createServer((socket) => { const newline = input.indexOf("\\n"); if (newline === -1) return; const request = JSON.parse(input.slice(0, newline)); - const result = request.method === "metadata" ? metadata : request.method === "list" ? tools : null; + const result = request.method === "metadata" ? metadata : request.method === "list" ? toolManifest : null; socket.end(JSON.stringify(result ? { ok: true, result } : { ok: false, error: "unknown" }) + "\\n"); if (count === 1 && request.method === "list") setTimeout(() => server.close(() => process.exit(17)), 5000); }); diff --git a/server/local-computer.test.ts b/server/local-computer.test.ts index f20153e7..f5119d0f 100644 --- a/server/local-computer.test.ts +++ b/server/local-computer.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { appendFileSync, chmodSync, mkdirSync, statSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -13,6 +13,17 @@ function linuxDescriptor(userData: string) { const binary = join(userData, "cua-driver"); const socket = join(userData, "runtime", "driver.sock"); writeFileSync(binary, "fake", { mode: 0o700 }); + const stat = statSync(binary, { bigint: true }); + const fileIdentity = { + dev: String(stat.dev), + ino: String(stat.ino), + uid: String(stat.uid), + gid: String(stat.gid), + mode: String(stat.mode), + size: String(stat.size), + mtimeNs: String(stat.mtimeNs), + ctimeNs: String(stat.ctimeNs), + }; return { schemaVersion: 1, mode: "linux-x11-supervised", @@ -22,7 +33,7 @@ function linuxDescriptor(userData: string) { status: "ready", ownerPid: process.pid, generation: "01234567-89ab-cdef-0123-456789abcdef", - driver: { path: binary, version: "0.19.3", manifestSchema: "1" }, + driver: { path: binary, version: "0.19.3", manifestSchema: "1", fileIdentity }, daemon: { socketPath: socket, pid: process.pid, @@ -86,6 +97,14 @@ describe("local computer descriptor", () => { }), ).toBeNull(); expect(decodeLinuxDescriptor({ ...descriptor, toolNames: ["list_apps"] })).toBeNull(); + expect( + decodeLinuxDescriptor({ + ...descriptor, + driver: { ...descriptor.driver, fileIdentity: { ...descriptor.driver.fileIdentity, extra: "1" } }, + }), + ).toBeNull(); + const { fileIdentity: _missingIdentity, ...driverWithoutIdentity } = descriptor.driver; + expect(decodeLinuxDescriptor({ ...descriptor, driver: driverWithoutIdentity })).toBeNull(); }); it("fails closed when runtime ownership or liveness validation fails", () => { @@ -114,6 +133,10 @@ describe("local computer descriptor", () => { try { chmodSync(descriptor.daemon.socketPath, 0o600); expect(validateLinuxDescriptorRuntime(file, descriptor)).toBe(true); + expect(readCuaConnection({ platform: "linux", userData })).not.toBeNull(); + appendFileSync(descriptor.driver.path, "changed after descriptor publication"); + expect(validateLinuxDescriptorRuntime(file, descriptor)).toBe(false); + expect(readCuaConnection({ platform: "linux", userData })).toBeNull(); chmodSync(file, 0o644); expect(validateLinuxDescriptorRuntime(file, descriptor)).toBe(false); } finally { diff --git a/server/local-computer.ts b/server/local-computer.ts index 0611f59c..27258edd 100644 --- a/server/local-computer.ts +++ b/server/local-computer.ts @@ -4,6 +4,16 @@ import { homedir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; const REQUIRED_LINUX_TOOLS = ["click", "get_window_state", "list_apps", "type_text"]; +const DRIVER_FILE_IDENTITY_KEYS = [ + "dev", + "ino", + "uid", + "gid", + "mode", + "size", + "mtimeNs", + "ctimeNs", +] as const; export type LocalComputerConnection = { command: string; @@ -33,6 +43,39 @@ function legacyPlatform(platform: NodeJS.Platform): "darwin" | "win32" | null { return null; } +function validDriverFileIdentity(value: unknown): value is Record { + return ( + Boolean(value) && + typeof value === "object" && + !Array.isArray(value) && + exactKeys(value as Record, DRIVER_FILE_IDENTITY_KEYS) && + DRIVER_FILE_IDENTITY_KEYS.every( + (key) => typeof (value as Record)[key] === "string" && /^\d+$/.test((value as Record)[key]), + ) + ); +} + +function currentDriverFileIdentity(file: string): Record { + const stat = statSync(file, { bigint: true }); + return { + dev: String(stat.dev), + ino: String(stat.ino), + uid: String(stat.uid), + gid: String(stat.gid), + mode: String(stat.mode), + size: String(stat.size), + mtimeNs: String(stat.mtimeNs), + ctimeNs: String(stat.ctimeNs), + }; +} + +function sameDriverFileIdentity(expected: unknown, actual: Record): boolean { + return ( + validDriverFileIdentity(expected) && + DRIVER_FILE_IDENTITY_KEYS.every((key) => expected[key] === actual[key]) + ); +} + function decodeLegacyDescriptor( value: LegacyConnectionDescriptor, platform: NodeJS.Platform, @@ -103,7 +146,7 @@ export function decodeLinuxDescriptor(value: LinuxConnectionDescriptor): LocalCo Array.isArray(driver) || Array.isArray(daemon) || Array.isArray(mcp) || - !exactKeys(driver, ["path", "version", "manifestSchema"]) || + !exactKeys(driver, ["path", "version", "manifestSchema", "fileIdentity"]) || !exactKeys(daemon, [ "socketPath", "pid", @@ -121,6 +164,7 @@ export function decodeLinuxDescriptor(value: LinuxConnectionDescriptor): LocalCo !isAbsolute(driver.path) || driver.version !== "0.19.3" || driver.manifestSchema !== "1" || + !validDriverFileIdentity(driver.fileIdentity) || typeof daemon.socketPath !== "string" || !isAbsolute(daemon.socketPath) || !Number.isInteger(daemon.pid) || @@ -225,10 +269,12 @@ export function validateLinuxDescriptorRuntime( const binaryPath = driver.path as string; const socketPath = daemon.socketPath as string; const binaryStat = statSync(binaryPath); + const currentFileIdentity = currentDriverFileIdentity(binaryPath); const socketStat = lstatSync(socketPath); const socketDirectoryStat = lstatSync(dirname(socketPath)); if ( realpathSync(binaryPath) !== binaryPath || + !sameDriverFileIdentity(driver.fileIdentity, currentFileIdentity) || !binaryStat.isFile() || (binaryStat.uid !== uid && binaryStat.uid !== 0) || (binaryStat.mode & 0o111) === 0 || From 392a594ad5589b423fe8c2d78eda6517181c5e70 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Thu, 13 Aug 2026 20:15:39 -0300 Subject: [PATCH 12/32] isolate Linux package smoke runtime --- .github/workflows/ci.yml | 2 +- package.json | 1 + scripts/run-linux-package-smoke.mjs | 32 +++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 scripts/run-linux-package-smoke.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99420f6b..be9f97b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: sudo chmod 4755 release/linux-unpacked/chrome-sandbox test "$(stat -c '%U:%G %a' release/linux-unpacked/chrome-sandbox)" = "root:root 4755" - name: Launch packaged app and verify lifecycle - run: dbus-run-session -- xvfb-run -a node scripts/smoke-linux-package.mjs + run: pnpm smoke:linux-package - uses: actions/upload-artifact@v4 if: always() with: diff --git a/package.json b/package.json index 259f0b43..24805790 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "package:mac": "pnpm package:prepare && pnpm build:speech && pnpm build:cua && electron-builder --mac --publish never", "package:win": "pnpm package:prepare && electron-builder --win --publish never", "package:linux": "pnpm package:prepare && electron-builder --linux --x64 --publish never", + "smoke:linux-package": "node scripts/run-linux-package-smoke.mjs", "package:linux:dir": "pnpm package:prepare && electron-builder --linux dir --x64 --publish never", "package": "pnpm package:mac" }, diff --git a/scripts/run-linux-package-smoke.mjs b/scripts/run-linux-package-smoke.mjs new file mode 100644 index 00000000..7a6f8ba3 --- /dev/null +++ b/scripts/run-linux-package-smoke.mjs @@ -0,0 +1,32 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const prefixName = "omb-linux-smoke-runtime-"; +const runtimeDirectory = mkdtempSync(path.join(tmpdir(), prefixName)); + +if ( + path.dirname(runtimeDirectory) !== path.resolve(tmpdir()) || + !path.basename(runtimeDirectory).startsWith(prefixName) +) { + throw new Error(`[run-linux-package-smoke] unexpected temporary path: ${runtimeDirectory}`); +} + +chmodSync(runtimeDirectory, 0o700); +const result = spawnSync( + "dbus-run-session", + ["--", "xvfb-run", "-a", process.execPath, path.join(root, "scripts", "smoke-linux-package.mjs")], + { + cwd: root, + env: { ...process.env, XDG_RUNTIME_DIR: runtimeDirectory }, + stdio: "inherit", + }, +); +if (result.error) throw result.error; +if (result.status !== 0) { + console.error(`[run-linux-package-smoke] isolated runtime kept at ${runtimeDirectory}`); + process.exitCode = result.status ?? 1; +} From 09fe0b8f2fa240f2eb7b93002bb8b36562bde537 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Thu, 13 Aug 2026 20:15:44 -0300 Subject: [PATCH 13/32] handle updater feed rejections --- electron/updater.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/electron/updater.mjs b/electron/updater.mjs index 866b9b97..a1910cb8 100644 --- a/electron/updater.mjs +++ b/electron/updater.mjs @@ -36,7 +36,10 @@ function check(manual = false) { if (!autoUpdater) return; userInitiated = manual; try { - autoUpdater.checkForUpdates(); + // electron-updater reports feed/network failures by both emitting `error` + // and rejecting this promise. Handle the rejection as well so a missing + // platform feed never becomes an unhandled rejection in the packaged app. + void autoUpdater.checkForUpdates().catch(reportError); } catch (e) { reportError(e); } @@ -52,7 +55,9 @@ export function registerUpdaterIpc() { ipcMain.handle("update:check", () => check(true)); ipcMain.handle("update:download", () => { try { - autoUpdater?.downloadUpdate(); + void autoUpdater?.downloadUpdate().catch((e) => + setState({ status: "error", message: String(e?.message ?? e) }), + ); } catch (e) { setState({ status: "error", message: String(e?.message ?? e) }); } From db43f678d879f5bc422469abadc255b9a4de6187 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Thu, 13 Aug 2026 20:15:49 -0300 Subject: [PATCH 14/32] document pinned Linux CUA installation --- docs/linux-desktop.md | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/linux-desktop.md b/docs/linux-desktop.md index f2d00166..f507b7ff 100644 --- a/docs/linux-desktop.md +++ b/docs/linux-desktop.md @@ -138,13 +138,28 @@ GNOME/Xorg. Install Cua Driver from its [official installation guide](https://cua.ai/docs/how-to-guides/driver/install): ```sh -/bin/bash -c "$(curl -fsSL https://cua.ai/driver/install.sh)" +CUA_DRIVER_RS_VERSION=0.19.3 /bin/bash -c "$(curl -fsSL https://cua.ai/driver/install.sh)" cua-driver --version cua-driver manifest --pretty cua-driver doctor --json ``` -Confirm the version is `0.19.3` and sign into a GNOME on Xorg session. Then: +Confirm the version is `0.19.3`. OpenMausBot also rejects an executable or containing directory that another +local user could replace. Ubuntu's normal user-private group layout is accepted after OpenMausBot verifies that +the group belongs only to your account. On a shared or centrally managed group, the app may ask you to remove +group write access from the exact user-owned install directories: + +```sh +driver_path="$(readlink -f "$(command -v cua-driver)")" +case "$driver_path" in + "$HOME"/.cua-driver/packages/releases/0.19.3-*/cua-driver) ;; + *) echo "Unexpected Cua Driver path: $driver_path" >&2; exit 1 ;; +esac +chmod go-w "$HOME/.local/bin" "$HOME/.cua-driver" "$HOME/.cua-driver/packages" \ + "$HOME/.cua-driver/packages/releases" "$(dirname "$driver_path")" +``` + +Sign into a GNOME on Xorg session. Then: 1. Open a bot's **Computer** panel. 2. In **Local control**, choose **Enable local control (Beta)** and review the warning. @@ -174,14 +189,15 @@ pnpm test pnpm check:electron pnpm package:linux node scripts/verify-linux-package.mjs -dbus-run-session -- xvfb-run -a node scripts/smoke-linux-package.mjs +pnpm smoke:linux-package ``` The verifier checks `.deb` metadata, desktop identity, resources, artifact permissions, and that no Cua executable was bundled. The smoke test launches the unpacked production app without `--no-sandbox`, validates the renderer/preload and embedded health endpoint, then uses a fake user-installed driver to prove diagnostics, -private-daemon readiness, crash invalidation, explicit retry, and clean shutdown. It is not a substitute for real -GNOME Xorg action evidence or real GNOME Wayland portal evidence. +private-daemon readiness, crash invalidation, explicit retry, and clean shutdown. Its wrapper isolates the +temporary D-Bus/AT-SPI runtime so it cannot replace the live desktop session's accessibility socket. It is not a +substitute for real GNOME Xorg action evidence or real GNOME Wayland portal evidence. ## Troubleshooting @@ -209,7 +225,9 @@ cua-driver doctor --json Repair any display, session bus, or AT-SPI diagnostic before choosing **Try again**. If the path shown in the app is unexpected, close OpenMausBot and launch it with an absolute `CUA_DRIVER_PATH`. An invalid explicit override -fails without silently selecting another executable. +fails without silently selecting another executable. For `unsafe-driver-permissions`, use the bounded +permission-hardening commands in **Enable local control on Xorg**; do not make the driver executable or its +directories world-writable. ### Screen preview does not start From 665ab4346e14b02cc99877264a710d5d31a222bd Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 11:13:31 -0300 Subject: [PATCH 15/32] enable guarded GNOME Wayland CUA control --- electron/capabilities.cjs | 33 ++++-- electron/capabilities.test.mjs | 34 ++++++ electron/cua-linux-runtime.cjs | 174 ++++++++++++++++++++++++++-- electron/cua-linux-runtime.test.mjs | 162 +++++++++++++++++++++++++- electron/cua-linux.cjs | 65 ++++++++--- electron/cua-linux.test.mjs | 70 ++++++++++- server/local-computer.test.ts | 36 +++++- server/local-computer.ts | 44 ++++--- 8 files changed, 559 insertions(+), 59 deletions(-) diff --git a/electron/capabilities.cjs b/electron/capabilities.cjs index 2551138b..5ecc3625 100644 --- a/electron/capabilities.cjs +++ b/electron/capabilities.cjs @@ -11,11 +11,10 @@ function normalizedPlatform(platform) { function linuxSession(platform, env) { if (platform !== "linux") return "unknown"; const declared = String(env.XDG_SESSION_TYPE ?? "").toLowerCase(); - if (declared === "wayland") return "wayland"; - if (declared === "x11" || declared === "xorg") return "x11"; // A Wayland user session may also expose DISPLAY for XWayland. Prefer the // Wayland signal so the UI never bypasses portal-mediated behavior. - if (env.WAYLAND_DISPLAY) return "wayland"; + if (declared === "wayland" || env.WAYLAND_DISPLAY) return "wayland"; + if (declared === "x11" || declared === "xorg") return "x11"; if (env.DISPLAY) return "x11"; return "headless"; } @@ -24,14 +23,22 @@ function localComputerReady(platform, connection) { if (platform === "darwin") { return connection?.mode === "embedded" || connection?.mode === "standalone"; } + if ( + platform !== "linux" || + connection?.schemaVersion !== 1 || + connection?.platform !== "linux" || + connection?.enabled !== true || + connection?.status !== "ready" + ) { + return false; + } + if (connection.mode === "linux-x11-supervised") { + return connection.session === "x11"; + } return ( - platform === "linux" && - connection?.schemaVersion === 1 && - connection?.mode === "linux-x11-supervised" && - connection?.platform === "linux" && - connection?.session === "x11" && - connection?.enabled === true && - connection?.status === "ready" + connection.mode === "linux-wayland-gnome-supervised" && + connection.session === "wayland" && + connection.compositor === "gnome-mutter" ); } @@ -90,6 +97,12 @@ function desktopCapabilities({ ...(typeof localConnection?.driver?.version === "string" ? { driverVersion: localConnection.driver.version } : {}), + ...(typeof localConnection?.session === "string" + ? { session: localConnection.session } + : {}), + ...(typeof localConnection?.compositor === "string" + ? { compositor: localConnection.compositor } + : {}), ...(!localAvailable ? { reasonCode: diff --git a/electron/capabilities.test.mjs b/electron/capabilities.test.mjs index bf40b779..60248bc4 100644 --- a/electron/capabilities.test.mjs +++ b/electron/capabilities.test.mjs @@ -65,6 +65,13 @@ describe("desktop capabilities", () => { it("detects Wayland before XWayland and distinguishes X11 and headless Linux", () => { expect(linuxSession("linux", { WAYLAND_DISPLAY: "wayland-0", DISPLAY: ":0" })).toBe("wayland"); + expect( + linuxSession("linux", { + XDG_SESSION_TYPE: "x11", + WAYLAND_DISPLAY: "wayland-0", + DISPLAY: ":0", + }), + ).toBe("wayland"); expect(linuxSession("linux", { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" })).toBe("x11"); expect(linuxSession("linux", {})).toBe("headless"); }); @@ -102,4 +109,31 @@ describe("desktop capabilities", () => { expect(localComputerReady("linux", { ...connection, status: "starting" })).toBe(false); expect(localComputerReady("linux", { ...connection, schemaVersion: 2 })).toBe(false); }); + + it("enables GNOME Wayland control only for the exact supervised contract", () => { + const connection = { + schemaVersion: 1, + mode: "linux-wayland-gnome-supervised", + platform: "linux", + session: "wayland", + compositor: "gnome-mutter", + enabled: true, + status: "ready", + }; + expect(localComputerReady("linux", connection)).toBe(true); + expect(localComputerReady("linux", { ...connection, compositor: undefined })).toBe(false); + expect(localComputerReady("linux", { ...connection, session: "x11" })).toBe(false); + expect( + desktopCapabilities({ + platform: "linux", + env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" }, + localConnection: connection, + }).localComputer, + ).toMatchObject({ + available: true, + support: "limited", + session: "wayland", + compositor: "gnome-mutter", + }); + }); }); diff --git a/electron/cua-linux-runtime.cjs b/electron/cua-linux-runtime.cjs index 966a4ddb..b548248f 100644 --- a/electron/cua-linux-runtime.cjs +++ b/electron/cua-linux-runtime.cjs @@ -21,6 +21,11 @@ const CERTIFIED_TOOLS_LIST_SCHEMA_VERSION = "1"; const CERTIFIED_CAPABILITY_VERSION = "1"; const CERTIFIED_MCP_PROTOCOL_VERSION = "2025-06-18"; const REQUIRED_TOOLS = ["click", "get_window_state", "list_apps", "type_text"]; +const REQUIRED_WAYLAND_HEALTH_CHECKS = [ + "ax_capability", + "screen_capture_capability", + "wayland_backend", +]; function ensurePrivateDirectory(directory, fileSystem = fs, currentUid = process.getuid?.() ?? os.userInfo().uid) { fileSystem.mkdirSync(directory, { recursive: true, mode: 0o700 }); @@ -195,8 +200,97 @@ function validateToolSurface(response) { return [...names].sort(); } +function healthFailure(check) { + const detail = `${check?.message ?? ""} ${check?.hint ?? ""}`.toLowerCase(); + if (check?.name === "ax_capability") { + return Object.assign( + new Error("Cua Driver could not reach the AT-SPI accessibility bus in this Wayland session."), + { code: "at-spi-unavailable" }, + ); + } + if (check?.name === "screen_capture_capability") { + return Object.assign( + new Error("Cua Driver could not reach a supported screen capture backend in this Wayland session."), + { code: "wayland-capture-unavailable" }, + ); + } + if (detail.includes("winrects") || detail.includes("target-activation")) { + return Object.assign( + new Error("The Cua WinRects helper is not active. Install it, then sign out and back in once."), + { code: "wayland-helper-required" }, + ); + } + if (detail.includes("portal")) { + return Object.assign( + new Error("The GNOME Remote Desktop portal is not available for local control."), + { code: "wayland-portal-unavailable" }, + ); + } + return Object.assign( + new Error("Cua Driver could not verify the GNOME Wayland control backend."), + { code: "wayland-health-failed" }, + ); +} + +function validateWaylandHealthReport(response) { + const result = response?.ok === true ? response.result : null; + const report = result?.structuredContent ?? result?.structured_content; + if ( + !report || + typeof report !== "object" || + Array.isArray(report) || + report.schema_version !== "1" || + report.platform !== "linux" || + report.driver_version !== CERTIFIED_DRIVER_VERSION || + !["ok", "degraded", "failed"].includes(report.overall) || + !Array.isArray(report.checks) + ) { + throw Object.assign(new Error("Cua Driver returned an invalid Wayland health report."), { + code: "invalid-health-report", + }); + } + const checks = new Map(); + for (const check of report.checks) { + if ( + !check || + typeof check !== "object" || + Array.isArray(check) || + typeof check.name !== "string" || + !["pass", "fail", "skip"].includes(check.status) || + typeof check.message !== "string" || + (check.status === "fail" && typeof check.hint !== "string") || + checks.has(check.name) + ) { + throw Object.assign(new Error("Cua Driver returned an invalid Wayland health check."), { + code: "invalid-health-report", + }); + } + checks.set(check.name, check); + } + for (const name of REQUIRED_WAYLAND_HEALTH_CHECKS) { + const check = checks.get(name); + if (check?.status !== "pass") throw healthFailure(check ?? { name }); + } + if (report.overall !== "ok") { + const failed = [...checks.values()].find((check) => check.status === "fail"); + throw healthFailure(failed); + } + return Object.freeze({ + schemaVersion: report.schema_version, + overall: report.overall, + requiredChecks: Object.freeze([...REQUIRED_WAYLAND_HEALTH_CHECKS]), + }); +} + +async function probeWaylandHealth(socketPath, { request = requestSocket } = {}) { + return validateWaylandHealthReport( + await request(socketPath, { method: "call", name: "health_report", args: {} }), + ); +} + async function probePrivateDaemon(socketPath, { childPid, + session = "x11", timeoutMs = 10_000, request = requestSocket, } = {}) { @@ -209,7 +303,9 @@ async function probePrivateDaemon(socketPath, { { childPid }, ); const tools = validateToolSurface(await request(socketPath, { method: "list" })); - return { metadata, tools }; + const health = + session === "wayland" ? await probeWaylandHealth(socketPath, { request }) : undefined; + return { metadata, tools, ...(health ? { health } : {}) }; } catch (error) { lastError = error; await new Promise((resolve) => setTimeout(resolve, 75)); @@ -255,11 +351,17 @@ async function stopOwnedChild(child) { function publicRuntimeStatus(connection) { return { enabled: connection.enabled === true, - status: connection.status ?? (connection.mode === "linux-x11-supervised" ? "ready" : "unavailable"), + status: + connection.status ?? + (["linux-x11-supervised", "linux-wayland-gnome-supervised"].includes(connection.mode) + ? "ready" + : "unavailable"), reasonCode: connection.reasonCode, message: connection.message ?? connection.reason, driverPath: connection.driver?.path, driverVersion: connection.driver?.version, + session: connection.session, + compositor: connection.compositor, warnings: connection.doctorWarnings ?? [], }; } @@ -273,6 +375,10 @@ function createLinuxCuaRuntime({ inspect = inspectLinuxCuaDriver, spawnProcess = spawn, probe = probePrivateDaemon, + healthProbe = probeWaylandHealth, + healthCheckIntervalMs = 30_000, + setRecurring = setInterval, + clearRecurring = clearInterval, identifier = randomUUID, processId = process.pid, onChange = () => {}, @@ -335,6 +441,10 @@ function createLinuxCuaRuntime({ }; const cleanupRuntimeFiles = (owned) => { + if (owned?.healthTimer) { + clearRecurring(owned.healthTimer); + owned.healthTimer = null; + } if (!owned?.runtimeDirectory) return; for (const file of [owned.socketPath, owned.pidFile]) { try { @@ -360,6 +470,37 @@ function createLinuxCuaRuntime({ ); }; + const markWaylandHealthLost = (owned, error) => { + if (active !== owned || owned.stopping || quitting) return; + owned.stopping = true; + active = null; + if (owned.healthTimer) { + clearRecurring(owned.healthTimer); + owned.healthTimer = null; + } + unavailable( + "error", + error?.code ?? "wayland-health-lost", + error?.message ?? "The GNOME Wayland control backend is no longer available.", + { generation: owned.generation }, + ); + void stopOwnedChild(owned.child).finally(() => cleanupRuntimeFiles(owned)); + }; + + const startWaylandHealthMonitor = (owned) => { + if (!Number.isFinite(healthCheckIntervalMs) || healthCheckIntervalMs <= 0) return; + owned.healthTimer = setRecurring(() => { + if (active !== owned || owned.stopping || owned.healthChecking) return; + owned.healthChecking = true; + void healthProbe(owned.socketPath) + .catch((error) => markWaylandHealthLost(owned, error)) + .finally(() => { + owned.healthChecking = false; + }); + }, healthCheckIntervalMs); + owned.healthTimer.unref?.(); + }; + const start = async () => { if (platform !== "linux") return connection; if (!enabled) { @@ -373,7 +514,7 @@ function createLinuxCuaRuntime({ if (startPromise) return startPromise; startPromise = (async () => { - unavailable("checking", "checking-driver", "Checking Cua Driver and the Xorg session…"); + unavailable("checking", "checking-driver", "Checking Cua Driver and the desktop session…"); const inspected = await inspect({ platform, env }); if (inspected.status !== "ready") { return unavailable("error", inspected.reasonCode, inspected.message, { @@ -384,6 +525,7 @@ function createLinuxCuaRuntime({ }); } if (!enabled || quitting) return connection; + const runtimeSession = inspected.session === "wayland" ? "wayland" : "x11"; const generation = identifier(); const root = runtimeRoot(); @@ -403,6 +545,7 @@ function createLinuxCuaRuntime({ CUA_DRIVER_EMBEDDED: "1", CUA_DRIVER_HOST_BUNDLE_ID: HOST_BUNDLE_ID, CUA_DRIVER_PARENT_LIVENESS_STDIN: "1", + ...(runtimeSession === "wayland" ? { CUA_DRIVER_RS_ENABLE_WAYLAND: "1" } : {}), }); const args = [ "serve", @@ -444,6 +587,8 @@ function createLinuxCuaRuntime({ pidFile, ready: false, stopping: false, + healthTimer: null, + healthChecking: false, }; active = owned; child.stderr?.on("data", () => {}); @@ -455,18 +600,25 @@ function createLinuxCuaRuntime({ }); try { - const handshake = await probe(socketPath, { childPid: child.pid }); + const handshake = await probe(socketPath, { + childPid: child.pid, + session: runtimeSession, + }); if (active !== owned || owned.stopping || !enabled) { await stopOwnedChild(child); cleanupRuntimeFiles(owned); return connection; } owned.ready = true; - return publish({ + const readyConnection = publish({ schemaVersion: CONNECTION_SCHEMA_VERSION, - mode: "linux-x11-supervised", + mode: + runtimeSession === "wayland" + ? "linux-wayland-gnome-supervised" + : "linux-x11-supervised", platform: "linux", - session: "x11", + session: runtimeSession, + ...(inspected.compositor ? { compositor: inspected.compositor } : {}), enabled: true, status: "ready", ownerPid: processId, @@ -494,11 +646,16 @@ function createLinuxCuaRuntime({ CUA_DRIVER_EMBEDDED: "1", CUA_DRIVER_HOST_BUNDLE_ID: HOST_BUNDLE_ID, CUA_DRIVER_RS_UPDATE_CHECK: "false", + ...(runtimeSession === "wayland" + ? { CUA_DRIVER_RS_ENABLE_WAYLAND: "1" } + : {}), }, }, toolNames: handshake.tools, doctorWarnings: inspected.doctor.warnings, }); + if (runtimeSession === "wayland") startWaylandHealthMonitor(owned); + return readyConnection; } catch (error) { owned.stopping = true; const stillOwned = active === owned; @@ -585,14 +742,17 @@ module.exports = { CONNECTION_SCHEMA_VERSION, HOST_BUNDLE_ID, REQUIRED_TOOLS, + REQUIRED_WAYLAND_HEALTH_CHECKS, createLinuxCuaPreferenceStore, createLinuxCuaRuntime, ensurePrivateDirectory, probePrivateDaemon, + probeWaylandHealth, publicRuntimeStatus, requestSocket, stopOwnedChild, validateDaemonMetadata, validateToolSurface, + validateWaylandHealthReport, writePrivateJson, }; diff --git a/electron/cua-linux-runtime.test.mjs b/electron/cua-linux-runtime.test.mjs index 9fffee66..9ce19eb3 100644 --- a/electron/cua-linux-runtime.test.mjs +++ b/electron/cua-linux-runtime.test.mjs @@ -11,8 +11,10 @@ const { validateDriverCandidate } = require("./cua-linux.cjs"); const { createLinuxCuaPreferenceStore, createLinuxCuaRuntime, + probePrivateDaemon, validateDaemonMetadata, validateToolSurface, + validateWaylandHealthReport, writePrivateJson, } = require("./cua-linux-runtime.cjs"); @@ -72,7 +74,42 @@ function handshake(pid = 4321) { }; } -function harness({ preferenceEnabled = false, afterIdentityCaptured } = {}) { +function healthyWaylandHealth() { + return { + ok: true, + result: { + structuredContent: { + schema_version: "1", + platform: "linux", + driver_version: "0.19.3", + overall: "ok", + checks: [ + { name: "binary_version", status: "pass", message: "cua-driver 0.19.3" }, + { name: "platform_supported", status: "pass", message: "Ubuntu 24.04" }, + { name: "session_active", status: "pass", message: "MCP session is active." }, + { name: "ax_capability", status: "pass", message: "AT-SPI is reachable." }, + { + name: "screen_capture_capability", + status: "pass", + message: "Screenshot portal is reachable.", + }, + { + name: "wayland_backend", + status: "pass", + message: "Portal/libei and verified target activation are reachable.", + }, + ], + }, + }, + }; +} + +function harness({ + preferenceEnabled = false, + afterIdentityCaptured, + session = "x11", + runtimeOptions = {}, +} = {}) { const userData = temporaryDirectory(); const runtimeRoot = path.join(userData, "session"); fs.mkdirSync(runtimeRoot, { mode: 0o700 }); @@ -93,6 +130,8 @@ function harness({ preferenceEnabled = false, afterIdentityCaptured } = {}) { manifestSchema: "1", mcp: { command: binary, args: ["mcp"] }, doctor: { ok: true, probes: [], warnings: [] }, + session, + ...(session === "wayland" ? { compositor: "gnome-mutter" } : {}), }; }); const spawnProcess = vi.fn(() => child); @@ -107,16 +146,25 @@ function harness({ preferenceEnabled = false, afterIdentityCaptured } = {}) { HOME: userData, PATH: "/usr/bin", DISPLAY: ":0", - XDG_SESSION_TYPE: "x11", + XDG_SESSION_TYPE: session, + ...(session === "wayland" + ? { + WAYLAND_DISPLAY: "wayland-0", + XDG_CURRENT_DESKTOP: "ubuntu:GNOME", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus", + } + : {}), XDG_RUNTIME_DIR: runtimeRoot, OPENAI_API_KEY: "must-not-leak", }, inspect, spawnProcess, probe, + healthCheckIntervalMs: 0, identifier: () => "01234567-89ab-cdef-0123-456789abcdef", processId: 1234, onChange: (connection) => changes.push(connection), + ...runtimeOptions, }); return { binary, @@ -171,6 +219,7 @@ describe("Linux CUA opt-in and lifecycle", () => { expect(spawnOptions.env.OPENAI_API_KEY).toBeUndefined(); expect(context.probe).toHaveBeenCalledWith(expect.stringMatching(/driver\.sock$/), { childPid: context.child.pid, + session: "x11", }); expect(context.runtime.getConnection()).toMatchObject({ schemaVersion: 1, @@ -259,6 +308,56 @@ describe("Linux CUA opt-in and lifecycle", () => { expect(context.preferenceStore.read()).toBe(false); expect(context.runtime.getStatus()).toMatchObject({ enabled: false, status: "disabled" }); }); + + it("publishes the distinct GNOME Wayland contract and propagates the opt-in environment", async () => { + const context = harness({ session: "wayland" }); + await context.runtime.enable(); + expect(context.probe).toHaveBeenCalledWith(expect.stringMatching(/driver\.sock$/), { + childPid: context.child.pid, + session: "wayland", + }); + expect(context.spawnProcess.mock.calls[0][2].env.CUA_DRIVER_RS_ENABLE_WAYLAND).toBe("1"); + expect(context.runtime.getConnection()).toMatchObject({ + mode: "linux-wayland-gnome-supervised", + session: "wayland", + compositor: "gnome-mutter", + mcp: { env: { CUA_DRIVER_RS_ENABLE_WAYLAND: "1" } }, + }); + }); + + it("revokes Wayland readiness when a prompt-free health recheck fails", async () => { + let healthTick = null; + const timer = { unref: vi.fn() }; + const clearRecurring = vi.fn(); + const context = harness({ + session: "wayland", + runtimeOptions: { + healthCheckIntervalMs: 30_000, + healthProbe: vi.fn(async () => { + throw Object.assign(new Error("The Cua WinRects helper is no longer active."), { + code: "wayland-helper-required", + }); + }), + setRecurring: vi.fn((callback) => { + healthTick = callback; + return timer; + }), + clearRecurring, + }, + }); + await context.runtime.enable(); + expect(context.runtime.getConnection().status).toBe("ready"); + healthTick(); + await vi.waitFor(() => { + expect(context.runtime.getConnection()).toMatchObject({ + mode: "unavailable", + status: "error", + reasonCode: "wayland-helper-required", + }); + }); + expect(clearRecurring).toHaveBeenCalledWith(timer); + expect(context.child.stdin.end).toHaveBeenCalledOnce(); + }); }); describe("Linux CUA private data", () => { @@ -314,4 +413,63 @@ describe("Linux CUA handshake validation", () => { validateToolSurface({ ok: true, result: { ...manifest, capability_version: "2" } }), ).toThrow(/could not be verified/); }); + + it("accepts only a healthy certified Wayland report", () => { + expect(validateWaylandHealthReport(healthyWaylandHealth())).toEqual({ + schemaVersion: "1", + overall: "ok", + requiredChecks: ["ax_capability", "screen_capture_capability", "wayland_backend"], + }); + const unhealthy = healthyWaylandHealth(); + const check = unhealthy.result.structuredContent.checks.find( + (entry) => entry.name === "wayland_backend", + ); + check.status = "fail"; + check.message = "The compositor has no verified target-activation adapter."; + check.hint = "Install and enable the bundled WinRects Shell helper."; + unhealthy.result.structuredContent.overall = "degraded"; + expect(() => validateWaylandHealthReport(unhealthy)).toThrowError( + expect.objectContaining({ code: "wayland-helper-required" }), + ); + expect(() => + validateWaylandHealthReport({ + ...healthyWaylandHealth(), + result: { + structuredContent: { + ...healthyWaylandHealth().result.structuredContent, + schema_version: "2", + }, + }, + }), + ).toThrowError(expect.objectContaining({ code: "invalid-health-report" })); + }); + + it("calls health_report during the private daemon handshake only on Wayland", async () => { + const calls = []; + const request = vi.fn(async (_socket, payload) => { + calls.push(payload); + if (payload.method === "metadata") return { ok: true, result: handshake(99).metadata }; + if (payload.method === "list") { + return { + ok: true, + result: { + schema_version: "1", + capability_version: "1", + tools: handshake().tools.map((name) => ({ name })), + }, + }; + } + return healthyWaylandHealth(); + }); + + await expect( + probePrivateDaemon("/tmp/cua.sock", { + childPid: 99, + session: "wayland", + request, + timeoutMs: 100, + }), + ).resolves.toMatchObject({ health: { overall: "ok" } }); + expect(calls.at(-1)).toEqual({ method: "call", name: "health_report", args: {} }); + }); }); diff --git a/electron/cua-linux.cjs b/electron/cua-linux.cjs index 0bc31473..84538d30 100644 --- a/electron/cua-linux.cjs +++ b/electron/cua-linux.cjs @@ -556,7 +556,7 @@ function validateManifest(manifest, binaryPath) { return { command: binaryPath, args: ["mcp"] }; } -function validateDoctor(report) { +function validateDoctor(report, { session = "x11" } = {}) { if (typeof report.ok !== "boolean" || !Array.isArray(report.probes)) { throw commandFailure("invalid-doctor-report", "Cua Driver returned an invalid doctor report."); } @@ -583,11 +583,21 @@ function validateDoctor(report) { if (!report.ok || probes.some((probe) => probe.status === "err")) { throw commandFailure("doctor-failed", "Cua Driver diagnostics reported an error.", { probes }); } - if (display?.status !== "ok" || !display.message.startsWith("X11 ")) { - throw commandFailure("x11-unavailable", "Cua Driver did not confirm an Xorg display.", { probes }); - } - if (!x11 || x11.status === "err") { - throw commandFailure("x11-unavailable", "Cua Driver could not verify the Xorg session.", { probes }); + if (session === "wayland") { + if (display?.status !== "ok" || !display.message.startsWith("Wayland")) { + throw commandFailure( + "wayland-session-unavailable", + "Cua Driver did not confirm an active Wayland display.", + { probes }, + ); + } + } else { + if (display?.status !== "ok" || !display.message.startsWith("X11 ")) { + throw commandFailure("x11-unavailable", "Cua Driver did not confirm an Xorg display.", { probes }); + } + if (!x11 || x11.status === "err") { + throw commandFailure("x11-unavailable", "Cua Driver could not verify the Xorg session.", { probes }); + } } if (atSpi?.status !== "ok") { throw commandFailure("at-spi-unavailable", "Cua Driver could not reach the AT-SPI accessibility bus.", { @@ -607,19 +617,39 @@ async function inspectLinuxCuaDriver({ lookupPrivateGroup, run = runCuaCommand, } = {}) { - const session = String(env.XDG_SESSION_TYPE ?? "").toLowerCase(); + const declaredSession = String(env.XDG_SESSION_TYPE ?? "").toLowerCase(); + const session = + declaredSession === "wayland" || env.WAYLAND_DISPLAY + ? "wayland" + : declaredSession === "x11" || declaredSession === "xorg" + ? "x11" + : "unknown"; if (platform !== "linux") { return unavailable("unsupported-platform", "Linux local control is only available on Ubuntu."); } - if (session !== "x11" && session !== "xorg") { + if (session === "wayland") { + const desktops = [env.XDG_CURRENT_DESKTOP, env.XDG_SESSION_DESKTOP] + .flatMap((value) => String(value ?? "").toLowerCase().split(":")) + .filter(Boolean); + if (!desktops.includes("gnome")) { + return unavailable( + "wayland-compositor-unsupported", + "Wayland local control is currently limited to GNOME.", + ); + } + if (!env.WAYLAND_DISPLAY || !env.DBUS_SESSION_BUS_ADDRESS) { + return unavailable( + "wayland-session-unavailable", + "Local control requires an active GNOME Wayland desktop session.", + ); + } + } else if (session !== "x11") { return unavailable( - session === "wayland" ? "wayland-unsupported" : "x11-required", - session === "wayland" - ? "Local control is not available in a Wayland session. Sign in with GNOME on Xorg." - : "Local control requires an interactive GNOME on Xorg session.", + "desktop-session-required", + "Local control requires an interactive GNOME desktop session.", ); } - if (!env.DISPLAY) { + if (session === "x11" && !env.DISPLAY) { return unavailable("display-unavailable", "Local control requires an active Xorg display."); } @@ -632,7 +662,10 @@ async function inspectLinuxCuaDriver({ lookupPrivateGroup, }); if (discovered.status !== "found") return discovered; - const commandEnv = desktopCommandEnvironment(env); + const commandEnv = desktopCommandEnvironment( + env, + session === "wayland" ? { CUA_DRIVER_RS_ENABLE_WAYLAND: "1" } : {}, + ); try { const versionResult = await run(discovered.path, ["--version"], { env: commandEnv }); @@ -663,7 +696,7 @@ async function inspectLinuxCuaDriver({ source: discovered.source, }); } - const doctor = validateDoctor(doctorReport); + const doctor = validateDoctor(doctorReport, { session }); return { status: "ready", @@ -675,6 +708,8 @@ async function inspectLinuxCuaDriver({ mcp, doctor, commandEnv, + session, + ...(session === "wayland" ? { compositor: "gnome-mutter" } : {}), }; } catch (error) { return unavailable(error?.code ?? "diagnostics-failed", error?.message ?? String(error), { diff --git a/electron/cua-linux.test.mjs b/electron/cua-linux.test.mjs index eca0cbe9..60d62af9 100644 --- a/electron/cua-linux.test.mjs +++ b/electron/cua-linux.test.mjs @@ -45,7 +45,23 @@ function healthyDoctor() { }; } -function successfulRunner(binary) { +function healthyWaylandDoctor() { + return { + ok: true, + probes: [ + { label: "binary", status: "ok", message: "cua-driver 0.19.3" }, + { + label: "display server", + status: "ok", + message: "Wayland+XWayland (WAYLAND_DISPLAY=wayland-0, DISPLAY=:0)", + }, + { label: "X11 connection", status: "warn", message: "no top-level windows returned" }, + { label: "AT-SPI", status: "ok", message: "org.a11y.Bus reachable via session bus" }, + ], + }; +} + +function successfulRunner(binary, { doctor = healthyDoctor() } = {}) { return vi.fn(async (_command, args, options) => { expect(_command).toBe(binary); expect(options.env.OPENAI_API_KEY).toBeUndefined(); @@ -62,7 +78,7 @@ function successfulRunner(binary) { stderr: "", }; } - return { exitCode: 0, stdout: JSON.stringify(healthyDoctor()), stderr: "" }; + return { exitCode: 0, stdout: JSON.stringify(doctor), stderr: "" }; }); } @@ -340,17 +356,63 @@ describe("Linux CUA diagnostics", () => { expect(run).toHaveBeenCalledTimes(3); }); - it("fails before discovery or execution outside Xorg", async () => { + it("fails before discovery or execution on a non-GNOME Wayland compositor", async () => { const run = vi.fn(); const result = await inspectLinuxCuaDriver({ platform: "linux", env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0", DISPLAY: ":0" }, run, }); - expect(result).toMatchObject({ status: "unavailable", reasonCode: "wayland-unsupported" }); + expect(result).toMatchObject({ + status: "unavailable", + reasonCode: "wayland-compositor-unsupported", + }); expect(run).not.toHaveBeenCalled(); }); + it("does not infer a supported local-control session from DISPLAY alone", async () => { + const run = vi.fn(); + const result = await inspectLinuxCuaDriver({ + platform: "linux", + env: { DISPLAY: ":0" }, + run, + }); + expect(result).toMatchObject({ + status: "unavailable", + reasonCode: "desktop-session-required", + }); + expect(run).not.toHaveBeenCalled(); + }); + + it("certifies GNOME Wayland diagnostics with the native backend explicitly enabled", async () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + const run = successfulRunner(binary, { doctor: healthyWaylandDoctor() }); + const result = await inspectLinuxCuaDriver({ + platform: "linux", + homeDir: root, + env: { + CUA_DRIVER_PATH: binary, + XDG_SESSION_TYPE: "wayland", + XDG_CURRENT_DESKTOP: "ubuntu:GNOME", + WAYLAND_DISPLAY: "wayland-0", + DISPLAY: ":0", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus", + }, + run, + }); + + expect(result).toMatchObject({ + status: "ready", + session: "wayland", + compositor: "gnome-mutter", + commandEnv: { CUA_DRIVER_RS_ENABLE_WAYLAND: "1" }, + }); + for (const call of run.mock.calls) { + expect(call[2].env.CUA_DRIVER_RS_ENABLE_WAYLAND).toBe("1"); + } + }); + it("rejects version and manifest drift", async () => { const root = temporaryDirectory(); const binary = executable(path.join(root, "bin")); diff --git a/server/local-computer.test.ts b/server/local-computer.test.ts index f5119d0f..9e73e74f 100644 --- a/server/local-computer.test.ts +++ b/server/local-computer.test.ts @@ -9,7 +9,7 @@ import { validateLinuxDescriptorRuntime, } from "./local-computer.ts"; -function linuxDescriptor(userData: string) { +function linuxDescriptor(userData: string, { session = "x11" }: { session?: "x11" | "wayland" } = {}) { const binary = join(userData, "cua-driver"); const socket = join(userData, "runtime", "driver.sock"); writeFileSync(binary, "fake", { mode: 0o700 }); @@ -26,9 +26,10 @@ function linuxDescriptor(userData: string) { }; return { schemaVersion: 1, - mode: "linux-x11-supervised", + mode: session === "wayland" ? "linux-wayland-gnome-supervised" : "linux-x11-supervised", platform: "linux", - session: "x11", + session, + ...(session === "wayland" ? { compositor: "gnome-mutter" } : {}), enabled: true, status: "ready", ownerPid: process.pid, @@ -49,6 +50,7 @@ function linuxDescriptor(userData: string) { CUA_DRIVER_EMBEDDED: "1", CUA_DRIVER_HOST_BUNDLE_ID: "com.openmausbot.app", CUA_DRIVER_RS_UPDATE_CHECK: "false", + ...(session === "wayland" ? { CUA_DRIVER_RS_ENABLE_WAYLAND: "1" } : {}), }, }, toolNames: ["click", "get_window_state", "list_apps", "type_text"], @@ -85,6 +87,34 @@ describe("local computer descriptor", () => { }); }); + it("accepts the exact GNOME Wayland descriptor without weakening the X11 contract", () => { + const userData = privateUserData("linux-wayland-user-data"); + const descriptor = linuxDescriptor(userData, { session: "wayland" }); + expect(decodeLinuxDescriptor(descriptor)).toEqual({ + command: descriptor.driver.path, + args: descriptor.mcp.args, + env: descriptor.mcp.env, + platform: "linux", + generation: descriptor.generation, + scope: "local-computer", + }); + expect(decodeLinuxDescriptor({ ...descriptor, compositor: "kde-kwin" })).toBeNull(); + const { CUA_DRIVER_RS_ENABLE_WAYLAND: _missing, ...x11OnlyEnv } = descriptor.mcp.env; + expect( + decodeLinuxDescriptor({ ...descriptor, mcp: { ...descriptor.mcp, env: x11OnlyEnv } }), + ).toBeNull(); + const x11Descriptor = linuxDescriptor(userData); + expect( + decodeLinuxDescriptor({ + ...x11Descriptor, + mcp: { + ...x11Descriptor.mcp, + env: { ...x11Descriptor.mcp.env, CUA_DRIVER_RS_ENABLE_WAYLAND: "1" }, + }, + }), + ).toBeNull(); + }); + it("rejects unknown fields, stale modes, arbitrary argv, and incomplete tool surfaces", () => { const userData = privateUserData("linux-invalid-user-data"); const descriptor = linuxDescriptor(userData); diff --git a/server/local-computer.ts b/server/local-computer.ts index 27258edd..76de5959 100644 --- a/server/local-computer.ts +++ b/server/local-computer.ts @@ -106,26 +106,32 @@ function decodeLegacyDescriptor( export function decodeLinuxDescriptor(value: LinuxConnectionDescriptor): LocalComputerConnection | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const x11 = value.mode === "linux-x11-supervised" && value.session === "x11"; + const wayland = + value.mode === "linux-wayland-gnome-supervised" && + value.session === "wayland" && + value.compositor === "gnome-mutter"; + const descriptorKeys = [ + "schemaVersion", + "mode", + "platform", + "session", + "enabled", + "status", + "ownerPid", + "generation", + "driver", + "daemon", + "mcp", + "toolNames", + "doctorWarnings", + ...(wayland ? ["compositor"] : []), + ]; if ( - !exactKeys(value, [ - "schemaVersion", - "mode", - "platform", - "session", - "enabled", - "status", - "ownerPid", - "generation", - "driver", - "daemon", - "mcp", - "toolNames", - "doctorWarnings", - ]) || + (!x11 && !wayland) || + !exactKeys(value, descriptorKeys) || value.schemaVersion !== 1 || - value.mode !== "linux-x11-supervised" || value.platform !== "linux" || - value.session !== "x11" || value.enabled !== true || value.status !== "ready" || !Number.isInteger(value.ownerPid) || @@ -187,10 +193,12 @@ export function decodeLinuxDescriptor(value: LinuxConnectionDescriptor): LocalCo "CUA_DRIVER_EMBEDDED", "CUA_DRIVER_HOST_BUNDLE_ID", "CUA_DRIVER_RS_UPDATE_CHECK", + ...(wayland ? ["CUA_DRIVER_RS_ENABLE_WAYLAND"] : []), ]) || (mcp.env as Record).CUA_DRIVER_EMBEDDED !== "1" || (mcp.env as Record).CUA_DRIVER_HOST_BUNDLE_ID !== "com.openmausbot.app" || - (mcp.env as Record).CUA_DRIVER_RS_UPDATE_CHECK !== "false" + (mcp.env as Record).CUA_DRIVER_RS_UPDATE_CHECK !== "false" || + (wayland && (mcp.env as Record).CUA_DRIVER_RS_ENABLE_WAYLAND !== "1") ) { return null; } From d4dab74fd8d92567f96c0e03226efccf2731c12d Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 11:13:38 -0300 Subject: [PATCH 16/32] surface GNOME Wayland local control states --- src/components/LinuxLocalControl.tsx | 4 +++- src/lib/local-computer.ts | 4 ++-- src/types/ogb.d.ts | 4 ++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/LinuxLocalControl.tsx b/src/components/LinuxLocalControl.tsx index 6878dc86..693eb409 100644 --- a/src/components/LinuxLocalControl.tsx +++ b/src/components/LinuxLocalControl.tsx @@ -23,6 +23,7 @@ export function LinuxLocalControl() { if (capabilities.host.platform !== "linux") return null; const busy = pending !== null || local.status === "checking" || local.status === "starting"; const ready = local.available; + const wayland = capabilities.host.session === "wayland"; const run = async (action: "enable" | "disable" | "retry") => { if (!window.ogb?.localControl) return; @@ -50,7 +51,7 @@ export function LinuxLocalControl() { Local control
- Beta · Ubuntu 24.04 GNOME/Xorg · Cua Driver 0.19.3 + Beta · Ubuntu 24.04 GNOME/{wayland ? "Wayland" : "Xorg"} · Cua Driver 0.19.3
Enabling lets bots you explicitly assign to This computer{" "} inspect the active desktop and request mouse or keyboard actions. Every local action asks you first. + {wayland && " GNOME may also ask you to allow foreground input for this desktop session."} diff --git a/src/lib/local-computer.ts b/src/lib/local-computer.ts index bfc892af..9f63a957 100644 --- a/src/lib/local-computer.ts +++ b/src/lib/local-computer.ts @@ -22,8 +22,8 @@ export function localComputerDisabledReason({ } if (capabilities.localComputer.available) return null; if (capabilities.host.platform === "linux") { - if (capabilities.localComputer.reasonCode === "wayland-unsupported") { - return "Local control requires a GNOME on Xorg session. Wayland preview remains available."; + if (capabilities.localComputer.reasonCode === "wayland-compositor-unsupported") { + return "Wayland local control is currently limited to GNOME. Xorg remains available on supported desktops."; } if (!capabilities.localComputer.enabled) { return "Enable the local control beta and complete the Cua Driver checks first."; diff --git a/src/types/ogb.d.ts b/src/types/ogb.d.ts index 1b0593e9..c0e32461 100644 --- a/src/types/ogb.d.ts +++ b/src/types/ogb.d.ts @@ -30,6 +30,8 @@ declare global { message?: string; driverPath?: string; driverVersion?: string; + session?: "x11" | "wayland" | "headless" | "unknown"; + compositor?: "gnome-mutter"; }; }; @@ -91,6 +93,8 @@ export interface LinuxLocalControlStatus { message?: string; driverPath?: string; driverVersion?: string; + session?: "x11" | "wayland" | "headless" | "unknown"; + compositor?: "gnome-mutter"; warnings?: Array<{ label: string; status: string; message: string; detail?: string }>; } From 00ba48b560a15d509164df53f67493f0f2a9fe39 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 11:13:44 -0300 Subject: [PATCH 17/32] smoke-test packaged GNOME Wayland control --- scripts/run-linux-package-smoke.mjs | 50 ++++++++++-------- scripts/smoke-linux-package.mjs | 81 +++++++++++++++++++++++------ 2 files changed, 92 insertions(+), 39 deletions(-) diff --git a/scripts/run-linux-package-smoke.mjs b/scripts/run-linux-package-smoke.mjs index 7a6f8ba3..d581ee94 100644 --- a/scripts/run-linux-package-smoke.mjs +++ b/scripts/run-linux-package-smoke.mjs @@ -6,27 +6,33 @@ import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const prefixName = "omb-linux-smoke-runtime-"; -const runtimeDirectory = mkdtempSync(path.join(tmpdir(), prefixName)); +for (const lane of ["x11", "wayland"]) { + const runtimeDirectory = mkdtempSync(path.join(tmpdir(), prefixName)); + if ( + path.dirname(runtimeDirectory) !== path.resolve(tmpdir()) || + !path.basename(runtimeDirectory).startsWith(prefixName) + ) { + throw new Error(`[run-linux-package-smoke] unexpected temporary path: ${runtimeDirectory}`); + } -if ( - path.dirname(runtimeDirectory) !== path.resolve(tmpdir()) || - !path.basename(runtimeDirectory).startsWith(prefixName) -) { - throw new Error(`[run-linux-package-smoke] unexpected temporary path: ${runtimeDirectory}`); -} - -chmodSync(runtimeDirectory, 0o700); -const result = spawnSync( - "dbus-run-session", - ["--", "xvfb-run", "-a", process.execPath, path.join(root, "scripts", "smoke-linux-package.mjs")], - { - cwd: root, - env: { ...process.env, XDG_RUNTIME_DIR: runtimeDirectory }, - stdio: "inherit", - }, -); -if (result.error) throw result.error; -if (result.status !== 0) { - console.error(`[run-linux-package-smoke] isolated runtime kept at ${runtimeDirectory}`); - process.exitCode = result.status ?? 1; + chmodSync(runtimeDirectory, 0o700); + const result = spawnSync( + "dbus-run-session", + ["--", "xvfb-run", "-a", process.execPath, path.join(root, "scripts", "smoke-linux-package.mjs")], + { + cwd: root, + env: { + ...process.env, + XDG_RUNTIME_DIR: runtimeDirectory, + ...(lane === "wayland" ? { OMB_SMOKE_WAYLAND: "1" } : {}), + }, + stdio: "inherit", + }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + console.error(`[run-linux-package-smoke] ${lane} runtime kept at ${runtimeDirectory}`); + process.exitCode = result.status ?? 1; + break; + } } diff --git a/scripts/smoke-linux-package.mjs b/scripts/smoke-linux-package.mjs index f0f6c987..5494645a 100644 --- a/scripts/smoke-linux-package.mjs +++ b/scripts/smoke-linux-package.mjs @@ -13,6 +13,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const wayland = process.env.OMB_SMOKE_WAYLAND === "1"; const executable = path.resolve( process.env.OMB_SMOKE_EXECUTABLE ?? path.join(root, "release", "linux-unpacked", "openmausbot"), ); @@ -50,8 +51,13 @@ const { appendFileSync, chmodSync, existsSync, readFileSync, realpathSync, unlin const net = require("node:net"); const marker = ${JSON.stringify(marker)}; const state = ${JSON.stringify(fakeState)}; +const wayland = ${JSON.stringify(wayland)}; const args = process.argv.slice(2); -appendFileSync(marker, JSON.stringify({ pid: process.pid, args }) + "\\n"); +appendFileSync(marker, JSON.stringify({ + pid: process.pid, + args, + waylandEnabled: process.env.CUA_DRIVER_RS_ENABLE_WAYLAND === "1", +}) + "\\n"); const after = (flag) => { const index = args.indexOf(flag); return index === -1 ? null : args[index + 1]; }; if (args.includes("--version")) { process.stdout.write("cua-driver 0.19.3\\n"); @@ -70,7 +76,9 @@ if (args[0] === "manifest") { if (args[0] === "doctor" && args.includes("--json")) { process.stdout.write(JSON.stringify({ ok: true, probes: [ { label: "binary", status: "ok", message: "cua-driver 0.19.3" }, - { label: "display server", status: "ok", message: "X11 (DISPLAY=:99)" }, + { label: "display server", status: "ok", message: wayland + ? "Wayland+XWayland (WAYLAND_DISPLAY=wayland-smoke, DISPLAY=:99)" + : "X11 (DISPLAY=:99)" }, { label: "X11 connection", status: "warn", message: "no top-level windows in Xvfb" }, { label: "AT-SPI", status: "ok", message: "fixture bus available" }, ] }) + "\\n"); @@ -82,6 +90,7 @@ const pidFile = after("--pid-file"); if (!socketPath || !pidFile || !args.includes("--embedded") || after("--permission-mode") !== "standard") { process.exit(64); } +if ((process.env.CUA_DRIVER_RS_ENABLE_WAYLAND === "1") !== wayland) process.exit(64); const count = existsSync(state) ? Number(readFileSync(state, "utf8")) + 1 : 1; writeFileSync(state, String(count)); writeFileSync(pidFile, String(process.pid), { mode: 0o600 }); @@ -97,6 +106,20 @@ const metadata = { }; const tools = ["click", "get_window_state", "list_apps", "type_text"].map((name) => ({ name })); const toolManifest = { schema_version: "1", capability_version: "1", tools }; +const healthReport = { + schema_version: "1", + platform: "linux", + driver_version: "0.19.3", + overall: "ok", + checks: [ + { name: "binary_version", status: "pass", message: "cua-driver 0.19.3" }, + { name: "platform_supported", status: "pass", message: "Ubuntu 24.04" }, + { name: "session_active", status: "pass", message: "MCP session is active." }, + { name: "ax_capability", status: "pass", message: "AT-SPI fixture is reachable." }, + { name: "screen_capture_capability", status: "pass", message: "Portal fixture is reachable." }, + { name: "wayland_backend", status: "pass", message: "WinRects and portal/libei fixtures are reachable." }, + ], +}; const server = net.createServer((socket) => { let input = ""; socket.on("data", (chunk) => { @@ -104,7 +127,13 @@ const server = net.createServer((socket) => { const newline = input.indexOf("\\n"); if (newline === -1) return; const request = JSON.parse(input.slice(0, newline)); - const result = request.method === "metadata" ? metadata : request.method === "list" ? toolManifest : null; + const result = request.method === "metadata" + ? metadata + : request.method === "list" + ? toolManifest + : request.method === "call" && request.name === "health_report" && wayland + ? { structuredContent: healthReport } + : null; socket.end(JSON.stringify(result ? { ok: true, result } : { ok: false, error: "unknown" }) + "\\n"); if (count === 1 && request.method === "list") setTimeout(() => server.close(() => process.exit(17)), 5000); }); @@ -120,20 +149,26 @@ process.on("SIGTERM", shutdown); ); chmodSync(sentinel, 0o755); +const desktopEnv = { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: xdgConfig, + XDG_RUNTIME_DIR: xdgRuntime, + XDG_SESSION_TYPE: wayland ? "wayland" : "x11", + XDG_CURRENT_DESKTOP: "GNOME", + CUA_DRIVER_PATH: sentinel, + OMB_SMOKE_TEST: "1", + OMB_SMOKE_CUA: "1", +}; +if (wayland) desktopEnv.WAYLAND_DISPLAY = "wayland-smoke"; +else delete desktopEnv.WAYLAND_DISPLAY; + let output = ""; let smokeResult = null; -const child = spawn(executable, [], { +const child = spawn(executable, wayland ? ["--ozone-platform=x11"] : [], { cwd: root, detached: true, - env: { - ...process.env, - HOME: home, - XDG_CONFIG_HOME: xdgConfig, - XDG_RUNTIME_DIR: xdgRuntime, - CUA_DRIVER_PATH: sentinel, - OMB_SMOKE_TEST: "1", - OMB_SMOKE_CUA: "1", - }, + env: desktopEnv, stdio: ["ignore", "pipe", "pipe"], }); @@ -197,13 +232,22 @@ try { } if (!String(title).includes("OpenMausBot")) throw new Error(`unexpected renderer title: ${title}`); if (capabilities.host.platform !== "linux") throw new Error("renderer did not report Linux"); - if (capabilities.host.session !== "x11") throw new Error("Xvfb did not report an X11 session"); - if (!capabilities.screenPreview.available || capabilities.screenPreview.interaction !== "direct") { - throw new Error("X11 screen preview capability was not available"); + if (capabilities.host.session !== (wayland ? "wayland" : "x11")) { + throw new Error(`renderer did not report the ${wayland ? "Wayland" : "X11"} contract`); + } + const expectedPreview = wayland ? "portal-picker" : "direct"; + if (!capabilities.screenPreview.available || capabilities.screenPreview.interaction !== expectedPreview) { + throw new Error(`${wayland ? "Wayland" : "X11"} screen preview capability was not available`); } if (capabilities.dictation.available) throw new Error("dictation must be unavailable on Linux"); if (!initialCapabilities.localComputer.available) throw new Error("initial Linux CUA runtime was not ready"); if (initialCapabilities.localComputer.support !== "limited") throw new Error("Linux CUA was not marked beta/limited"); + if (wayland && ( + initialCapabilities.localComputer.session !== "wayland" || + initialCapabilities.localComputer.compositor !== "gnome-mutter" + )) { + throw new Error("initial Linux CUA runtime did not publish the guarded GNOME Wayland contract"); + } if (cuaCrashReason !== "daemon-exited") throw new Error("daemon crash did not invalidate local control"); if (cuaRetryStatus?.status !== "ready" || !capabilities.localComputer.available) { throw new Error("explicit CUA retry did not create a ready generation"); @@ -221,6 +265,9 @@ try { for (const expected of ["--version", "manifest", "doctor --json"]) { if (!commands.some((command) => command === expected)) throw new Error(`missing CUA probe: ${expected}`); } + if (invocations.some((entry) => entry.waylandEnabled !== wayland)) { + throw new Error("CUA Wayland opt-in escaped its certified smoke lane"); + } const daemons = invocations.filter((entry) => entry.args[0] === "serve"); if (daemons.length !== 2) throw new Error(`expected crash + retry daemon generations, found ${daemons.length}`); for (const daemon of daemons) { @@ -232,7 +279,7 @@ try { } } - console.log("[smoke-linux-package] OK: renderer, private CUA crash/retry, harness, and shutdown"); + console.log(`[smoke-linux-package] OK (${wayland ? "GNOME/Wayland" : "GNOME/X11"}): renderer, private CUA crash/retry, harness, and shutdown`); } finally { await stopProcess(); if (process.env.OMB_KEEP_SMOKE_DIR !== "1") rmSync(sandbox, { recursive: true, force: true }); From ea632df25a1de5971728fcb89365b2e6d1b616c3 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 11:13:50 -0300 Subject: [PATCH 18/32] document the guarded Wayland control beta --- CONTRIBUTING.md | 3 +- README.md | 17 +++---- docs/computer-use-integration.md | 6 ++- docs/linux-desktop.md | 78 +++++++++++++++++++++++--------- 4 files changed, 71 insertions(+), 33 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00682317..3eeee17c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -106,7 +106,8 @@ The SPI in [`server/contracts.ts`](server/contracts.ts) is deliberately small. A - Linux local control must remain explicit: global opt-in plus per-bot **This computer**. Linux Auto, provider full-auto/bypass modes, remembered grants, and cloud approvals must never authorize the user's desktop. - Keep user-installed CUA discovery shell-free and pin accepted manifest/driver contracts. Do not add a bundled - binary, automatic installer/update, Wayland mutation, or default-daemon ownership to the Xorg beta. + binary, automatic installer/update, or default-daemon ownership to the Linux beta. GNOME/Wayland readiness must + require its exact compositor/helper/portal health contract; never infer it from `WAYLAND_DISPLAY` or XWayland. - **Never build command strings for a shell.** No `shell: true`, no spawning through `cmd.exe` with quoted strings — model names, personas, and MCP config JSON travel through argv, and cmd.exe metacharacter expansion is a real injection class. On Windows, resolve `.cmd` shims to their JS diff --git a/README.md b/README.md index d6f6da82..e6bbaf8e 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ already have: - **Local first.** One small harness server on `127.0.0.1` owns every agent process. Transcripts, keys, and events live in `~/.openmausbot`, not a cloud. - **Agents with hands.** Each bot can use a cloud Linux desktop, an isolated Local VM, or your own computer, - plus 500+ apps through Composio Connect. Host control is available on macOS and as an explicit Ubuntu Xorg beta. + plus 500+ apps through Composio Connect. Host control is available on macOS and as an explicit Ubuntu GNOME beta. ## Features @@ -172,7 +172,7 @@ flowchart LR | API | `server/index.ts` | Bots, turns, approvals, model catalog, computer lifecycle, connectors, config — HTTP + SSE. | | Voice | `server/tts/` | ElevenLabs, bring your own key. Runs on the harness so the key never reaches the UI; markdown is rewritten into something worth hearing before it is spoken. | | App | `src/` | The chat shell. Server-backed store, one reducer, zero client-side transports. | -| Desktop | `electron/` | macOS, Windows, and Ubuntu shells with an embedded harness and platform capabilities; Apple speech stays macOS-only, while user-installed CUA can enable the Ubuntu Xorg local-control beta. | +| Desktop | `electron/` | macOS, Windows, and Ubuntu shells with an embedded harness and platform capabilities; Apple speech stays macOS-only, while user-installed CUA can enable guarded Ubuntu GNOME local control. | ## Quick start @@ -217,15 +217,16 @@ pnpm package:linux # Ubuntu x64: .deb + AppImage; no Swift required | Packaged app, embedded harness, local agent CLIs | Supported | Beta | Beta | | Composio and Box/cloud computers | Supported | Beta | Beta | | Explicit preview-only local screen capture | Supported | Beta | Beta | -| Bot control of this computer | Supported | Beta: opt-in, Cua 0.19.3 | Planned after compositor validation | +| Bot control of this computer | Supported | Beta: opt-in, Cua 0.19.3 | Beta: GNOME only, opt-in, Cua 0.19.3 + WinRects v8 | | Native on-device dictation | Supported | Planned | Planned | -The Linux preview is user-initiated and never enables local bot control or Auto routing. The Xorg control beta -requires a separately installed Cua Driver 0.19.3, explicit app opt-in, and an explicit per-bot **This computer** -selection; every local action asks for approval. Wayland control stays disabled. Unavailable native features fail -closed without blocking chat or cloud features. See the [Ubuntu Desktop guide](docs/linux-desktop.md) and +The Linux preview is user-initiated and never enables local bot control or Auto routing. Linux control requires a +separately installed Cua Driver 0.19.3, explicit app opt-in, and an explicit per-bot **This computer** selection; +every local action asks for approval. GNOME/Wayland additionally requires the versioned WinRects v8 helper and a +passing prompt-free AT-SPI/capture/portal health report. Other Wayland compositors fail closed without blocking +chat or cloud features. See the [Ubuntu Desktop guide](docs/linux-desktop.md) and tracking issues [#29](https://github.com/milind-soni/OpenMausBot/issues/29) and -[#79](https://github.com/milind-soni/OpenMausBot/issues/79). +[#79](https://github.com/milind-soni/OpenMausBot/issues/79) / [#109](https://github.com/milind-soni/OpenMausBot/issues/109). These credentials are optional — local chat works without them. Paste a key once in **App Settings** (gear in the sidebar footer) when you want to enable its integration: diff --git a/docs/computer-use-integration.md b/docs/computer-use-integration.md index 98d09765..6ad211a1 100644 --- a/docs/computer-use-integration.md +++ b/docs/computer-use-integration.md @@ -38,12 +38,14 @@ No cliclick, no robotjs/nut.js, no Python computer-server, no fallbacks.** Everything that touches the user's screen/mouse/keyboard goes through the bundled `cua-driver` binary. Alternatives evaluated and rejected: -The Ubuntu GNOME/Xorg beta is an intentional staged exception to the +The Ubuntu GNOME beta is an intentional staged exception to the zero-install packaging statement: it uses the same official CUA provider but requires user-installed Cua Driver 0.19.3 while supply-chain bundling remains Phase 5 of [#29](https://github.com/milind-soni/OpenMausBot/issues/29). Electron still owns a private embedded daemon/socket, and the harness only receives the -validated MCP proxy contract. See [#79](https://github.com/milind-soni/OpenMausBot/issues/79). +validated MCP proxy contract. Xorg is tracked in [#79](https://github.com/milind-soni/OpenMausBot/issues/79); +GNOME/Wayland additionally requires WinRects v8 plus the exact Cua health-report contract tracked in +[#109](https://github.com/milind-soni/OpenMausBot/issues/109). | Option | Verdict | | --- | --- | diff --git a/docs/linux-desktop.md b/docs/linux-desktop.md index f507b7ff..1baf4265 100644 --- a/docs/linux-desktop.md +++ b/docs/linux-desktop.md @@ -12,13 +12,14 @@ installed builds do not require Node, pnpm, Swift, or a terminal at runtime. - External documentation and OAuth links in the default browser. - An explicit, view-only local screen preview on GNOME Xorg and GNOME Wayland. The Wayland path uses the native portal chooser and keeps the selected PipeWire stream open until the user stops sharing. -- An explicit local-computer control beta on GNOME/Xorg with user-installed Cua Driver 0.19.3 and an - approval-capable Claude or ACP provider. +- An explicit local-computer control beta on GNOME/Xorg and guarded GNOME/Wayland with user-installed Cua + Driver 0.19.3 and an approval-capable Claude or ACP provider. The local preview does **not** give the bot control of this computer by itself. Local control is a separate, -off-by-default Xorg beta. Wayland control, bundled CUA, Linux dictation, and ARM64 remain unavailable and fail -closed; follow their progress in [issue #29](https://github.com/milind-soni/OpenMausBot/issues/29). The Xorg beta -is tracked in [issue #79](https://github.com/milind-soni/OpenMausBot/issues/79). +off-by-default beta. Bundled CUA, Linux dictation, and ARM64 remain unavailable and fail closed; follow their +progress in [issue #29](https://github.com/milind-soni/OpenMausBot/issues/29). Xorg is tracked in +[issue #79](https://github.com/milind-soni/OpenMausBot/issues/79), and guarded GNOME/Wayland support in +[issue #109](https://github.com/milind-soni/OpenMausBot/issues/109). ## Build packages @@ -125,15 +126,16 @@ Cancelling or ending Wayland sharing returns to a calm **Try again** state and n automatically. OpenMausBot does not capture screen audio, remember the selected monitor after restart, or offer an **Open Settings** action on Linux. -Local computer control is available only on Xorg after the separate opt-in below. Wayland support remains -disabled until the exact GNOME/Mutter action surface has real capture, input, scaling, permission, and lifecycle -evidence. +Local computer control is a separate opt-in. On Wayland, OpenMausBot recognizes only GNOME/Mutter and requires +the certified Cua health report to pass AT-SPI, portal capture, and the portal/libei input backend with verified +WinRects target activation. Other Wayland compositors remain unavailable. XWayland's `DISPLAY` never bypasses +these checks. -## Enable local control on Xorg +## Enable local control This beta deliberately uses a user-installed driver. OpenMausBot does not bundle, download, update, or stop a global Cua daemon. The certified contract is **Cua Driver 0.19.3**, manifest schema `1`, on Ubuntu 24.04 x64 -GNOME/Xorg. +GNOME/Xorg or GNOME/Wayland. OpenMausBot owns a separate private daemon only after the user enables the beta. Install Cua Driver from its [official installation guide](https://cua.ai/docs/how-to-guides/driver/install): @@ -159,7 +161,24 @@ chmod go-w "$HOME/.local/bin" "$HOME/.cua-driver" "$HOME/.cua-driver/packages" \ "$HOME/.cua-driver/packages/releases" "$(dirname "$driver_path")" ``` -Sign into a GNOME on Xorg session. Then: +For GNOME/Wayland, install the versioned helper shipped with that same verified Cua release: + +```sh +~/.cua-driver/packages/current/wayland-helper/install.sh +``` + +Sign out and back in once, then verify that GNOME loaded exactly the expected helper: + +```sh +gnome-extensions info winrects@cua +``` + +The output must include `Version: 8`, `Enabled: Yes`, and `State: ACTIVE`. OpenMausBot never installs or enables +this GNOME extension silently. The helper exposes window identity, geometry, capture, cursor, and verified target +activation to Cua; foreground pointer or keyboard delivery remains scoped by GNOME's Remote Desktop portal and +may ask for session consent. + +Then: 1. Open a bot's **Computer** panel. 2. In **Local control**, choose **Enable local control (Beta)** and review the warning. @@ -168,10 +187,12 @@ Sign into a GNOME on Xorg session. Then: Linux **Auto** never falls back to the user's desktop. **This computer** is available only when the current provider advertises an interactive approval channel. Claude `bypassPermissions`, ACP full-auto, Codex's current -app-server adapter, Wayland/headless sessions, missing diagnostics, and stale/crashed runtimes fail closed. +app-server adapter, non-GNOME/headless sessions, missing diagnostics, and stale/crashed runtimes fail closed. OpenMausBot starts one private embedded daemon with a private socket for its own app generation. It never touches -Cua's default/global daemon. Disabling local control or quitting stops the owned daemon and active proxies. +Cua's default/global daemon. On GNOME/Wayland, the app also rechecks the prompt-free health contract while the +runtime is active and revokes readiness if the helper or backend disappears. Disabling local control or quitting +stops the owned daemon and active proxies. The driver uses Cua's `standard` permission mode. Cua routine actions are promptless at the driver layer, while OpenMausBot requires its own **Allow** or **Deny** decision before every local action. Bot Auto mode, persistent @@ -195,9 +216,10 @@ pnpm smoke:linux-package The verifier checks `.deb` metadata, desktop identity, resources, artifact permissions, and that no Cua executable was bundled. The smoke test launches the unpacked production app without `--no-sandbox`, validates the renderer/preload and embedded health endpoint, then uses a fake user-installed driver to prove diagnostics, -private-daemon readiness, crash invalidation, explicit retry, and clean shutdown. Its wrapper isolates the -temporary D-Bus/AT-SPI runtime so it cannot replace the live desktop session's accessibility socket. It is not a -substitute for real GNOME Xorg action evidence or real GNOME Wayland portal evidence. +private-daemon readiness, crash invalidation, explicit retry, and clean shutdown in separate Xorg and simulated +GNOME/Wayland contract lanes. The Wayland lane also requires the opt-in environment and certified health report. +Its wrapper isolates the temporary D-Bus/AT-SPI runtime so it cannot replace the live desktop session's +accessibility socket. It is not a substitute for real GNOME Xorg or GNOME Wayland action evidence. ## Troubleshooting @@ -209,24 +231,36 @@ considered for automatic discovery. ### A bot needs computer tools -On Wayland, choose **Cloud box** and add a Box token in App Settings. On Xorg, either use a cloud box or complete -the local-control opt-in above. A missing or unsupported provider keeps **This computer** disabled with an -explanation. +Choose **Cloud box** and add a Box token in App Settings, or complete the local-control opt-in above on a supported +GNOME session. A missing driver/helper, unsupported compositor or provider keeps **This computer** disabled with +an explanation. ### Local control is not ready -Run the certified probes in a terminal launched inside the same GNOME/Xorg session: +Run the certified probes in a terminal launched inside the same GNOME session: ```sh -echo "$XDG_SESSION_TYPE" # must be x11 +echo "$XDG_SESSION_TYPE" # x11 or wayland cua-driver --version # must be 0.19.3 for this beta cua-driver doctor --json ``` +On Wayland, also run: + +```sh +echo "$XDG_CURRENT_DESKTOP" # must include GNOME +gnome-extensions info winrects@cua +CUA_DRIVER_RS_ENABLE_WAYLAND=1 cua-driver doctor --json +``` + +If the helper is installed but not `ACTIVE`, sign out and back in once. If the app reports a portal error, confirm +that `xdg-desktop-portal` and `xdg-desktop-portal-gnome` are running in the user session. OpenMausBot's readiness +probe never opens a consent prompt; GNOME may prompt when the first approved foreground input action starts. + Repair any display, session bus, or AT-SPI diagnostic before choosing **Try again**. If the path shown in the app is unexpected, close OpenMausBot and launch it with an absolute `CUA_DRIVER_PATH`. An invalid explicit override fails without silently selecting another executable. For `unsafe-driver-permissions`, use the bounded -permission-hardening commands in **Enable local control on Xorg**; do not make the driver executable or its +permission-hardening commands in **Enable local control**; do not make the driver executable or its directories world-writable. ### Screen preview does not start From 162b9fbf41ee2290b51e5ba42e2a314d6f77a499 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 11:32:02 -0300 Subject: [PATCH 19/32] scope Linux filesystem tests to Linux --- electron/cua-linux.test.mjs | 10 +++++++--- server/local-computer.test.ts | 5 ++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/electron/cua-linux.test.mjs b/electron/cua-linux.test.mjs index 60d62af9..dc52b2cd 100644 --- a/electron/cua-linux.test.mjs +++ b/electron/cua-linux.test.mjs @@ -88,7 +88,11 @@ afterEach(() => { } }); -describe("Linux CUA discovery", () => { +// These cases intentionally exercise real Linux ownership, mode bits, +// executable detection, canonical paths, and PATH semantics. macOS rewrites +// /var through /private/var, while Windows does not expose POSIX executable +// bits; Ubuntu CI is the authoritative host for this filesystem contract. +describe.skipIf(process.platform !== "linux")("Linux CUA discovery", () => { it("rejects an invalid explicit override without falling through", () => { const root = temporaryDirectory(); const fallback = executable(path.join(root, "bin")); @@ -324,7 +328,7 @@ describe("Linux private primary group proof", () => { }); }); -describe("Linux CUA diagnostics", () => { +describe.skipIf(process.platform !== "linux")("Linux CUA diagnostics", () => { it("passes only the minimal desktop environment and returns a certified contract", async () => { const root = temporaryDirectory(); const binary = executable(path.join(root, "bin")); @@ -478,7 +482,7 @@ describe("bounded command execution", () => { }); }); -describe("minimal child environment", () => { +describe.skipIf(process.platform !== "linux")("minimal child environment", () => { it("keeps desktop session values but drops application secrets", () => { expect( desktopCommandEnvironment({ diff --git a/server/local-computer.test.ts b/server/local-computer.test.ts index 9e73e74f..f3a849cc 100644 --- a/server/local-computer.test.ts +++ b/server/local-computer.test.ts @@ -146,7 +146,10 @@ describe("local computer descriptor", () => { ).toBeNull(); }); - it.skipIf(process.platform === "win32")( + // Runtime ownership includes a real Linux Unix-domain socket and POSIX + // permission checks. Keep the schema/decoder cases above cross-platform, + // but run this host-filesystem proof only on the authoritative Linux lane. + it.skipIf(process.platform !== "linux")( "validates private descriptor, executable, socket, and live owned processes", async () => { const userData = privateUserData("linux-runtime-security"); From 05c87883e5981f7250df650f3739e19820cf1f99 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 11:33:50 -0300 Subject: [PATCH 20/32] scope Linux runtime lifecycle tests to Linux --- electron/cua-linux-runtime.test.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/electron/cua-linux-runtime.test.mjs b/electron/cua-linux-runtime.test.mjs index 9ce19eb3..bb70bd57 100644 --- a/electron/cua-linux-runtime.test.mjs +++ b/electron/cua-linux-runtime.test.mjs @@ -186,7 +186,11 @@ afterEach(() => { } }); -describe("Linux CUA opt-in and lifecycle", () => { +// Lifecycle readiness deliberately includes Linux executable identity, +// private-directory permissions, and the Linux runtime's Unix socket layout. +// Keep the pure state/handshake suites below cross-platform, but exercise the +// real host-filesystem contract only on the authoritative Ubuntu CI lane. +describe.skipIf(process.platform !== "linux")("Linux CUA opt-in and lifecycle", () => { it("does not inspect or execute a driver before explicit opt-in", async () => { const context = harness(); await context.runtime.initialize(); From b3513b9baa36231ca2a1cbd893192d83ec9e217b Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 11:36:56 -0300 Subject: [PATCH 21/32] keep Linux private-state tests platform-correct --- electron/cua-connection.test.mjs | 8 ++++++-- electron/cua-linux-runtime.test.mjs | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/electron/cua-connection.test.mjs b/electron/cua-connection.test.mjs index fb292ef7..81365dd8 100644 --- a/electron/cua-connection.test.mjs +++ b/electron/cua-connection.test.mjs @@ -39,8 +39,12 @@ describe("CUA connection persistence", () => { expect( fs.existsSync(path.join(userData, "cua-connection.json.123.test.tmp")), ).toBe(false); - expect(fs.statSync(path.join(userData, "cua-connection.json")).mode & 0o777).toBe(0o600); - expect(fs.statSync(userData).mode & 0o777).toBe(0o700); + // Keep replacement/rollback coverage on Windows, where Node accepts + // chmod but NTFS does not expose the requested POSIX mode bits. + if (process.platform !== "win32") { + expect(fs.statSync(path.join(userData, "cua-connection.json")).mode & 0o777).toBe(0o600); + expect(fs.statSync(userData).mode & 0o777).toBe(0o700); + } } finally { rmSync(userData, { recursive: true, force: true }); } diff --git a/electron/cua-linux-runtime.test.mjs b/electron/cua-linux-runtime.test.mjs index bb70bd57..56dbdba9 100644 --- a/electron/cua-linux-runtime.test.mjs +++ b/electron/cua-linux-runtime.test.mjs @@ -364,7 +364,9 @@ describe.skipIf(process.platform !== "linux")("Linux CUA opt-in and lifecycle", }); }); -describe("Linux CUA private data", () => { +// Directory fsync and POSIX mode/symlink guarantees are part of the Linux +// runtime contract; Windows intentionally rejects directory fsync. +describe.skipIf(process.platform !== "linux")("Linux CUA private data", () => { it("uses strict preference schema and private atomic files", () => { const userData = temporaryDirectory(); const store = createLinuxCuaPreferenceStore({ getUserData: () => userData }); From f427c4b9549ca78a91a048881197e5effb84d818 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 11:50:28 -0300 Subject: [PATCH 22/32] address Ubuntu desktop review feedback --- docs/computer-use-integration.md | 25 +++++++----- docs/linux-desktop.md | 33 +++++++++++++-- electron/cua-linux-runtime.cjs | 47 ++++++++++++++++----- electron/cua-linux-runtime.test.mjs | 36 ++++++++++++---- electron/cua-linux.cjs | 6 +-- electron/cua-linux.test.mjs | 15 ++++--- electron/cua.mjs | 18 ++++++-- electron/main.mjs | 14 ++++++- electron/screen-preview.cjs | 7 +++- electron/screen-preview.test.mjs | 8 ++++ electron/updater.mjs | 9 ++-- package.json | 2 +- scripts/run-linux-package-smoke.mjs | 2 +- server/index.test.ts | 9 ++++ server/index.ts | 5 ++- server/local-computer.test.ts | 27 ++++++++++-- server/local-computer.ts | 2 + src/components/LocalScreenPreview.tsx | 23 ++++++++--- src/lib/desktop.test.ts | 59 +++++++++++++++++++++++++++ src/lib/desktop.ts | 12 +++++- src/state/store.tsx | 9 ++-- 21 files changed, 295 insertions(+), 73 deletions(-) create mode 100644 src/lib/desktop.test.ts diff --git a/docs/computer-use-integration.md b/docs/computer-use-integration.md index 6ad211a1..6a631e86 100644 --- a/docs/computer-use-integration.md +++ b/docs/computer-use-integration.md @@ -1,16 +1,18 @@ # Computer use & browser use in OpenMausBot Decision doc, 2026-08-12. How bots in OpenMausBot get local computer use and -browser use, out of the box, with no separate installs. Based on a survey of -OSS chat-app MCP hosts, macOS control servers, browser-automation stacks, and -the local `cua` / `axstream` code on this machine. +browser use. macOS targets an out-of-the-box bundled provider; the staged +Ubuntu beta currently verifies a separately installed, pinned provider. Based +on a survey of OSS chat-app MCP hosts, macOS control servers, +browser-automation stacks, and the local `cua` / `axstream` code on this +machine. ## TL;DR architecture ``` Electron main process -├── EmbeddedCuaDriverHost ──spawns──▶ cua-driver (bundled Rust binary, Resources/) -│ one TCC prompt, named OpenMausBot │ unix socket (private) +├── CUA host ──spawns──▶ cua-driver (bundled on macOS; verified user install on Ubuntu) +│ platform permission boundary │ unix socket (private) ├── WebContentsView pool (embedded browser, persist: partitions per bot) │ driven via webContents.debugger (CDP) — zero-install browser use └── server/ harness (drivers spawn agent CLIs with --mcp-config) @@ -21,8 +23,9 @@ Electron main process - **Plugins = MCP servers over stdio.** The Plugins panel toggles which MCP servers get injected into each bot's `--mcp-config`. Same pattern as Claude Desktop / Cherry Studio / LibreChat. -- **Computer use = bundled `cua-driver`** (Rust, single static Mach-O, - 23MB arm64 / 48MB universal — from `mywork/cua/libs/cua-driver/rust`). +- **Computer use = `cua-driver`**. macOS packages the Rust Mach-O in app + Resources; the Ubuntu beta accepts only the certified user-installed 0.19.3 + Linux binary while bundling is tracked separately. NOT Swift — the Swift file everyone remembers (`examples/embedded-host-macos/ExampleAgentHarness.swift`) is a 165-line reference host showing the embedding pattern, not the driver. @@ -31,12 +34,12 @@ Electron main process `webContents.debugger` CDP transport. No Chrome dependency, no 281MB Playwright download, and the user watches the bot browse inside the chat. -## Computer use: CUA only — bundle cua-driver, spawn from Electron main +## Computer use: CUA only — Electron owns the driver lifecycle **Decision (Milind, 2026-08-12): CUA is the ONLY computer-use provider. No cliclick, no robotjs/nut.js, no Python computer-server, no fallbacks.** Everything that touches the user's screen/mouse/keyboard goes through the -bundled `cua-driver` binary. Alternatives evaluated and rejected: +validated `cua-driver` binary. Alternatives evaluated and rejected: The Ubuntu GNOME beta is an intentional staged exception to the zero-install packaging statement: it uses the same official CUA provider but @@ -51,7 +54,7 @@ GNOME/Wayland additionally requires WinRects v8 plus the exact Cua health-report | --- | --- | | cua `computer-server` (Python/FastAPI) | ✗ 200MB+ frozen Python, second TCC prompt under wrong identity | | axstream / cliclick / robotjs-class | ✗ rejected — CUA-only policy | -| **cua-driver binary, embedded mode** | ✓ THE provider: zero deps, 20+ tools, its own stdio MCP proxy + socket daemon + TS SDK (`@trycua/cua-driver`), agent-cursor overlay, permission tooling | +| **cua-driver binary, embedded mode** | ✓ THE provider: one contract, 20+ tools, its own stdio MCP proxy + socket daemon + TS SDK (`@trycua/cua-driver`), agent-cursor overlay, permission tooling | ### The rules (from `cua/libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md` — read it end to end) @@ -73,7 +76,7 @@ GNOME/Wayland additionally requires WinRects v8 plus the exact Cua health-report grant change, destroy clients → `restart()` → reconnect (macOS caches TCC per process). -### Packaging +### macOS packaging target - Ship the binary at `OpenMausBot.app/Contents/Resources/cua-driver`, **outside the ASAR**, executable bit preserved (electron-builder diff --git a/docs/linux-desktop.md b/docs/linux-desktop.md index 1baf4265..0cb0154d 100644 --- a/docs/linux-desktop.md +++ b/docs/linux-desktop.md @@ -137,15 +137,42 @@ This beta deliberately uses a user-installed driver. OpenMausBot does not bundle global Cua daemon. The certified contract is **Cua Driver 0.19.3**, manifest schema `1`, on Ubuntu 24.04 x64 GNOME/Xorg or GNOME/Wayland. OpenMausBot owns a separate private daemon only after the user enables the beta. -Install Cua Driver from its [official installation guide](https://cua.ai/docs/how-to-guides/driver/install): +Install the exact x86_64 asset from the +[official 0.19.3 release](https://github.com/trycua/cua/releases/tag/cua-driver-rs-v0.19.3). +The upstream release does not currently provide a signature or GitHub artifact attestation for this asset, so +OpenMausBot pins its published SHA-256 here and verifies it before extraction. Do not pipe a remote installer +directly into a shell: ```sh -CUA_DRIVER_RS_VERSION=0.19.3 /bin/bash -c "$(curl -fsSL https://cua.ai/driver/install.sh)" +version="0.19.3" +target="x86_64-unknown-linux-gnu" +asset="cua-driver-rs-${version}-linux-x86_64-binary.tar.gz" +release_url="https://github.com/trycua/cua/releases/download/cua-driver-rs-v${version}" +download_dir="$(mktemp -d)" + +curl --fail --location --proto '=https' --tlsv1.2 \ + --output "$download_dir/$asset" "$release_url/$asset" +printf '%s %s\n' \ + '3db9d4257d84bacaf7eb104d225f85613ce67edbb20d6eeb83c1384b6d8a5b10' \ + "$download_dir/$asset" | sha256sum --check --strict + +release_dir="$HOME/.cua-driver/packages/releases/${version}-${target}" +test ! -e "$release_dir" || { echo "Already exists: $release_dir" >&2; exit 1; } +install -d -m 700 "$HOME/.local/bin" "$HOME/.cua-driver/packages/releases" "$release_dir" +tar --extract --gzip --no-same-owner --no-same-permissions \ + --file "$download_dir/$asset" --directory "$release_dir" +chmod 700 "$release_dir/cua-driver" "$release_dir/wayland-helper/install.sh" +ln -sfn "$release_dir" "$HOME/.cua-driver/packages/current" +ln -sfn "$release_dir/cua-driver" "$HOME/.local/bin/cua-driver" +printf 'Verified download directory: %s\n' "$download_dir" + cua-driver --version cua-driver manifest --pretty cua-driver doctor --json ``` +The verified archive remains in the printed temporary directory and may be removed after the checks complete. + Confirm the version is `0.19.3`. OpenMausBot also rejects an executable or containing directory that another local user could replace. Ubuntu's normal user-private group layout is accepted after OpenMausBot verifies that the group belongs only to your account. On a shared or centrally managed group, the app may ask you to remove @@ -164,7 +191,7 @@ chmod go-w "$HOME/.local/bin" "$HOME/.cua-driver" "$HOME/.cua-driver/packages" \ For GNOME/Wayland, install the versioned helper shipped with that same verified Cua release: ```sh -~/.cua-driver/packages/current/wayland-helper/install.sh +~/.cua-driver/packages/releases/0.19.3-x86_64-unknown-linux-gnu/wayland-helper/install.sh ``` Sign out and back in once, then verify that GNOME loaded exactly the expected helper: diff --git a/electron/cua-linux-runtime.cjs b/electron/cua-linux-runtime.cjs index b548248f..6bdbcc0f 100644 --- a/electron/cua-linux-runtime.cjs +++ b/electron/cua-linux-runtime.cjs @@ -62,7 +62,12 @@ function writePrivateJson(file, value, { fileSystem.chmodSync(file, 0o600); const directoryHandle = fileSystem.openSync(directory, "r"); try { - fileSystem.fsyncSync(directoryHandle); + try { + fileSystem.fsyncSync(directoryHandle); + } catch { + // The file itself is already flushed and atomically renamed. Some + // otherwise-supported filesystems reject fsync on a directory. + } } finally { fileSystem.closeSync(directoryHandle); } @@ -420,9 +425,7 @@ function createLinuxCuaRuntime({ }); const runtimeRoot = () => { - const userData = getUserData(); const configured = env.XDG_RUNTIME_DIR; - let base = userData; if (configured && path.isAbsolute(configured)) { try { const stat = fs.lstatSync(configured); @@ -433,11 +436,15 @@ function createLinuxCuaRuntime({ stat.uid === currentUid && (stat.mode & 0o077) === 0 ) { - base = configured; + return ensurePrivateDirectory(path.join(configured, "openmausbot-cua")); } } catch {} } - return ensurePrivateDirectory(path.join(base, "openmausbot-cua")); + // Unix sockets have a short path limit. A private, uid-scoped directory + // directly under the system temp root keeps the fallback deterministic + // and short when XDG_RUNTIME_DIR is missing or unsafe. + const currentUid = process.getuid?.() ?? os.userInfo().uid; + return ensurePrivateDirectory(path.join(os.tmpdir(), `openmausbot-cua-${currentUid}`)); }; const cleanupRuntimeFiles = (owned) => { @@ -450,7 +457,8 @@ function createLinuxCuaRuntime({ try { fs.unlinkSync(file); } catch (error) { - if (error?.code !== "ENOENT") break; + // A failure for one owned path must not prevent cleanup of the other. + if (error?.code !== "ENOENT") continue; } } try { @@ -515,7 +523,16 @@ function createLinuxCuaRuntime({ startPromise = (async () => { unavailable("checking", "checking-driver", "Checking Cua Driver and the desktop session…"); - const inspected = await inspect({ platform, env }); + let inspected; + try { + inspected = await inspect({ platform, env }); + } catch (error) { + return unavailable( + "error", + error?.code ?? "driver-inspection-failed", + "Cua Driver could not be inspected. Check the installation and try again.", + ); + } if (inspected.status !== "ready") { return unavailable("error", inspected.reasonCode, inspected.message, { ...(inspected.path @@ -534,6 +551,7 @@ function createLinuxCuaRuntime({ const socketPath = path.join(runtimeDirectory, "driver.sock"); const pidFile = path.join(runtimeDirectory, "driver.pid"); if (Buffer.byteLength(socketPath) > 100) { + cleanupRuntimeFiles({ runtimeDirectory, socketPath, pidFile }); return unavailable( "error", "socket-path-too-long", @@ -670,9 +688,18 @@ function createLinuxCuaRuntime({ { generation, driver: { path: inspected.path, version: inspected.driverVersion } }, ); } - })().finally(() => { - startPromise = null; - }); + })() + .catch((error) => { + if (!enabled || quitting) return connection; + return unavailable( + "error", + error?.code ?? "runtime-start-failed", + "The private Cua Driver runtime could not start. Check the installation and try again.", + ); + }) + .finally(() => { + startPromise = null; + }); return startPromise; }; diff --git a/electron/cua-linux-runtime.test.mjs b/electron/cua-linux-runtime.test.mjs index 56dbdba9..0002710a 100644 --- a/electron/cua-linux-runtime.test.mjs +++ b/electron/cua-linux-runtime.test.mjs @@ -21,7 +21,8 @@ const { const temporaryDirectories = []; function temporaryDirectory() { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "openmausbot-cua-runtime-")); + const base = process.platform === "win32" ? os.tmpdir() : fs.realpathSync(os.tmpdir()); + const directory = fs.mkdtempSync(path.join(base, "omb-cua-runtime-")); temporaryDirectories.push(directory); return directory; } @@ -186,11 +187,10 @@ afterEach(() => { } }); -// Lifecycle readiness deliberately includes Linux executable identity, -// private-directory permissions, and the Linux runtime's Unix socket layout. -// Keep the pure state/handshake suites below cross-platform, but exercise the -// real host-filesystem contract only on the authoritative Ubuntu CI lane. -describe.skipIf(process.platform !== "linux")("Linux CUA opt-in and lifecycle", () => { +// Windows does not provide the POSIX executable and Unix-socket semantics this +// lifecycle contract exercises. Canonical short temp paths keep it portable +// across Linux and macOS. +describe.skipIf(process.platform === "win32")("Linux CUA opt-in and lifecycle", () => { it("does not inspect or execute a driver before explicit opt-in", async () => { const context = harness(); await context.runtime.initialize(); @@ -203,6 +203,25 @@ describe.skipIf(process.platform !== "linux")("Linux CUA opt-in and lifecycle", }); }); + it("publishes a retryable error when driver inspection throws", async () => { + const inspectError = Object.assign(new Error("probe failed"), { + code: "driver-inspection-failed", + }); + const context = harness({ + runtimeOptions: { inspect: vi.fn(async () => Promise.reject(inspectError)) }, + }); + + await expect(context.runtime.enable()).resolves.toMatchObject({ + status: "error", + reasonCode: "driver-inspection-failed", + }); + expect(context.runtime.getStatus()).toMatchObject({ + status: "error", + reasonCode: "driver-inspection-failed", + }); + expect(context.spawnProcess).not.toHaveBeenCalled(); + }); + it("coalesces starts, verifies a private daemon, and publishes a strict ready descriptor", async () => { const context = harness(); const [first, second] = await Promise.all([context.runtime.enable(), context.runtime.enable()]); @@ -364,9 +383,8 @@ describe.skipIf(process.platform !== "linux")("Linux CUA opt-in and lifecycle", }); }); -// Directory fsync and POSIX mode/symlink guarantees are part of the Linux -// runtime contract; Windows intentionally rejects directory fsync. -describe.skipIf(process.platform !== "linux")("Linux CUA private data", () => { +// POSIX modes and symlink guarantees are available on Linux and macOS. +describe.skipIf(process.platform === "win32")("Linux CUA private data", () => { it("uses strict preference schema and private atomic files", () => { const userData = temporaryDirectory(); const store = createLinuxCuaPreferenceStore({ getUserData: () => userData }); diff --git a/electron/cua-linux.cjs b/electron/cua-linux.cjs index 84538d30..f4ca978b 100644 --- a/electron/cua-linux.cjs +++ b/electron/cua-linux.cjs @@ -10,6 +10,8 @@ const DEFAULT_MAX_OUTPUT_BYTES = 512 * 1024; const GETENT_BINARY = "/usr/bin/getent"; const GETENT_TIMEOUT_MS = 1_500; const GETENT_MAX_OUTPUT_BYTES = 256 * 1024; +// Keep this exact field set synchronized with DRIVER_FILE_IDENTITY_KEYS in +// server/local-computer.ts; Electron publishes it and the server revalidates it. const DRIVER_FILE_IDENTITY_KEYS = Object.freeze([ "dev", "ino", @@ -142,14 +144,12 @@ function runCuaCommand(binary, args, { ); return; } - resolve({ + finish(resolve, { exitCode, signal, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8"), }); - settled = true; - clearTimeout(timer); }); const timer = setTimeout(() => { diff --git a/electron/cua-linux.test.mjs b/electron/cua-linux.test.mjs index dc52b2cd..65b0143e 100644 --- a/electron/cua-linux.test.mjs +++ b/electron/cua-linux.test.mjs @@ -20,7 +20,8 @@ const { const temporaryDirectories = []; function temporaryDirectory() { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "openmausbot-cua-linux-")); + const base = process.platform === "win32" ? os.tmpdir() : fs.realpathSync(os.tmpdir()); + const directory = fs.mkdtempSync(path.join(base, "omb-cua-linux-")); temporaryDirectories.push(directory); return directory; } @@ -88,11 +89,9 @@ afterEach(() => { } }); -// These cases intentionally exercise real Linux ownership, mode bits, -// executable detection, canonical paths, and PATH semantics. macOS rewrites -// /var through /private/var, while Windows does not expose POSIX executable -// bits; Ubuntu CI is the authoritative host for this filesystem contract. -describe.skipIf(process.platform !== "linux")("Linux CUA discovery", () => { +// Windows does not expose the POSIX executable and ownership semantics used by +// discovery. Canonical temp paths make the same contract useful on macOS. +describe.skipIf(process.platform === "win32")("Linux CUA discovery", () => { it("rejects an invalid explicit override without falling through", () => { const root = temporaryDirectory(); const fallback = executable(path.join(root, "bin")); @@ -328,7 +327,7 @@ describe("Linux private primary group proof", () => { }); }); -describe.skipIf(process.platform !== "linux")("Linux CUA diagnostics", () => { +describe.skipIf(process.platform === "win32")("Linux CUA diagnostics", () => { it("passes only the minimal desktop environment and returns a certified contract", async () => { const root = temporaryDirectory(); const binary = executable(path.join(root, "bin")); @@ -482,7 +481,7 @@ describe("bounded command execution", () => { }); }); -describe.skipIf(process.platform !== "linux")("minimal child environment", () => { +describe.skipIf(process.platform === "win32")("minimal child environment", () => { it("keeps desktop session values but drops application secrets", () => { expect( desktopCommandEnvironment({ diff --git a/electron/cua.mjs b/electron/cua.mjs index 477c9f55..174ab560 100644 --- a/electron/cua.mjs +++ b/electron/cua.mjs @@ -210,21 +210,33 @@ export function registerCuaIpc() { if (process.platform !== "linux") { return { enabled: false, status: "unavailable", reasonCode: "unsupported-platform" }; } - await ensureLinuxRuntime().enable(); + try { + await ensureLinuxRuntime().enable(); + } catch (error) { + console.error("[cua] Linux enable failed:", error); + } return ensureLinuxRuntime().getStatus(); }); ipcMain.handle("cua:linux-disable", async () => { if (process.platform !== "linux") { return { enabled: false, status: "unavailable", reasonCode: "unsupported-platform" }; } - await ensureLinuxRuntime().disable(); + try { + await ensureLinuxRuntime().disable(); + } catch (error) { + console.error("[cua] Linux disable failed:", error); + } return ensureLinuxRuntime().getStatus(); }); ipcMain.handle("cua:linux-retry", async () => { if (process.platform !== "linux") { return { enabled: false, status: "unavailable", reasonCode: "unsupported-platform" }; } - await ensureLinuxRuntime().retry(); + try { + await ensureLinuxRuntime().retry(); + } catch (error) { + console.error("[cua] Linux retry failed:", error); + } return ensureLinuxRuntime().getStatus(); }); } diff --git a/electron/main.mjs b/electron/main.mjs index 6e196ce5..86d2ed71 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -359,7 +359,9 @@ async function broadcastDesktopCapabilities() { setCuaStateListener((connection) => { cuaReady = Promise.resolve(connection); - void broadcastDesktopCapabilities(); + void broadcastDesktopCapabilities().catch((error) => { + console.error("[desktop] capability broadcast failed:", error); + }); }); app.whenReady().then(async () => { @@ -400,9 +402,17 @@ app.whenReady().then(async () => { ? screen.getPrimaryDisplay().id : null, }); + if (!source) { + console.warn( + `[screen-preview] rejected ${captureHost} source set (${sources.length} candidates)`, + ); + } respondToDisplayMediaRequest(callback, source ? { video: source } : {}); }) - .catch(() => respondToDisplayMediaRequest(callback, {})); + .catch((error) => { + console.warn("[screen-preview] source discovery failed:", error); + respondToDisplayMediaRequest(callback, {}); + }); }, { useSystemPicker: false }, ); diff --git a/electron/screen-preview.cjs b/electron/screen-preview.cjs index f9cb8858..109ec408 100644 --- a/electron/screen-preview.cjs +++ b/electron/screen-preview.cjs @@ -52,7 +52,12 @@ function selectCaptureSource({ sources, host, primaryDisplayId }) { if (!Array.isArray(sources) || sources.length === 0) return null; if (host === "wayland") return sources.length === 1 ? sources[0] : null; if (host === "x11") { - return sources.find((source) => String(source.display_id) === String(primaryDisplayId)) ?? null; + const exact = sources.find( + (source) => String(source.display_id) === String(primaryDisplayId), + ); + // Some X11 backends omit or misreport display_id. A single enumerated + // source is still unambiguous; never guess when multiple sources remain. + return exact ?? (sources.length === 1 ? sources[0] : null); } if (host === "darwin") return sources[0]; return null; diff --git a/electron/screen-preview.test.mjs b/electron/screen-preview.test.mjs index 748c4ce7..8f2cde66 100644 --- a/electron/screen-preview.test.mjs +++ b/electron/screen-preview.test.mjs @@ -62,6 +62,14 @@ describe("display source selection", () => { expect(selectCaptureSource({ sources, host: "x11", primaryDisplayId: 99 })).toBeNull(); }); + it("uses an unambiguous Xorg source when display_id is absent or mismatched", () => { + const onlySource = { id: "only", display_id: "" }; + expect( + selectCaptureSource({ sources: [onlySource], host: "x11", primaryDisplayId: 42 }), + ).toEqual(onlySource); + expect(selectCaptureSource({ sources: [], host: "x11", primaryDisplayId: 42 })).toBeNull(); + }); + it("accepts only the single portal-selected Wayland source", () => { expect( selectCaptureSource({ sources: [sources[0]], host: "wayland", primaryDisplayId: 42 }), diff --git a/electron/updater.mjs b/electron/updater.mjs index a1910cb8..cb9f0d88 100644 --- a/electron/updater.mjs +++ b/electron/updater.mjs @@ -35,18 +35,19 @@ function setState(patch) { function check(manual = false) { if (!autoUpdater) return; userInitiated = manual; + const initiatedManually = manual; try { // electron-updater reports feed/network failures by both emitting `error` // and rejecting this promise. Handle the rejection as well so a missing // platform feed never becomes an unhandled rejection in the packaged app. - void autoUpdater.checkForUpdates().catch(reportError); + void autoUpdater.checkForUpdates().catch((error) => reportError(error, initiatedManually)); } catch (e) { - reportError(e); + reportError(e, initiatedManually); } } -function reportError(e) { - if (!userInitiated) return setState({ status: "idle" }); +function reportError(e, initiatedManually = userInitiated) { + if (!initiatedManually) return setState({ status: "idle" }); setState({ status: "error", message: String(e?.message ?? e) }); } diff --git a/package.json b/package.json index 24805790..2ec82810 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "test:watch": "vitest", "test:cua": "pnpm build:cua && node scripts/smoke-cua.mjs", "test:cua-container": "node scripts/smoke-cua-container.mjs", - "check:electron": "node --check electron/main.mjs && node --check electron/terminal-launch.mjs && node --check electron/preload.cjs && node --check electron/capabilities.cjs && node --check electron/cua-connection.cjs && node --check electron/cua-linux.cjs && node --check electron/cua-linux-runtime.cjs && node --check electron/cua.mjs && node --check electron/screen-preview.cjs && node --check electron/speech.mjs", + "check:electron": "node --check electron/main.mjs && node --check electron/terminal-launch.mjs && node --check electron/preload.cjs && node --check electron/capabilities.cjs && node --check electron/cua-connection.cjs && node --check electron/cua-linux.cjs && node --check electron/cua-linux-runtime.cjs && node --check electron/cua.mjs && node --check electron/screen-preview.cjs && node --check electron/speech.mjs && node --check electron/updater.mjs", "preview": "vite preview", "build:server": "tsc -p tsconfig.server.build.json", "build:speech": "node electron/build-speech-helper.mjs", diff --git a/scripts/run-linux-package-smoke.mjs b/scripts/run-linux-package-smoke.mjs index d581ee94..89dee2b8 100644 --- a/scripts/run-linux-package-smoke.mjs +++ b/scripts/run-linux-package-smoke.mjs @@ -24,7 +24,7 @@ for (const lane of ["x11", "wayland"]) { env: { ...process.env, XDG_RUNTIME_DIR: runtimeDirectory, - ...(lane === "wayland" ? { OMB_SMOKE_WAYLAND: "1" } : {}), + OMB_SMOKE_WAYLAND: lane === "wayland" ? "1" : "0", }, stdio: "inherit", }, diff --git a/server/index.test.ts b/server/index.test.ts index 9830d413..27f49389 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -193,6 +193,15 @@ describe("harness HTTP API", () => { const rejected = await api("PATCH", `/api/bots/${bot.id}`, { autoApprove: true }); expect(rejected.status).toBe(400); expect(rejected.body.error).toContain("local computer beta"); + + const cloud = await api("PATCH", `/api/bots/${bot.id}`, { computer: "cloud" }); + expect(cloud.body.bot.computer).toBe("cloud"); + const simultaneous = await api("PATCH", `/api/bots/${bot.id}`, { + computer: "local", + autoApprove: true, + }); + expect(simultaneous.status).toBe(400); + expect(simultaneous.body.error).toContain("local computer beta"); await api("DELETE", `/api/bots/${bot.id}`); }); diff --git a/server/index.ts b/server/index.ts index c4bdef62..74915143 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1248,6 +1248,7 @@ const server = createServer(async (req, res) => { ) { return json(res, 400, { error: "computer must be cloud, vm, local, or off" }); } + const effectiveComputer = body.computer ?? existingBot?.computer; if (body.chiefOfStaff !== undefined && typeof body.chiefOfStaff !== "boolean") { return json(res, 400, { error: "chiefOfStaff must be true or false" }); } @@ -1260,7 +1261,7 @@ const server = createServer(async (req, res) => { // still answer .includes() — with substring matches, not tool names if (body.autoApprove !== undefined) { if (typeof body.autoApprove !== "boolean") return json(res, 400, { error: "autoApprove must be true or false" }); - if (body.autoApprove === true && existingBot?.computer === "local") { + if (body.autoApprove === true && effectiveComputer === "local") { return json(res, 400, { error: "Auto mode is unavailable while this bot uses the local computer beta" }); } patch.autoApprove = body.autoApprove; @@ -1271,7 +1272,7 @@ const server = createServer(async (req, res) => { } patch.alwaysAllow = [...new Set(body.alwaysAllow as string[])].slice(0, 200); } - if (body.computer === "local" && existingBot?.autoApprove) { + if (effectiveComputer === "local" && body.autoApprove === undefined && existingBot?.autoApprove) { patch.autoApprove = false; } if (existingBot?.computer === "local" && body.computer !== undefined && body.computer !== "local") { diff --git a/server/local-computer.test.ts b/server/local-computer.test.ts index f3a849cc..604be343 100644 --- a/server/local-computer.test.ts +++ b/server/local-computer.test.ts @@ -1,7 +1,17 @@ -import { appendFileSync, chmodSync, mkdirSync, statSync, writeFileSync } from "node:fs"; +import { + appendFileSync, + chmodSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { createServer } from "node:net"; +import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { decodeLinuxDescriptor, @@ -58,13 +68,24 @@ function linuxDescriptor(userData: string, { session = "x11" }: { session?: "x11 }; } +const temporaryDirectories: string[] = []; + function privateUserData(name: string) { - const userData = join(process.env.HOME!, name); + const base = process.platform === "win32" ? tmpdir() : realpathSync(tmpdir()); + const root = mkdtempSync(join(base, "omb-local-computer-")); + temporaryDirectories.push(root); + const userData = join(root, name); mkdirSync(userData, { recursive: true, mode: 0o700 }); chmodSync(userData, 0o700); return userData; } +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + describe("local computer descriptor", () => { it("accepts only the exact certified Linux X11 descriptor", () => { const userData = privateUserData("linux-user-data"); diff --git a/server/local-computer.ts b/server/local-computer.ts index 76de5959..30c0414a 100644 --- a/server/local-computer.ts +++ b/server/local-computer.ts @@ -4,6 +4,8 @@ import { homedir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; const REQUIRED_LINUX_TOOLS = ["click", "get_window_state", "list_apps", "type_text"]; +// Keep this exact field set synchronized with DRIVER_FILE_IDENTITY_KEYS in +// electron/cua-linux.cjs; Electron publishes it and the server revalidates it. const DRIVER_FILE_IDENTITY_KEYS = [ "dev", "ino", diff --git a/src/components/LocalScreenPreview.tsx b/src/components/LocalScreenPreview.tsx index 01078988..00dcf6c3 100644 --- a/src/components/LocalScreenPreview.tsx +++ b/src/components/LocalScreenPreview.tsx @@ -92,12 +92,25 @@ export function LocalScreenPreview() { }, { once: true }, ); - if (videoRef.current) { - videoRef.current.srcObject = stream; - void videoRef.current.play().catch(() => {}); + const video = videoRef.current; + if (!video) { + releaseStream("error", "Couldn't display screen preview."); + return; + } + video.srcObject = stream; + try { + await video.play(); + } catch { + if (currentRequest === requestId.current && streamRef.current === stream) { + releaseStream("error", "Couldn't display screen preview."); + } + return; } + if (currentRequest !== requestId.current || streamRef.current !== stream) return; setPhase("streaming"); - setMessage("Preview active. The bot still cannot control this computer."); + setMessage( + "Preview active. Previewing does not grant local control; local actions still require approval.", + ); }; if (!isLinux) return null; @@ -112,7 +125,7 @@ export function LocalScreenPreview() { Preview this computer
- Preview only — the bot cannot control this computer. + Preview only — starting a preview does not grant local control.
diff --git a/src/lib/desktop.test.ts b/src/lib/desktop.test.ts new file mode 100644 index 00000000..d08fc45c --- /dev/null +++ b/src/lib/desktop.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +function capabilities(status: "checking" | "ready"): DesktopCapabilities { + return { + host: { + platform: "linux", + label: "Ubuntu", + session: "x11", + packaged: true, + }, + windowChrome: "native", + screenPreview: { + available: true, + interaction: "direct", + }, + dictation: { + available: false, + engine: "none", + onDevice: false, + reasonCode: "unsupported-platform", + }, + localComputer: { + available: status === "ready", + support: "limited", + enabled: true, + status, + reasonCode: status === "checking" ? "checking-driver" : undefined, + }, + }; +} + +afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); +}); + +describe("desktop capability cache", () => { + it("does not let an older initial query replace a newer IPC update", async () => { + let resolveInitial!: (value: DesktopCapabilities) => void; + const initial = new Promise((resolve) => { + resolveInitial = resolve; + }); + vi.stubGlobal("window", { + ogb: { + platform: "linux", + getCapabilities: () => initial, + }, + }); + const desktop = await import("./desktop"); + const pending = desktop.loadDesktopCapabilities(); + const ready = capabilities("ready"); + + desktop.cacheDesktopCapabilities(ready); + resolveInitial(capabilities("checking")); + + await expect(pending).resolves.toBe(ready); + await expect(desktop.loadDesktopCapabilities()).resolves.toBe(ready); + }); +}); diff --git a/src/lib/desktop.ts b/src/lib/desktop.ts index db8ed949..2fa07c0c 100644 --- a/src/lib/desktop.ts +++ b/src/lib/desktop.ts @@ -27,6 +27,7 @@ const browserCapabilities: DesktopCapabilities = { }; let cached: DesktopCapabilities | null = null; +let cacheRevision = 0; export function browserDesktopCapabilities(): DesktopCapabilities { return browserCapabilities; @@ -56,15 +57,22 @@ export function initialDesktopCapabilities(): DesktopCapabilities { export async function loadDesktopCapabilities(): Promise { if (cached) return cached; if (!window.ogb?.getCapabilities) return browserCapabilities; + const revisionAtStart = cacheRevision; + let loaded: DesktopCapabilities; try { - cached = await window.ogb.getCapabilities(); + loaded = await window.ogb.getCapabilities(); } catch { - cached = browserCapabilities; + loaded = browserCapabilities; } + // An IPC push may deliver newer runtime readiness while the initial query is + // still pending. Never let that older response replace the pushed state. + if (cacheRevision !== revisionAtStart && cached) return cached; + cached = loaded; return cached; } export function cacheDesktopCapabilities(capabilities: DesktopCapabilities): DesktopCapabilities { + cacheRevision += 1; cached = capabilities; return capabilities; } diff --git a/src/state/store.tsx b/src/state/store.tsx index 74572cd9..3f373e9d 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -603,11 +603,10 @@ function reducer(state: AppState, action: Action): AppState { ), } : animated; - return updateBot(next, action.botId, (b) => ({ - ...b, - ...action.patch, - ...(action.patch.computer === "local" ? { autoApprove: false } : {}), - })); + return updateBot(next, action.botId, (b) => { + const merged = { ...b, ...action.patch }; + return merged.computer === "local" ? { ...merged, autoApprove: false } : merged; + }); } case "threadActive": { const bot = state.bots.find((b) => b.threadId === action.threadId); From 507b1855b28aa35128d77559d7be9ee14ed3b64c Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 11:55:58 -0300 Subject: [PATCH 23/32] keep POSIX socket fixtures short --- electron/cua-linux-runtime.test.mjs | 2 +- electron/cua-linux.test.mjs | 2 +- server/local-computer.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/electron/cua-linux-runtime.test.mjs b/electron/cua-linux-runtime.test.mjs index 0002710a..ef64f9ef 100644 --- a/electron/cua-linux-runtime.test.mjs +++ b/electron/cua-linux-runtime.test.mjs @@ -21,7 +21,7 @@ const { const temporaryDirectories = []; function temporaryDirectory() { - const base = process.platform === "win32" ? os.tmpdir() : fs.realpathSync(os.tmpdir()); + const base = process.platform === "win32" ? os.tmpdir() : fs.realpathSync("/tmp"); const directory = fs.mkdtempSync(path.join(base, "omb-cua-runtime-")); temporaryDirectories.push(directory); return directory; diff --git a/electron/cua-linux.test.mjs b/electron/cua-linux.test.mjs index 65b0143e..9e066e7d 100644 --- a/electron/cua-linux.test.mjs +++ b/electron/cua-linux.test.mjs @@ -20,7 +20,7 @@ const { const temporaryDirectories = []; function temporaryDirectory() { - const base = process.platform === "win32" ? os.tmpdir() : fs.realpathSync(os.tmpdir()); + const base = process.platform === "win32" ? os.tmpdir() : fs.realpathSync("/tmp"); const directory = fs.mkdtempSync(path.join(base, "omb-cua-linux-")); temporaryDirectories.push(directory); return directory; diff --git a/server/local-computer.test.ts b/server/local-computer.test.ts index 604be343..75aac294 100644 --- a/server/local-computer.test.ts +++ b/server/local-computer.test.ts @@ -71,7 +71,7 @@ function linuxDescriptor(userData: string, { session = "x11" }: { session?: "x11 const temporaryDirectories: string[] = []; function privateUserData(name: string) { - const base = process.platform === "win32" ? tmpdir() : realpathSync(tmpdir()); + const base = process.platform === "win32" ? tmpdir() : realpathSync("/tmp"); const root = mkdtempSync(join(base, "omb-local-computer-")); temporaryDirectories.push(root); const userData = join(root, name); From 26a16783b2bd3fe038a81da35f44a33dc527541c Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 11:58:58 -0300 Subject: [PATCH 24/32] scope Linux group permission proofs --- electron/cua-linux.test.mjs | 151 +++++++++++++++++++----------------- 1 file changed, 80 insertions(+), 71 deletions(-) diff --git a/electron/cua-linux.test.mjs b/electron/cua-linux.test.mjs index 9e066e7d..a80fd0b8 100644 --- a/electron/cua-linux.test.mjs +++ b/electron/cua-linux.test.mjs @@ -135,77 +135,86 @@ describe.skipIf(process.platform === "win32")("Linux CUA discovery", () => { }); }); - it("accepts the official 0775 layout only for a proven user-private primary group", () => { - const root = temporaryDirectory(); - const releaseDirectory = path.join(root, ".cua-driver", "packages", "releases", "0.19.3"); - const release = executable(releaseDirectory); - const localBin = path.join(root, ".local", "bin"); - fs.mkdirSync(localBin, { recursive: true, mode: 0o775 }); - fs.symlinkSync(release, path.join(localBin, "cua-driver")); - for (const component of [ - path.join(root, ".local"), - localBin, - path.join(root, ".cua-driver"), - path.join(root, ".cua-driver", "packages"), - path.join(root, ".cua-driver", "packages", "releases"), - releaseDirectory, - release, - ]) { - fs.chmodSync(component, 0o775); - } - const identity = os.userInfo(); - const lookupPrivateGroup = vi.fn(() => ({ - exclusive: true, - gid: identity.gid, - name: identity.username, - })); - - expect( - discoverLinuxCuaDriver({ - env: { PATH: "" }, - homeDir: root, - currentUid: identity.uid, - currentGid: identity.gid, - currentUsername: identity.username, - lookupPrivateGroup, - }), - ).toMatchObject({ status: "found", path: release, source: "user-local" }); - expect(lookupPrivateGroup).toHaveBeenCalledTimes(1); - }); - - it("rejects a group-writable executable when the group is shared or unverifiable", () => { - const root = temporaryDirectory(); - const binary = executable(path.join(root, "bin")); - fs.chmodSync(binary, 0o720); - expect( - validateDriverCandidate(binary, { - lookupPrivateGroup: () => ({ exclusive: false, reason: "primary-group-shared" }), - }), - ).toMatchObject({ - status: "unavailable", - reasonCode: "unsafe-driver-permissions", - affectedPaths: [binary], - permissionReason: "primary-group-shared", - }); - }); - - it("contains a failed group lookup and keeps the exact affected path", () => { - const root = temporaryDirectory(); - const binary = executable(path.join(root, "bin")); - fs.chmodSync(binary, 0o720); - expect( - validateDriverCandidate(binary, { - lookupPrivateGroup: () => { - throw new Error("NSS unavailable"); - }, - }), - ).toMatchObject({ - status: "unavailable", - reasonCode: "unsafe-driver-permissions", - affectedPaths: [binary], - permissionReason: "lookup-failed", - }); - }); + it.skipIf(process.platform !== "linux")( + "accepts the official 0775 layout only for a proven user-private primary group", + () => { + const root = temporaryDirectory(); + const releaseDirectory = path.join(root, ".cua-driver", "packages", "releases", "0.19.3"); + const release = executable(releaseDirectory); + const localBin = path.join(root, ".local", "bin"); + fs.mkdirSync(localBin, { recursive: true, mode: 0o775 }); + fs.symlinkSync(release, path.join(localBin, "cua-driver")); + for (const component of [ + path.join(root, ".local"), + localBin, + path.join(root, ".cua-driver"), + path.join(root, ".cua-driver", "packages"), + path.join(root, ".cua-driver", "packages", "releases"), + releaseDirectory, + release, + ]) { + fs.chmodSync(component, 0o775); + } + const identity = os.userInfo(); + const lookupPrivateGroup = vi.fn(() => ({ + exclusive: true, + gid: identity.gid, + name: identity.username, + })); + + expect( + discoverLinuxCuaDriver({ + env: { PATH: "" }, + homeDir: root, + currentUid: identity.uid, + currentGid: identity.gid, + currentUsername: identity.username, + lookupPrivateGroup, + }), + ).toMatchObject({ status: "found", path: release, source: "user-local" }); + expect(lookupPrivateGroup).toHaveBeenCalledTimes(1); + }, + ); + + it.skipIf(process.platform !== "linux")( + "rejects a group-writable executable when the group is shared or unverifiable", + () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + fs.chmodSync(binary, 0o720); + expect( + validateDriverCandidate(binary, { + lookupPrivateGroup: () => ({ exclusive: false, reason: "primary-group-shared" }), + }), + ).toMatchObject({ + status: "unavailable", + reasonCode: "unsafe-driver-permissions", + affectedPaths: [binary], + permissionReason: "primary-group-shared", + }); + }, + ); + + it.skipIf(process.platform !== "linux")( + "contains a failed group lookup and keeps the exact affected path", + () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + fs.chmodSync(binary, 0o720); + expect( + validateDriverCandidate(binary, { + lookupPrivateGroup: () => { + throw new Error("NSS unavailable"); + }, + }), + ).toMatchObject({ + status: "unavailable", + reasonCode: "unsafe-driver-permissions", + affectedPaths: [binary], + permissionReason: "lookup-failed", + }); + }, + ); it("always rejects world-writable paths and reports the exact component", () => { const root = temporaryDirectory(); From f87c3cbe9143db7eaa897b8939942f6732f100fe Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 12:04:55 -0300 Subject: [PATCH 25/32] guard capability state against stale loads --- src/components/DesktopCapabilities.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/DesktopCapabilities.tsx b/src/components/DesktopCapabilities.tsx index 42622f2b..4e049efa 100644 --- a/src/components/DesktopCapabilities.tsx +++ b/src/components/DesktopCapabilities.tsx @@ -19,11 +19,16 @@ export function DesktopCapabilitiesProvider({ children }: { children: ReactNode useEffect(() => { let alive = true; + let eventRevision = 0; const unsubscribe = window.ogb?.onCapabilitiesChanged?.((capabilities) => { + eventRevision += 1; if (alive) setState({ capabilities: cacheDesktopCapabilities(capabilities), ready: true }); }); + const initialRevision = eventRevision; void loadDesktopCapabilities().then((capabilities) => { - if (alive) setState({ capabilities, ready: true }); + if (alive && eventRevision === initialRevision) { + setState({ capabilities, ready: true }); + } }); return () => { alive = false; From 510d9d54be61f38e4892e61c4760637d873f34da Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 13:47:09 -0300 Subject: [PATCH 26/32] prove Linux native feature gates --- electron/capabilities.cjs | 17 +++++++++++++++- electron/capabilities.test.mjs | 21 +++++++++++++++++++- electron/main.mjs | 17 ++++++++-------- src/components/ComputerPanel.tsx | 9 +++++++-- src/lib/local-computer.test.ts | 33 +++++++++++++++++++++++++++++++- src/lib/local-computer.ts | 14 ++++++++++++++ 6 files changed, 98 insertions(+), 13 deletions(-) diff --git a/electron/capabilities.cjs b/electron/capabilities.cjs index 5ecc3625..0294821b 100644 --- a/electron/capabilities.cjs +++ b/electron/capabilities.cjs @@ -8,6 +8,15 @@ function normalizedPlatform(platform) { return DESKTOP_PLATFORMS.has(platform) ? platform : "other"; } +function nativeDesktopActions(platform) { + const appleNative = normalizedPlatform(platform) === "darwin"; + return Object.freeze({ + appleMediaPermissions: appleNative, + applePrivacySettings: appleNative, + appleSpeech: appleNative, + }); +} + function linuxSession(platform, env) { if (platform !== "linux") return "unknown"; const declared = String(env.XDG_SESSION_TYPE ?? "").toLowerCase(); @@ -119,4 +128,10 @@ function connectionEnabled(platform, connection) { return platform === "linux" && connection?.enabled === true; } -module.exports = { connectionEnabled, desktopCapabilities, linuxSession, localComputerReady }; +module.exports = { + connectionEnabled, + desktopCapabilities, + linuxSession, + localComputerReady, + nativeDesktopActions, +}; diff --git a/electron/capabilities.test.mjs b/electron/capabilities.test.mjs index 60248bc4..5218388b 100644 --- a/electron/capabilities.test.mjs +++ b/electron/capabilities.test.mjs @@ -2,9 +2,28 @@ import { createRequire } from "node:module"; import { describe, expect, it } from "vitest"; const require = createRequire(import.meta.url); -const { desktopCapabilities, linuxSession, localComputerReady } = require("./capabilities.cjs"); +const { + desktopCapabilities, + linuxSession, + localComputerReady, + nativeDesktopActions, +} = require("./capabilities.cjs"); describe("desktop capabilities", () => { + it("keeps Apple permissions, Settings, and speech actions unreachable on Linux", () => { + expect(nativeDesktopActions("linux")).toEqual({ + appleMediaPermissions: false, + applePrivacySettings: false, + appleSpeech: false, + }); + expect(nativeDesktopActions("win32")).toEqual(nativeDesktopActions("linux")); + expect(nativeDesktopActions("darwin")).toEqual({ + appleMediaPermissions: true, + applePrivacySettings: true, + appleSpeech: true, + }); + }); + it("keeps macOS native features behind a ready CUA connection", () => { const capabilities = desktopCapabilities({ platform: "darwin", diff --git a/electron/main.mjs b/electron/main.mjs index 86d2ed71..ea745dbf 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -9,7 +9,8 @@ import { openBlankTerminal } from "./terminal-launch.mjs"; import { startUpdater, registerUpdaterIpc } from "./updater.mjs"; import capabilitiesModule from "./capabilities.cjs"; -const { desktopCapabilities } = capabilitiesModule; +const { desktopCapabilities, nativeDesktopActions } = capabilitiesModule; +const nativeActions = nativeDesktopActions(process.platform); const require = createRequire(import.meta.url); const { createDisplayMediaGuard, invokeDisplayMediaCallback, selectCaptureSource } = require( "./screen-preview.cjs", @@ -292,12 +293,12 @@ ipcMain.handle("engine:open-terminal", async (_event, command) => { ipcMain.handle("perm:status", () => ({ mic: - process.platform === "darwin" + nativeActions.appleMediaPermissions ? systemPreferences.getMediaAccessStatus?.("microphone") ?? "unknown" : "unsupported", })); ipcMain.handle("perm:request-mic", async () => { - if (process.platform !== "darwin") return false; + if (!nativeActions.appleMediaPermissions) return false; try { return await systemPreferences.askForMediaAccess("microphone"); } catch { @@ -308,7 +309,7 @@ ipcMain.handle("perm:request-mic", async () => { // macOS never re-prompts a denied permission — the only path is System // Settings; deep-link straight to the right privacy pane. ipcMain.handle("perm:open-settings", (_event, pane) => { - if (process.platform !== "darwin") return false; + if (!nativeActions.applePrivacySettings) return false; const panes = { mic: "Privacy_Microphone", screen: "Privacy_ScreenCapture", @@ -323,17 +324,17 @@ ipcMain.handle("perm:open-settings", (_event, pane) => { ipcMain.handle("speech:start", (event, options) => { const win = BrowserWindow.fromWebContents(event.sender); if (!win) return; - if (process.platform !== "darwin") { + if (!nativeActions.appleSpeech) { win.webContents.send("speech:end", { code: 2, reason: "unsupported-platform" }); return; } startSpeech(win, options); }); ipcMain.handle("speech:stop", () => { - if (process.platform === "darwin") stopSpeech(); + if (nativeActions.appleSpeech) stopSpeech(); }); ipcMain.handle("speech:finish", () => { - if (process.platform === "darwin") finishSpeech(); + if (nativeActions.appleSpeech) finishSpeech(); }); ipcMain.handle("desktop:capabilities", async () => @@ -456,7 +457,7 @@ app.on("before-quit", (e) => { } catch {} // a live dictation session runs its own helper child that holds the mic — // stop it here so quitting never orphans a recording process - stopSpeech(); + if (nativeActions.appleSpeech) stopSpeech(); const cleanup = Promise.race([ stopCua().catch(() => {}), new Promise((resolve) => setTimeout(resolve, CUA_STOP_TIMEOUT_MS).unref()), diff --git a/src/components/ComputerPanel.tsx b/src/components/ComputerPanel.tsx index 31b831a0..b6ba5e75 100644 --- a/src/components/ComputerPanel.tsx +++ b/src/components/ComputerPanel.tsx @@ -26,6 +26,7 @@ import { RoutineEditor } from "./RoutinesPage"; import { LocalScreenPreview } from "./LocalScreenPreview"; import { LinuxLocalControl } from "./LinuxLocalControl"; import { + autoSelectsLocalComputer, instanceSupportsLocalComputer, linuxAutoDescription, localComputerDisabledReason, @@ -183,8 +184,12 @@ export function ComputerPanel({ bot }: { bot: Bot }) { api(`/api/bots/${bot.id}/computer`) .then((status) => { if (!alive) return; - const autoLocal = - !isLinux && bot.computer !== "cloud" && capabilitiesReady && localSelectable; + const autoLocal = autoSelectsLocalComputer({ + platform: capabilities.host.platform, + computer: bot.computer, + capabilitiesReady, + localSelectable, + }); if (!status.configured) { setPhase(autoLocal ? "local" : "unconfigured"); return; diff --git a/src/lib/local-computer.test.ts b/src/lib/local-computer.test.ts index 0d50baf2..5b32a976 100644 --- a/src/lib/local-computer.test.ts +++ b/src/lib/local-computer.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { instanceSupportsLocalComputer, linuxAutoDescription } from "./local-computer"; +import { + autoSelectsLocalComputer, + instanceSupportsLocalComputer, + linuxAutoDescription, +} from "./local-computer"; describe("local computer UI eligibility", () => { it("requires the selected instance to advertise approval-capable local MCP", () => { @@ -18,5 +22,32 @@ describe("local computer UI eligibility", () => { it("states that Linux Auto never selects this computer", () => { expect(linuxAutoDescription()).toContain("otherwise computer use stays off"); + expect( + autoSelectsLocalComputer({ + platform: "linux", + computer: undefined, + capabilitiesReady: true, + localSelectable: true, + }), + ).toBe(false); + }); + + it("preserves the ready local fallback on supported non-Linux hosts", () => { + expect( + autoSelectsLocalComputer({ + platform: "darwin", + computer: undefined, + capabilitiesReady: true, + localSelectable: true, + }), + ).toBe(true); + expect( + autoSelectsLocalComputer({ + platform: "darwin", + computer: "cloud", + capabilitiesReady: true, + localSelectable: true, + }), + ).toBe(false); }); }); diff --git a/src/lib/local-computer.ts b/src/lib/local-computer.ts index 9f63a957..480adbb4 100644 --- a/src/lib/local-computer.ts +++ b/src/lib/local-computer.ts @@ -39,3 +39,17 @@ export function localComputerDisabledReason({ export function linuxAutoDescription(): string { return "Auto uses a cloud box when one is configured; otherwise computer use stays off."; } + +export function autoSelectsLocalComputer({ + platform, + computer, + capabilitiesReady, + localSelectable, +}: { + platform: DesktopCapabilities["host"]["platform"]; + computer: Bot["computer"]; + capabilitiesReady: boolean; + localSelectable: boolean; +}): boolean { + return platform !== "linux" && computer !== "cloud" && capabilitiesReady && localSelectable; +} From 0870140b28b62693b5e4128ef351a0a3c326439e Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 14:05:29 -0300 Subject: [PATCH 27/32] harden Linux CUA lifecycle and privacy --- docs/linux-desktop.md | 6 +- electron/cua-linux-runtime.cjs | 72 ++++++++++- electron/cua-linux-runtime.test.mjs | 43 ++++++- electron/cua-linux.cjs | 6 +- electron/cua-linux.test.mjs | 1 + electron/main.mjs | 2 +- scripts/run-linux-package-smoke.mjs | 11 +- scripts/smoke-linux-package.mjs | 180 +++++++++++++++++++++++++--- server/local-computer.test.ts | 9 ++ server/local-computer.ts | 2 + 10 files changed, 302 insertions(+), 30 deletions(-) diff --git a/docs/linux-desktop.md b/docs/linux-desktop.md index 0cb0154d..7927ed9d 100644 --- a/docs/linux-desktop.md +++ b/docs/linux-desktop.md @@ -225,9 +225,9 @@ The driver uses Cua's `standard` permission mode. Cua routine actions are prompt OpenMausBot requires its own **Allow** or **Deny** decision before every local action. Bot Auto mode, persistent **Always allow** grants, and cloud-computer approvals cannot authorize the local desktop in this beta. -Cua Driver has content-free telemetry and an update check enabled by default. OpenMausBot disables the update -check only for children it starts and does not change the user's persisted telemetry preference. Review or change -that preference with the [official telemetry documentation](https://cua.ai/docs/reference/cua-driver/telemetry). +Cua Driver has content-free telemetry and an update check enabled by default. OpenMausBot disables both for every +Cua process it starts and does not change the user's persisted Cua preferences. Review or change those preferences +with the [official telemetry documentation](https://cua.ai/docs/reference/cua-driver/telemetry). ## Validate a package change diff --git a/electron/cua-linux-runtime.cjs b/electron/cua-linux-runtime.cjs index 6bdbcc0f..6d696b76 100644 --- a/electron/cua-linux-runtime.cjs +++ b/electron/cua-linux-runtime.cjs @@ -44,6 +44,68 @@ function ensurePrivateDirectory(directory, fileSystem = fs, currentUid = process return directory; } +function cleanupStaleRuntimeDirectories(root, { + fileSystem = fs, + currentUid = process.getuid?.() ?? os.userInfo().uid, + isProcessAlive = (pid) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code !== "ESRCH"; + } + }, +} = {}) { + let entries; + try { + entries = fileSystem.readdirSync(root, { withFileTypes: true }); + } catch { + return 0; + } + let removed = 0; + for (const entry of entries) { + const match = /^([1-9][0-9]*)-([0-9a-f]{8}-[0-9a-f]{3})$/.exec(entry.name); + if (!match || !entry.isDirectory() || entry.isSymbolicLink()) continue; + const ownerPid = Number(match[1]); + if (!Number.isSafeInteger(ownerPid) || isProcessAlive(ownerPid)) continue; + const directory = path.join(root, entry.name); + try { + const directoryStat = fileSystem.lstatSync(directory); + if ( + !directoryStat.isDirectory() || + directoryStat.isSymbolicLink() || + directoryStat.uid !== currentUid || + (directoryStat.mode & 0o077) !== 0 + ) { + continue; + } + const children = fileSystem.readdirSync(directory, { withFileTypes: true }); + if (children.some((child) => !["driver.pid", "driver.sock"].includes(child.name))) continue; + const removable = []; + let safe = true; + for (const child of children) { + const childPath = path.join(directory, child.name); + const childStat = fileSystem.lstatSync(childPath); + const expectedType = + child.name === "driver.pid" ? childStat.isFile() : childStat.isSocket(); + if (childStat.isSymbolicLink() || childStat.uid !== currentUid || !expectedType) { + safe = false; + break; + } + removable.push(childPath); + } + if (!safe) continue; + for (const childPath of removable) fileSystem.unlinkSync(childPath); + fileSystem.rmdirSync(directory); + removed += 1; + } catch { + // Runtime cleanup is best-effort and strictly scoped. A suspicious or + // concurrently changing entry is left untouched for manual inspection. + } + } + return removed; +} + function writePrivateJson(file, value, { fileSystem = fs, temporaryId = randomUUID, @@ -436,7 +498,9 @@ function createLinuxCuaRuntime({ stat.uid === currentUid && (stat.mode & 0o077) === 0 ) { - return ensurePrivateDirectory(path.join(configured, "openmausbot-cua")); + const root = ensurePrivateDirectory(path.join(configured, "openmausbot-cua")); + cleanupStaleRuntimeDirectories(root); + return root; } } catch {} } @@ -444,7 +508,9 @@ function createLinuxCuaRuntime({ // directly under the system temp root keeps the fallback deterministic // and short when XDG_RUNTIME_DIR is missing or unsafe. const currentUid = process.getuid?.() ?? os.userInfo().uid; - return ensurePrivateDirectory(path.join(os.tmpdir(), `openmausbot-cua-${currentUid}`)); + const root = ensurePrivateDirectory(path.join(os.tmpdir(), `openmausbot-cua-${currentUid}`)); + cleanupStaleRuntimeDirectories(root); + return root; }; const cleanupRuntimeFiles = (owned) => { @@ -664,6 +730,7 @@ function createLinuxCuaRuntime({ CUA_DRIVER_EMBEDDED: "1", CUA_DRIVER_HOST_BUNDLE_ID: HOST_BUNDLE_ID, CUA_DRIVER_RS_UPDATE_CHECK: "false", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", ...(runtimeSession === "wayland" ? { CUA_DRIVER_RS_ENABLE_WAYLAND: "1" } : {}), @@ -770,6 +837,7 @@ module.exports = { HOST_BUNDLE_ID, REQUIRED_TOOLS, REQUIRED_WAYLAND_HEALTH_CHECKS, + cleanupStaleRuntimeDirectories, createLinuxCuaPreferenceStore, createLinuxCuaRuntime, ensurePrivateDirectory, diff --git a/electron/cua-linux-runtime.test.mjs b/electron/cua-linux-runtime.test.mjs index ef64f9ef..2f728af9 100644 --- a/electron/cua-linux-runtime.test.mjs +++ b/electron/cua-linux-runtime.test.mjs @@ -9,6 +9,7 @@ const require = createRequire(import.meta.url); const { createCuaConnectionStore } = require("./cua-connection.cjs"); const { validateDriverCandidate } = require("./cua-linux.cjs"); const { + cleanupStaleRuntimeDirectories, createLinuxCuaPreferenceStore, createLinuxCuaRuntime, probePrivateDaemon, @@ -191,6 +192,32 @@ afterEach(() => { // lifecycle contract exercises. Canonical short temp paths keep it portable // across Linux and macOS. describe.skipIf(process.platform === "win32")("Linux CUA opt-in and lifecycle", () => { + it("cleans only known private runtime files from a dead owner", () => { + const root = temporaryDirectory(); + const stale = path.join(root, "99999999-01234567-89a"); + const suspicious = path.join(root, "99999998-01234567-89a"); + fs.mkdirSync(stale, { mode: 0o700 }); + fs.writeFileSync(path.join(stale, "driver.pid"), "99999999\n", { mode: 0o600 }); + fs.mkdirSync(suspicious, { mode: 0o700 }); + fs.writeFileSync(path.join(suspicious, "unexpected"), "preserve", { mode: 0o600 }); + + expect( + cleanupStaleRuntimeDirectories(root, { isProcessAlive: () => false }), + ).toBe(1); + expect(fs.existsSync(stale)).toBe(false); + expect(fs.readFileSync(path.join(suspicious, "unexpected"), "utf8")).toBe("preserve"); + }); + + it("preserves a certified runtime directory while its owner is alive", () => { + const root = temporaryDirectory(); + const live = path.join(root, "1234-01234567-89a"); + fs.mkdirSync(live, { mode: 0o700 }); + fs.writeFileSync(path.join(live, "driver.pid"), "1234\n", { mode: 0o600 }); + + expect(cleanupStaleRuntimeDirectories(root, { isProcessAlive: () => true })).toBe(0); + expect(fs.existsSync(path.join(live, "driver.pid"))).toBe(true); + }); + it("does not inspect or execute a driver before explicit opt-in", async () => { const context = harness(); await context.runtime.initialize(); @@ -238,6 +265,8 @@ describe.skipIf(process.platform === "win32")("Linux CUA opt-in and lifecycle", CUA_DRIVER_EMBEDDED: "1", CUA_DRIVER_PARENT_LIVENESS_STDIN: "1", CUA_DRIVER_HOST_BUNDLE_ID: "com.openmausbot.app", + CUA_DRIVER_RS_UPDATE_CHECK: "false", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", }); expect(spawnOptions.env.OPENAI_API_KEY).toBeUndefined(); expect(context.probe).toHaveBeenCalledWith(expect.stringMatching(/driver\.sock$/), { @@ -263,6 +292,12 @@ describe.skipIf(process.platform === "win32")("Linux CUA opt-in and lifecycle", mcp: { command: context.binary, args: ["mcp", "--embedded", "--socket", expect.stringMatching(/driver\.sock$/)], + env: { + CUA_DRIVER_EMBEDDED: "1", + CUA_DRIVER_HOST_BUNDLE_ID: "com.openmausbot.app", + CUA_DRIVER_RS_UPDATE_CHECK: "false", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", + }, }, }); const descriptor = path.join(context.userData, "cua-connection.json"); @@ -344,7 +379,13 @@ describe.skipIf(process.platform === "win32")("Linux CUA opt-in and lifecycle", mode: "linux-wayland-gnome-supervised", session: "wayland", compositor: "gnome-mutter", - mcp: { env: { CUA_DRIVER_RS_ENABLE_WAYLAND: "1" } }, + mcp: { + env: { + CUA_DRIVER_RS_ENABLE_WAYLAND: "1", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", + CUA_DRIVER_RS_UPDATE_CHECK: "false", + }, + }, }); }); diff --git a/electron/cua-linux.cjs b/electron/cua-linux.cjs index f4ca978b..c9668e66 100644 --- a/electron/cua-linux.cjs +++ b/electron/cua-linux.cjs @@ -65,10 +65,10 @@ function desktopCommandEnvironment(source = process.env, additions = {}) { if (SESSION_ENV_KEYS.has(key) || key.startsWith("LC_")) env[key] = String(value); } env.PATH = sanitizePath(source.PATH); - // Keep the finite probes deterministic and avoid an unrelated network check. - // This affects only children owned by OpenMausBot and does not change the - // user's persisted Cua preferences. + // Keep every Cua child owned by OpenMausBot deterministic and local-only. + // This does not change the user's persisted Cua preferences. env.CUA_DRIVER_RS_UPDATE_CHECK = "false"; + env.CUA_DRIVER_RS_TELEMETRY_ENABLED = "false"; for (const [key, value] of Object.entries(additions)) { if (value != null) env[key] = String(value); } diff --git a/electron/cua-linux.test.mjs b/electron/cua-linux.test.mjs index a80fd0b8..9e60f08a 100644 --- a/electron/cua-linux.test.mjs +++ b/electron/cua-linux.test.mjs @@ -504,6 +504,7 @@ describe.skipIf(process.platform === "win32")("minimal child environment", () => DISPLAY: ":0", PATH: "/usr/bin", CUA_DRIVER_RS_UPDATE_CHECK: "false", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", }); }); }); diff --git a/electron/main.mjs b/electron/main.mjs index ea745dbf..b2b71bc0 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -244,7 +244,7 @@ function createWindow() { } catch (error) { console.error(`[smoke] renderer-failed ${error?.stack ?? error}`); } finally { - win.close(); + if (process.env.OMB_SMOKE_KEEP_OPEN !== "1") win.close(); } }); } diff --git a/scripts/run-linux-package-smoke.mjs b/scripts/run-linux-package-smoke.mjs index 89dee2b8..90daf408 100644 --- a/scripts/run-linux-package-smoke.mjs +++ b/scripts/run-linux-package-smoke.mjs @@ -6,7 +6,11 @@ import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const prefixName = "omb-linux-smoke-runtime-"; -for (const lane of ["x11", "wayland"]) { +for (const lane of [ + { name: "x11", wayland: false, hardDeath: false }, + { name: "wayland", wayland: true, hardDeath: false }, + { name: "x11-hard-death", wayland: false, hardDeath: true }, +]) { const runtimeDirectory = mkdtempSync(path.join(tmpdir(), prefixName)); if ( path.dirname(runtimeDirectory) !== path.resolve(tmpdir()) || @@ -24,14 +28,15 @@ for (const lane of ["x11", "wayland"]) { env: { ...process.env, XDG_RUNTIME_DIR: runtimeDirectory, - OMB_SMOKE_WAYLAND: lane === "wayland" ? "1" : "0", + OMB_SMOKE_WAYLAND: lane.wayland ? "1" : "0", + OMB_SMOKE_HARD_DEATH: lane.hardDeath ? "1" : "0", }, stdio: "inherit", }, ); if (result.error) throw result.error; if (result.status !== 0) { - console.error(`[run-linux-package-smoke] ${lane} runtime kept at ${runtimeDirectory}`); + console.error(`[run-linux-package-smoke] ${lane.name} runtime kept at ${runtimeDirectory}`); process.exitCode = result.status ?? 1; break; } diff --git a/scripts/smoke-linux-package.mjs b/scripts/smoke-linux-package.mjs index 5494645a..311fc99a 100644 --- a/scripts/smoke-linux-package.mjs +++ b/scripts/smoke-linux-package.mjs @@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const wayland = process.env.OMB_SMOKE_WAYLAND === "1"; +const hardDeath = process.env.OMB_SMOKE_HARD_DEATH === "1"; const executable = path.resolve( process.env.OMB_SMOKE_EXECUTABLE ?? path.join(root, "release", "linux-unpacked", "openmausbot"), ); @@ -56,6 +57,8 @@ const args = process.argv.slice(2); appendFileSync(marker, JSON.stringify({ pid: process.pid, args, + telemetryEnabled: process.env.CUA_DRIVER_RS_TELEMETRY_ENABLED, + updateCheck: process.env.CUA_DRIVER_RS_UPDATE_CHECK, waylandEnabled: process.env.CUA_DRIVER_RS_ENABLE_WAYLAND === "1", }) + "\\n"); const after = (flag) => { const index = args.indexOf(flag); return index === -1 ? null : args[index + 1]; }; @@ -158,7 +161,8 @@ const desktopEnv = { XDG_CURRENT_DESKTOP: "GNOME", CUA_DRIVER_PATH: sentinel, OMB_SMOKE_TEST: "1", - OMB_SMOKE_CUA: "1", + OMB_SMOKE_CUA: hardDeath ? "0" : "1", + ...(hardDeath ? { OMB_SMOKE_KEEP_OPEN: "1" } : {}), }; if (wayland) desktopEnv.WAYLAND_DISPLAY = "wayland-smoke"; else delete desktopEnv.WAYLAND_DISPLAY; @@ -182,13 +186,16 @@ for (const stream of [child.stdout, child.stderr]) { } const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const childRunning = () => child.exitCode === null && child.signalCode === null; async function until(probe, description) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { const value = await probe().catch(() => null); if (value) return value; - if (child.exitCode !== null) { - throw new Error(`Electron exited ${child.exitCode} while waiting for ${description}.\n${output}`); + if (!childRunning()) { + throw new Error( + `Electron exited ${child.exitCode ?? child.signalCode} while waiting for ${description}.\n${output}`, + ); } await delay(100); } @@ -197,18 +204,18 @@ async function until(probe, description) { async function waitForExit() { const deadline = Date.now() + 10_000; - while (child.exitCode === null && Date.now() < deadline) await delay(50); - if (child.exitCode === null) throw new Error(`Electron did not exit after its window closed.\n${output}`); + while (childRunning() && Date.now() < deadline) await delay(50); + if (childRunning()) throw new Error(`Electron did not exit after its window closed.\n${output}`); } async function stopProcess() { - if (child.exitCode !== null) return; + if (!childRunning()) return; try { process.kill(-child.pid, "SIGTERM"); } catch {} const stopDeadline = Date.now() + 5_000; - while (child.exitCode === null && Date.now() < stopDeadline) await delay(50); - if (child.exitCode === null) { + while (childRunning() && Date.now() < stopDeadline) await delay(50); + if (childRunning()) { try { process.kill(-child.pid, "SIGKILL"); } catch {} @@ -248,15 +255,13 @@ try { )) { throw new Error("initial Linux CUA runtime did not publish the guarded GNOME Wayland contract"); } - if (cuaCrashReason !== "daemon-exited") throw new Error("daemon crash did not invalidate local control"); - if (cuaRetryStatus?.status !== "ready" || !capabilities.localComputer.available) { - throw new Error("explicit CUA retry did not create a ready generation"); + if (!hardDeath) { + if (cuaCrashReason !== "daemon-exited") throw new Error("daemon crash did not invalidate local control"); + if (cuaRetryStatus?.status !== "ready" || !capabilities.localComputer.available) { + throw new Error("explicit CUA retry did not create a ready generation"); + } } if (displayMediaRequests !== 0) throw new Error("launch triggered display capture without user intent"); - - await waitForExit(); - const staleHealth = await fetch(new URL("/api/health", location)).catch(() => null); - if (staleHealth?.ok) throw new Error("embedded harness remained reachable after Electron quit"); const invocations = readFileSync(marker, "utf8") .trim() .split("\n") @@ -268,8 +273,147 @@ try { if (invocations.some((entry) => entry.waylandEnabled !== wayland)) { throw new Error("CUA Wayland opt-in escaped its certified smoke lane"); } + if ( + invocations.some( + (entry) => entry.updateCheck !== "false" || entry.telemetryEnabled !== "false", + ) + ) { + throw new Error("a CUA child escaped the local-only update/telemetry environment"); + } const daemons = invocations.filter((entry) => entry.args[0] === "serve"); - if (daemons.length !== 2) throw new Error(`expected crash + retry daemon generations, found ${daemons.length}`); + const expectedDaemonCount = hardDeath ? 1 : 2; + if (daemons.length !== expectedDaemonCount) { + throw new Error(`expected ${expectedDaemonCount} daemon generation(s), found ${daemons.length}`); + } + + if (hardDeath) { + child.kill("SIGKILL"); + await waitForExit(); + const cleanupDeadline = Date.now() + 10_000; + while (Date.now() < cleanupDeadline) { + const stillAlive = daemons.some((daemon) => { + try { + process.kill(daemon.pid, 0); + return true; + } catch (error) { + return error?.code !== "ESRCH"; + } + }); + const runtimeFilesRemain = daemons.some((daemon) => { + const socketIndex = daemon.args.indexOf("--socket"); + const pidFileIndex = daemon.args.indexOf("--pid-file"); + return ( + (socketIndex !== -1 && existsSync(daemon.args[socketIndex + 1])) || + (pidFileIndex !== -1 && existsSync(daemon.args[pidFileIndex + 1])) + ); + }); + if (!stillAlive && !runtimeFilesRemain) break; + await delay(50); + } + for (const daemon of daemons) { + try { + process.kill(daemon.pid, 0); + throw new Error(`owned CUA daemon survived hard Electron death: ${daemon.pid}`); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + } + const serverDeadline = Date.now() + 10_000; + let staleHealth = null; + while (Date.now() < serverDeadline) { + staleHealth = await fetch(new URL("/api/health", location)).catch(() => null); + if (!staleHealth?.ok) break; + await delay(50); + } + if (staleHealth?.ok) throw new Error("embedded harness survived hard Electron death"); + const userData = ["openmausbot", "OpenMausBot"] + .map((name) => path.join(xdgConfig, name)) + .find((directory) => existsSync(path.join(directory, "cua-connection.json"))); + if (!userData) throw new Error("hard-death smoke could not locate the CUA descriptor"); + const { readCuaConnection } = await import( + new URL("../dist-server/local-computer.js", import.meta.url) + ); + if (readCuaConnection({ platform: "linux", userData }) !== null) { + throw new Error("stale hard-death CUA descriptor remained usable"); + } + + let restartOutput = ""; + let restartResult = null; + const restart = spawn(executable, wayland ? ["--ozone-platform=x11"] : [], { + cwd: root, + detached: true, + env: { ...desktopEnv, OMB_SMOKE_KEEP_OPEN: "0" }, + stdio: ["ignore", "pipe", "pipe"], + }); + for (const stream of [restart.stdout, restart.stderr]) { + stream.setEncoding("utf8"); + stream.on("data", (chunk) => { + restartOutput += chunk; + const match = restartOutput.match(/\[smoke\] renderer-ready (\{.*\})\r?\n/); + if (match && !restartResult) restartResult = JSON.parse(match[1]); + }); + } + const restartDeadline = Date.now() + 30_000; + while (!restartResult && Date.now() < restartDeadline) { + if (restart.exitCode !== null || restart.signalCode !== null) { + throw new Error(`Electron restart exited before renderer readiness.\n${restartOutput}`); + } + await delay(100); + } + if (!restartResult?.initialCapabilities?.localComputer?.available) { + throw new Error(`Electron restart did not create a ready CUA generation.\n${restartOutput}`); + } + const restartExitDeadline = Date.now() + 10_000; + while ( + restart.exitCode === null && + restart.signalCode === null && + Date.now() < restartExitDeadline + ) { + await delay(50); + } + if (restart.exitCode === null && restart.signalCode === null) { + try { + process.kill(-restart.pid, "SIGTERM"); + } catch {} + throw new Error(`Electron restart did not close normally.\n${restartOutput}`); + } + + const restartedInvocations = readFileSync(marker, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const restartedDaemons = restartedInvocations.filter((entry) => entry.args[0] === "serve"); + if (restartedDaemons.length !== 2) { + throw new Error(`hard-death restart expected two generations, found ${restartedDaemons.length}`); + } + const socketPaths = new Set( + restartedDaemons.map((daemon) => daemon.args[daemon.args.indexOf("--socket") + 1]), + ); + if (socketPaths.size !== 2) throw new Error("hard-death restart reused a stale CUA generation"); + for (const daemon of restartedDaemons) { + try { + process.kill(daemon.pid, 0); + throw new Error(`CUA daemon survived restart shutdown: ${daemon.pid}`); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + for (const flag of ["--socket", "--pid-file"]) { + const index = daemon.args.indexOf(flag); + if (index !== -1 && existsSync(daemon.args[index + 1])) { + throw new Error(`stale CUA runtime file survived restart: ${daemon.args[index + 1]}`); + } + } + } + if (readCuaConnection({ platform: "linux", userData }) !== null) { + throw new Error("CUA descriptor remained usable after restart shutdown"); + } + console.log(`[smoke-linux-package] OK (${wayland ? "GNOME/Wayland" : "GNOME/X11"} hard death): restart replaced the generation and left no daemon, runtime file, server, or usable descriptor`); + } else { + await waitForExit(); + const staleHealth = await fetch(new URL("/api/health", location)).catch(() => null); + if (staleHealth?.ok) throw new Error("embedded harness remained reachable after Electron quit"); + } + for (const daemon of daemons) { try { process.kill(daemon.pid, 0); @@ -279,7 +423,9 @@ try { } } - console.log(`[smoke-linux-package] OK (${wayland ? "GNOME/Wayland" : "GNOME/X11"}): renderer, private CUA crash/retry, harness, and shutdown`); + if (!hardDeath) { + console.log(`[smoke-linux-package] OK (${wayland ? "GNOME/Wayland" : "GNOME/X11"}): renderer, private CUA crash/retry, harness, and shutdown`); + } } finally { await stopProcess(); if (process.env.OMB_KEEP_SMOKE_DIR !== "1") rmSync(sandbox, { recursive: true, force: true }); diff --git a/server/local-computer.test.ts b/server/local-computer.test.ts index 75aac294..6fbdb309 100644 --- a/server/local-computer.test.ts +++ b/server/local-computer.test.ts @@ -60,6 +60,7 @@ function linuxDescriptor(userData: string, { session = "x11" }: { session?: "x11 CUA_DRIVER_EMBEDDED: "1", CUA_DRIVER_HOST_BUNDLE_ID: "com.openmausbot.app", CUA_DRIVER_RS_UPDATE_CHECK: "false", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", ...(session === "wayland" ? { CUA_DRIVER_RS_ENABLE_WAYLAND: "1" } : {}), }, }, @@ -148,6 +149,14 @@ describe("local computer descriptor", () => { }), ).toBeNull(); expect(decodeLinuxDescriptor({ ...descriptor, toolNames: ["list_apps"] })).toBeNull(); + const { CUA_DRIVER_RS_TELEMETRY_ENABLED: _missingTelemetry, ...telemetryEnabledEnv } = + descriptor.mcp.env; + expect( + decodeLinuxDescriptor({ + ...descriptor, + mcp: { ...descriptor.mcp, env: telemetryEnabledEnv }, + }), + ).toBeNull(); expect( decodeLinuxDescriptor({ ...descriptor, diff --git a/server/local-computer.ts b/server/local-computer.ts index 30c0414a..aad6d79f 100644 --- a/server/local-computer.ts +++ b/server/local-computer.ts @@ -195,11 +195,13 @@ export function decodeLinuxDescriptor(value: LinuxConnectionDescriptor): LocalCo "CUA_DRIVER_EMBEDDED", "CUA_DRIVER_HOST_BUNDLE_ID", "CUA_DRIVER_RS_UPDATE_CHECK", + "CUA_DRIVER_RS_TELEMETRY_ENABLED", ...(wayland ? ["CUA_DRIVER_RS_ENABLE_WAYLAND"] : []), ]) || (mcp.env as Record).CUA_DRIVER_EMBEDDED !== "1" || (mcp.env as Record).CUA_DRIVER_HOST_BUNDLE_ID !== "com.openmausbot.app" || (mcp.env as Record).CUA_DRIVER_RS_UPDATE_CHECK !== "false" || + (mcp.env as Record).CUA_DRIVER_RS_TELEMETRY_ENABLED !== "false" || (wayland && (mcp.env as Record).CUA_DRIVER_RS_ENABLE_WAYLAND !== "1") ) { return null; From d5ca52676b1c8c43824b051ef1f809ecfafa1014 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 14:17:26 -0300 Subject: [PATCH 28/32] address final Ubuntu PR review --- docs/computer-use-integration.md | 21 ++--- scripts/smoke-linux-package.mjs | 133 ++++++++++++++++--------------- 2 files changed, 82 insertions(+), 72 deletions(-) diff --git a/docs/computer-use-integration.md b/docs/computer-use-integration.md index 6a631e86..d6bd9b5f 100644 --- a/docs/computer-use-integration.md +++ b/docs/computer-use-integration.md @@ -9,9 +9,9 @@ machine. ## TL;DR architecture -``` +```text Electron main process -├── CUA host ──spawns──▶ cua-driver (bundled on macOS; verified user install on Ubuntu) +├── CUA host ──spawns──▶ cua-driver (bundled on macOS; verified user install on Ubuntu 24.04 GNOME Xorg/Wayland betas) │ platform permission boundary │ unix socket (private) ├── WebContentsView pool (embedded browser, persist: partitions per bot) │ driven via webContents.debugger (CDP) — zero-install browser use @@ -23,9 +23,11 @@ Electron main process - **Plugins = MCP servers over stdio.** The Plugins panel toggles which MCP servers get injected into each bot's `--mcp-config`. Same pattern as Claude Desktop / Cherry Studio / LibreChat. -- **Computer use = `cua-driver`**. macOS packages the Rust Mach-O in app - Resources; the Ubuntu beta accepts only the certified user-installed 0.19.3 - Linux binary while bundling is tracked separately. +- **Local desktop use = `cua-driver`**. macOS packages the Rust Mach-O in app + Resources; the Ubuntu 24.04 GNOME/Xorg beta and guarded GNOME/Wayland beta + accept only the certified user-installed 0.19.3 Linux binary while bundling + is tracked separately. Remote/cloud boxes and the isolated Local VM remain + separate providers. NOT Swift — the Swift file everyone remembers (`examples/embedded-host-macos/ExampleAgentHarness.swift`) is a 165-line reference host showing the embedding pattern, not the driver. @@ -34,12 +36,13 @@ Electron main process `webContents.debugger` CDP transport. No Chrome dependency, no 281MB Playwright download, and the user watches the bot browse inside the chat. -## Computer use: CUA only — Electron owns the driver lifecycle +## Local desktop use: CUA only — Electron owns the driver lifecycle -**Decision (Milind, 2026-08-12): CUA is the ONLY computer-use provider. +**Decision (Milind, 2026-08-12): CUA is the ONLY local desktop-control provider. No cliclick, no robotjs/nut.js, no Python computer-server, no fallbacks.** -Everything that touches the user's screen/mouse/keyboard goes through the -validated `cua-driver` binary. Alternatives evaluated and rejected: +Everything that touches the host's local screen/mouse/keyboard goes through the +validated `cua-driver` binary. This rule does not replace remote/cloud boxes or +the isolated Local VM provider. Local alternatives evaluated and rejected: The Ubuntu GNOME beta is an intentional staged exception to the zero-install packaging statement: it uses the same official CUA provider but diff --git a/scripts/smoke-linux-package.mjs b/scripts/smoke-linux-package.mjs index 311fc99a..c697d189 100644 --- a/scripts/smoke-linux-package.mjs +++ b/scripts/smoke-linux-package.mjs @@ -186,7 +186,9 @@ for (const stream of [child.stdout, child.stderr]) { } const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const childRunning = () => child.exitCode === null && child.signalCode === null; +const processRunning = (processHandle) => + processHandle.exitCode === null && processHandle.signalCode === null; +const childRunning = () => processRunning(child); async function until(probe, description) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { @@ -208,20 +210,24 @@ async function waitForExit() { if (childRunning()) throw new Error(`Electron did not exit after its window closed.\n${output}`); } -async function stopProcess() { - if (!childRunning()) return; +async function stopDetached(processHandle) { + if (!processRunning(processHandle)) return; try { - process.kill(-child.pid, "SIGTERM"); + process.kill(-processHandle.pid, "SIGTERM"); } catch {} const stopDeadline = Date.now() + 5_000; - while (childRunning() && Date.now() < stopDeadline) await delay(50); - if (childRunning()) { + while (processRunning(processHandle) && Date.now() < stopDeadline) await delay(50); + if (processRunning(processHandle)) { try { - process.kill(-child.pid, "SIGKILL"); + process.kill(-processHandle.pid, "SIGKILL"); } catch {} } } +async function stopProcess() { + await stopDetached(child); +} + try { const result = await until(async () => smokeResult, "the packaged renderer smoke result"); const { @@ -345,67 +351,68 @@ try { env: { ...desktopEnv, OMB_SMOKE_KEEP_OPEN: "0" }, stdio: ["ignore", "pipe", "pipe"], }); - for (const stream of [restart.stdout, restart.stderr]) { - stream.setEncoding("utf8"); - stream.on("data", (chunk) => { - restartOutput += chunk; - const match = restartOutput.match(/\[smoke\] renderer-ready (\{.*\})\r?\n/); - if (match && !restartResult) restartResult = JSON.parse(match[1]); - }); - } - const restartDeadline = Date.now() + 30_000; - while (!restartResult && Date.now() < restartDeadline) { - if (restart.exitCode !== null || restart.signalCode !== null) { - throw new Error(`Electron restart exited before renderer readiness.\n${restartOutput}`); + try { + for (const stream of [restart.stdout, restart.stderr]) { + stream.setEncoding("utf8"); + stream.on("data", (chunk) => { + restartOutput += chunk; + const match = restartOutput.match(/\[smoke\] renderer-ready (\{.*\})\r?\n/); + if (match && !restartResult) restartResult = JSON.parse(match[1]); + }); + } + const restartDeadline = Date.now() + 30_000; + while (!restartResult && Date.now() < restartDeadline) { + if (restart.exitCode !== null || restart.signalCode !== null) { + throw new Error(`Electron restart exited before renderer readiness.\n${restartOutput}`); + } + await delay(100); + } + if (!restartResult?.initialCapabilities?.localComputer?.available) { + throw new Error(`Electron restart did not create a ready CUA generation.\n${restartOutput}`); + } + const restartExitDeadline = Date.now() + 10_000; + while ( + restart.exitCode === null && + restart.signalCode === null && + Date.now() < restartExitDeadline + ) { + await delay(50); + } + if (restart.exitCode === null && restart.signalCode === null) { + throw new Error(`Electron restart did not close normally.\n${restartOutput}`); } - await delay(100); - } - if (!restartResult?.initialCapabilities?.localComputer?.available) { - throw new Error(`Electron restart did not create a ready CUA generation.\n${restartOutput}`); - } - const restartExitDeadline = Date.now() + 10_000; - while ( - restart.exitCode === null && - restart.signalCode === null && - Date.now() < restartExitDeadline - ) { - await delay(50); - } - if (restart.exitCode === null && restart.signalCode === null) { - try { - process.kill(-restart.pid, "SIGTERM"); - } catch {} - throw new Error(`Electron restart did not close normally.\n${restartOutput}`); - } - const restartedInvocations = readFileSync(marker, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - const restartedDaemons = restartedInvocations.filter((entry) => entry.args[0] === "serve"); - if (restartedDaemons.length !== 2) { - throw new Error(`hard-death restart expected two generations, found ${restartedDaemons.length}`); - } - const socketPaths = new Set( - restartedDaemons.map((daemon) => daemon.args[daemon.args.indexOf("--socket") + 1]), - ); - if (socketPaths.size !== 2) throw new Error("hard-death restart reused a stale CUA generation"); - for (const daemon of restartedDaemons) { - try { - process.kill(daemon.pid, 0); - throw new Error(`CUA daemon survived restart shutdown: ${daemon.pid}`); - } catch (error) { - if (error?.code !== "ESRCH") throw error; + const restartedInvocations = readFileSync(marker, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const restartedDaemons = restartedInvocations.filter((entry) => entry.args[0] === "serve"); + if (restartedDaemons.length !== 2) { + throw new Error(`hard-death restart expected two generations, found ${restartedDaemons.length}`); } - for (const flag of ["--socket", "--pid-file"]) { - const index = daemon.args.indexOf(flag); - if (index !== -1 && existsSync(daemon.args[index + 1])) { - throw new Error(`stale CUA runtime file survived restart: ${daemon.args[index + 1]}`); + const socketPaths = new Set( + restartedDaemons.map((daemon) => daemon.args[daemon.args.indexOf("--socket") + 1]), + ); + if (socketPaths.size !== 2) throw new Error("hard-death restart reused a stale CUA generation"); + for (const daemon of restartedDaemons) { + try { + process.kill(daemon.pid, 0); + throw new Error(`CUA daemon survived restart shutdown: ${daemon.pid}`); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + for (const flag of ["--socket", "--pid-file"]) { + const index = daemon.args.indexOf(flag); + if (index !== -1 && existsSync(daemon.args[index + 1])) { + throw new Error(`stale CUA runtime file survived restart: ${daemon.args[index + 1]}`); + } } } - } - if (readCuaConnection({ platform: "linux", userData }) !== null) { - throw new Error("CUA descriptor remained usable after restart shutdown"); + if (readCuaConnection({ platform: "linux", userData }) !== null) { + throw new Error("CUA descriptor remained usable after restart shutdown"); + } + } finally { + await stopDetached(restart); } console.log(`[smoke-linux-package] OK (${wayland ? "GNOME/Wayland" : "GNOME/X11"} hard death): restart replaced the generation and left no daemon, runtime file, server, or usable descriptor`); } else { From b939c4fc5f4ebd75d9e6b1df649a6ed230dc8dd4 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 14:24:17 -0300 Subject: [PATCH 29/32] clarify Linux preview policy --- docs/computer-use-integration.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/computer-use-integration.md b/docs/computer-use-integration.md index d6bd9b5f..d61a4f3e 100644 --- a/docs/computer-use-integration.md +++ b/docs/computer-use-integration.md @@ -40,9 +40,11 @@ Electron main process **Decision (Milind, 2026-08-12): CUA is the ONLY local desktop-control provider. No cliclick, no robotjs/nut.js, no Python computer-server, no fallbacks.** -Everything that touches the host's local screen/mouse/keyboard goes through the -validated `cua-driver` binary. This rule does not replace remote/cloud boxes or -the isolated Local VM provider. Local alternatives evaluated and rejected: +All local desktop-control and input actions go through the validated +`cua-driver` binary. Linux screen preview uses the supported Xorg or +user-initiated XDG portal capture path and is not a control provider. This rule +does not replace remote/cloud boxes or the isolated Local VM provider. Local +alternatives evaluated and rejected: The Ubuntu GNOME beta is an intentional staged exception to the zero-install packaging statement: it uses the same official CUA provider but From d1b435d5475707b5c25406dcb675bc9a057459c7 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 15:04:00 -0300 Subject: [PATCH 30/32] address stacked Ubuntu review findings --- electron/cua-linux-runtime.cjs | 6 +++- electron/cua-linux-runtime.test.mjs | 43 +++++++++++++++++++++++++++++ electron/updater.mjs | 2 +- server/index.test.ts | 7 ++++- server/index.ts | 3 ++ server/local-computer.test.ts | 6 ++-- server/local-routing.test.ts | 19 +++++++++++++ server/local-routing.ts | 2 +- src/components/SettingsPanel.tsx | 4 ++- 9 files changed, 85 insertions(+), 7 deletions(-) diff --git a/electron/cua-linux-runtime.cjs b/electron/cua-linux-runtime.cjs index 6d696b76..e52cb508 100644 --- a/electron/cua-linux-runtime.cjs +++ b/electron/cua-linux-runtime.cjs @@ -384,7 +384,10 @@ async function probePrivateDaemon(socketPath, { } function waitForChildExit(child, timeoutMs) { - if (child.exitCode !== null && child.exitCode !== undefined) return Promise.resolve(true); + const exited = + (child.exitCode !== null && child.exitCode !== undefined) || + (child.signalCode !== null && child.signalCode !== undefined); + if (exited) return Promise.resolve(true); return new Promise((resolve) => { let settled = false; const finish = (value) => { @@ -810,6 +813,7 @@ function createLinuxCuaRuntime({ }, async retry() { if (!enabled) return connection; + if (startPromise) await startPromise; await stop(); return start(); }, diff --git a/electron/cua-linux-runtime.test.mjs b/electron/cua-linux-runtime.test.mjs index 2f728af9..6849d667 100644 --- a/electron/cua-linux-runtime.test.mjs +++ b/electron/cua-linux-runtime.test.mjs @@ -39,6 +39,7 @@ function fakeChild(pid = 4321) { const child = new EventEmitter(); child.pid = pid; child.exitCode = null; + child.signalCode = null; child.stderr = new EventEmitter(); child.stdin = { end: vi.fn(() => { @@ -358,6 +359,48 @@ describe.skipIf(process.platform === "win32")("Linux CUA opt-in and lifecycle", }); }); + it("does not wait again for a child that was already reaped by a signal", async () => { + const context = harness(); + await context.runtime.enable(); + context.child.signalCode = "SIGTERM"; + context.child.stdin.end = vi.fn(); + vi.useFakeTimers(); + try { + const shutdown = context.runtime.shutdown(); + await vi.runAllTimersAsync(); + await shutdown; + expect(context.child.kill).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("serializes an in-flight start before retrying with a fresh runtime", async () => { + let releaseFirstProbe; + const firstProbeGate = new Promise((resolve) => { + releaseFirstProbe = resolve; + }); + const children = [fakeChild(4321), fakeChild(4322)]; + const spawnProcess = vi.fn(() => children.shift()); + const probe = vi + .fn() + .mockImplementationOnce(async () => { + await firstProbeGate; + return handshake(4321); + }) + .mockResolvedValueOnce(handshake(4322)); + const context = harness({ runtimeOptions: { spawnProcess, probe } }); + + const firstStart = context.runtime.enable(); + await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1)); + const retry = context.runtime.retry(); + releaseFirstProbe(); + + await expect(firstStart).resolves.toMatchObject({ status: "ready" }); + await expect(retry).resolves.toMatchObject({ status: "ready", daemon: { pid: 4322 } }); + expect(spawnProcess).toHaveBeenCalledTimes(2); + }); + it("starts on launch only after a durable prior opt-in and supports explicit disable", async () => { const context = harness({ preferenceEnabled: true }); await context.runtime.initialize(); diff --git a/electron/updater.mjs b/electron/updater.mjs index cb9f0d88..8490dba0 100644 --- a/electron/updater.mjs +++ b/electron/updater.mjs @@ -101,7 +101,7 @@ export function startUpdater(mainWindow) { autoUpdater.on("update-downloaded", (info) => setState({ status: "downloaded", version: info?.version }), ); - autoUpdater.on("error", reportError); + autoUpdater.on("error", (error) => reportError(error)); // first check ~15s after launch (let the app settle), then hourly — both // silent on failure, hence the arrow: a bare `check` would receive the diff --git a/server/index.test.ts b/server/index.test.ts index 27f49389..336d9c89 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -206,7 +206,12 @@ describe("harness HTTP API", () => { }); it("offers an idempotent stop boundary for active local turns", async () => { - const stopped = await api("POST", "/api/local-computer/interrupt"); + const unsupported = await api("POST", "/api/local-computer/interrupt"); + expect(unsupported).toEqual({ + status: 415, + body: { error: "content-type must be application/json" }, + }); + const stopped = await api("POST", "/api/local-computer/interrupt", {}); expect(stopped).toEqual({ status: 200, body: { ok: true } }); }); diff --git a/server/index.ts b/server/index.ts index 74915143..361ce3a2 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1297,6 +1297,9 @@ const server = createServer(async (req, res) => { } if (method === "POST" && path === "/api/local-computer/interrupt") { + if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) { + return json(res, 415, { error: "content-type must be application/json" }); + } await Promise.allSettled( store.bots .filter((bot) => bot.computer === "local") diff --git a/server/local-computer.test.ts b/server/local-computer.test.ts index 6fbdb309..bd320983 100644 --- a/server/local-computer.test.ts +++ b/server/local-computer.test.ts @@ -197,11 +197,13 @@ describe("local computer descriptor", () => { chmodSync(descriptor.daemon.socketPath, 0o600); expect(validateLinuxDescriptorRuntime(file, descriptor)).toBe(true); expect(readCuaConnection({ platform: "linux", userData })).not.toBeNull(); + chmodSync(file, 0o644); + expect(validateLinuxDescriptorRuntime(file, descriptor)).toBe(false); + chmodSync(file, 0o600); + expect(validateLinuxDescriptorRuntime(file, descriptor)).toBe(true); appendFileSync(descriptor.driver.path, "changed after descriptor publication"); expect(validateLinuxDescriptorRuntime(file, descriptor)).toBe(false); expect(readCuaConnection({ platform: "linux", userData })).toBeNull(); - chmodSync(file, 0o644); - expect(validateLinuxDescriptorRuntime(file, descriptor)).toBe(false); } finally { await new Promise((resolve) => server.close(() => resolve())); } diff --git a/server/local-routing.test.ts b/server/local-routing.test.ts index d74f30ab..bd9e7b82 100644 --- a/server/local-routing.test.ts +++ b/server/local-routing.test.ts @@ -38,4 +38,23 @@ describe("local computer routing", () => { }), ).toBe(true); }); + + it("never mounts the local desktop for explicit cloud/off or on an unsupported host", () => { + for (const requested of ["cloud", "off"] as const) { + expect( + shouldMountLocalComputer({ + requested, + hostPlatform: "darwin", + providerSupportsLocal: true, + }), + ).toBe(false); + } + expect( + shouldMountLocalComputer({ + requested: "local", + hostPlatform: "win32", + providerSupportsLocal: true, + }), + ).toBe(false); + }); }); diff --git a/server/local-routing.ts b/server/local-routing.ts index db010a6a..ee13e88c 100644 --- a/server/local-routing.ts +++ b/server/local-routing.ts @@ -8,7 +8,7 @@ export function shouldMountLocalComputer({ providerSupportsLocal: boolean; }): boolean { if (!providerSupportsLocal) return false; - if (requested === "local") return true; + if (requested === "local") return hostPlatform === "darwin" || hostPlatform === "linux"; // Preserve the established macOS Auto behavior. Linux local control is a // beta and can only be selected explicitly per bot. return requested === undefined && hostPlatform === "darwin"; diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index d79975e3..59796938 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -258,7 +258,9 @@ export function SettingsPanel({ bot }: { bot: Bot }) { key={mode} disabled={mode === "local" && !localSelectable} title={mode === "local" && !localSelectable ? localDisabledReason ?? undefined : undefined} - onClick={() => patch({ computer: mode })} + onClick={() => + patch(mode === "local" ? { computer: mode, autoApprove: false } : { computer: mode }) + } className={cn( "flex-1 py-1.5 text-[13px] capitalize", i > 0 && "border-l border-hairline/40", From 99029a1f5d629545c0c08c09ecf0e24ed6f189e9 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 15:16:55 -0300 Subject: [PATCH 31/32] tighten Ubuntu desktop contracts --- electron/cua-linux-runtime.cjs | 3 +-- electron/cua-linux.cjs | 1 + electron/screen-preview.cjs | 11 +++++++++-- electron/screen-preview.test.mjs | 14 ++++++++++++++ server/local-computer.test.ts | 16 ++++++++++++++++ server/local-computer.ts | 4 ++-- src/lib/local-computer.test.ts | 18 ++++++++++++------ 7 files changed, 55 insertions(+), 12 deletions(-) diff --git a/electron/cua-linux-runtime.cjs b/electron/cua-linux-runtime.cjs index e52cb508..0b2ada89 100644 --- a/electron/cua-linux-runtime.cjs +++ b/electron/cua-linux-runtime.cjs @@ -525,9 +525,8 @@ function createLinuxCuaRuntime({ for (const file of [owned.socketPath, owned.pidFile]) { try { fs.unlinkSync(file); - } catch (error) { + } catch { // A failure for one owned path must not prevent cleanup of the other. - if (error?.code !== "ENOENT") continue; } } try { diff --git a/electron/cua-linux.cjs b/electron/cua-linux.cjs index c9668e66..6b9bb3bb 100644 --- a/electron/cua-linux.cjs +++ b/electron/cua-linux.cjs @@ -723,6 +723,7 @@ async function inspectLinuxCuaDriver({ module.exports = { CERTIFIED_DRIVER_VERSION, CERTIFIED_MANIFEST_SCHEMA, + DRIVER_FILE_IDENTITY_KEYS, captureDriverFileIdentity, desktopCommandEnvironment, discoverLinuxCuaDriver, diff --git a/electron/screen-preview.cjs b/electron/screen-preview.cjs index 109ec408..9bfd1d69 100644 --- a/electron/screen-preview.cjs +++ b/electron/screen-preview.cjs @@ -35,6 +35,8 @@ function createDisplayMediaGuard({ now = Date.now, ttlMs = 5_000 } = {}) { if (!key) return false; const expiresAt = intents.get(key); intents.delete(key); + const requestOrigin = originOf(request.securityOrigin); + const allowedOrigin = originOf(expectedOrigin); return Boolean( expiresAt !== undefined && @@ -42,7 +44,9 @@ function createDisplayMediaGuard({ now = Date.now, ttlMs = 5_000 } = {}) { request.userGesture === true && request.videoRequested === true && request.audioRequested === false && - originOf(request.securityOrigin) === originOf(expectedOrigin), + requestOrigin !== null && + allowedOrigin !== null && + requestOrigin === allowedOrigin, ); }, }); @@ -53,7 +57,10 @@ function selectCaptureSource({ sources, host, primaryDisplayId }) { if (host === "wayland") return sources.length === 1 ? sources[0] : null; if (host === "x11") { const exact = sources.find( - (source) => String(source.display_id) === String(primaryDisplayId), + (source) => + source.display_id !== undefined && + primaryDisplayId !== undefined && + String(source.display_id) === String(primaryDisplayId), ); // Some X11 backends omit or misreport display_id. A single enumerated // source is still unambiguous; never guess when multiple sources remain. diff --git a/electron/screen-preview.test.mjs b/electron/screen-preview.test.mjs index 8f2cde66..5532a02b 100644 --- a/electron/screen-preview.test.mjs +++ b/electron/screen-preview.test.mjs @@ -29,6 +29,7 @@ describe("display media request guard", () => { ["audio capture", { audioRequested: true }], ["missing video", { videoRequested: false }], ["untrusted origin", { securityOrigin: "https://example.com" }], + ["unparseable origin", { securityOrigin: "not a URL" }], ["different frame", { frame: { processId: 10, routingId: 21 } }], ])("rejects %s", (_name, change) => { const guard = createDisplayMediaGuard({ now: () => 1_000 }); @@ -47,6 +48,12 @@ describe("display media request guard", () => { expect(guard.consume(validRequest, "http://127.0.0.1:8799")).toBe(false); }); + + it("rejects a request when both origins are missing or invalid", () => { + const guard = createDisplayMediaGuard({ now: () => 1_000 }); + guard.begin(frame); + expect(guard.consume({ ...validRequest, securityOrigin: undefined }, undefined)).toBe(false); + }); }); describe("display source selection", () => { @@ -68,6 +75,13 @@ describe("display source selection", () => { selectCaptureSource({ sources: [onlySource], host: "x11", primaryDisplayId: 42 }), ).toEqual(onlySource); expect(selectCaptureSource({ sources: [], host: "x11", primaryDisplayId: 42 })).toBeNull(); + expect( + selectCaptureSource({ + sources: [{ id: "first" }, { id: "second" }], + host: "x11", + primaryDisplayId: undefined, + }), + ).toBeNull(); }); it("accepts only the single portal-selected Wayland source", () => { diff --git a/server/local-computer.test.ts b/server/local-computer.test.ts index bd320983..bcb19d34 100644 --- a/server/local-computer.test.ts +++ b/server/local-computer.test.ts @@ -9,16 +9,25 @@ import { writeFileSync, } from "node:fs"; import { createServer } from "node:net"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + DRIVER_FILE_IDENTITY_KEYS, + REQUIRED_LINUX_TOOLS, decodeLinuxDescriptor, readCuaConnection, validateLinuxDescriptorRuntime, } from "./local-computer.ts"; +const require = createRequire(import.meta.url); +const { DRIVER_FILE_IDENTITY_KEYS: ELECTRON_DRIVER_FILE_IDENTITY_KEYS } = require( + "../electron/cua-linux.cjs", +); +const { REQUIRED_TOOLS: ELECTRON_REQUIRED_TOOLS } = require("../electron/cua-linux-runtime.cjs"); + function linuxDescriptor(userData: string, { session = "x11" }: { session?: "x11" | "wayland" } = {}) { const binary = join(userData, "cua-driver"); const socket = join(userData, "runtime", "driver.sock"); @@ -69,6 +78,13 @@ function linuxDescriptor(userData: string, { session = "x11" }: { session?: "x11 }; } +describe("local computer descriptor contract", () => { + it("stays synchronized with the Electron producer", () => { + expect(DRIVER_FILE_IDENTITY_KEYS).toEqual([...ELECTRON_DRIVER_FILE_IDENTITY_KEYS]); + expect(REQUIRED_LINUX_TOOLS).toEqual([...ELECTRON_REQUIRED_TOOLS]); + }); +}); + const temporaryDirectories: string[] = []; function privateUserData(name: string) { diff --git a/server/local-computer.ts b/server/local-computer.ts index aad6d79f..eec4fa50 100644 --- a/server/local-computer.ts +++ b/server/local-computer.ts @@ -3,10 +3,10 @@ import type { Stats } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; -const REQUIRED_LINUX_TOOLS = ["click", "get_window_state", "list_apps", "type_text"]; +export const REQUIRED_LINUX_TOOLS = ["click", "get_window_state", "list_apps", "type_text"]; // Keep this exact field set synchronized with DRIVER_FILE_IDENTITY_KEYS in // electron/cua-linux.cjs; Electron publishes it and the server revalidates it. -const DRIVER_FILE_IDENTITY_KEYS = [ +export const DRIVER_FILE_IDENTITY_KEYS = [ "dev", "ino", "uid", diff --git a/src/lib/local-computer.test.ts b/src/lib/local-computer.test.ts index 5b32a976..101a6450 100644 --- a/src/lib/local-computer.test.ts +++ b/src/lib/local-computer.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { Bot, InstanceInfo } from "@/state/store"; import { autoSelectsLocalComputer, instanceSupportsLocalComputer, @@ -7,17 +8,22 @@ import { describe("local computer UI eligibility", () => { it("requires the selected instance to advertise approval-capable local MCP", () => { - const bot = { modelSelection: { instanceId: "claude", model: "test" } }; + const bot = { + modelSelection: { instanceId: "claude", model: "test" }, + } satisfies Pick; const instances = [ { instanceId: "claude", capabilities: { localComputerMcp: true }, }, - ] as any; - expect(instanceSupportsLocalComputer(instances, bot as any)).toBe(true); - expect(instanceSupportsLocalComputer([{ ...instances[0], capabilities: {} }] as any, bot as any)).toBe( - false, - ); + ] satisfies Array>; + expect(instanceSupportsLocalComputer(instances as InstanceInfo[], bot)).toBe(true); + expect( + instanceSupportsLocalComputer( + [{ ...instances[0], capabilities: {} }] as InstanceInfo[], + bot, + ), + ).toBe(false); }); it("states that Linux Auto never selects this computer", () => { From 4e72e6a8a91eb3c18c59bcfb8afee17ec71983d0 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 14 Aug 2026 15:28:39 -0300 Subject: [PATCH 32/32] strengthen Linux runtime lifecycle tests --- electron/cua-linux-runtime.test.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/electron/cua-linux-runtime.test.mjs b/electron/cua-linux-runtime.test.mjs index 6849d667..b25cb00a 100644 --- a/electron/cua-linux-runtime.test.mjs +++ b/electron/cua-linux-runtime.test.mjs @@ -367,8 +367,11 @@ describe.skipIf(process.platform === "win32")("Linux CUA opt-in and lifecycle", vi.useFakeTimers(); try { const shutdown = context.runtime.shutdown(); - await vi.runAllTimersAsync(); - await shutdown; + await vi.advanceTimersByTimeAsync(0); + await expect(shutdown).resolves.toMatchObject({ + status: "stopped", + reasonCode: "app-stopped", + }); expect(context.child.kill).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); @@ -380,7 +383,8 @@ describe.skipIf(process.platform === "win32")("Linux CUA opt-in and lifecycle", const firstProbeGate = new Promise((resolve) => { releaseFirstProbe = resolve; }); - const children = [fakeChild(4321), fakeChild(4322)]; + const firstChild = fakeChild(4321); + const children = [firstChild, fakeChild(4322)]; const spawnProcess = vi.fn(() => children.shift()); const probe = vi .fn() @@ -399,6 +403,7 @@ describe.skipIf(process.platform === "win32")("Linux CUA opt-in and lifecycle", await expect(firstStart).resolves.toMatchObject({ status: "ready" }); await expect(retry).resolves.toMatchObject({ status: "ready", daemon: { pid: 4322 } }); expect(spawnProcess).toHaveBeenCalledTimes(2); + expect(firstChild.exitCode !== null || firstChild.signalCode !== null).toBe(true); }); it("starts on launch only after a durable prior opt-in and supports explicit disable", async () => {