diff --git a/.tmpexp/ext.mjs b/.tmpexp/ext.mjs deleted file mode 100644 index 846822b342..0000000000 --- a/.tmpexp/ext.mjs +++ /dev/null @@ -1,20 +0,0 @@ -const destinations = new WeakSet(); -const DRIVE = /^[A-Za-z]:(?:[\\/]|$)/; -const STILL_ESCAPES = new Set(["\\", "(", ")", "<", ">"]); -function enterDestination() { - this.buffer(); - destinations.add(this.stack[this.stack.length - 1]); -} -function exitCharacterEscapeValue(token) { - const tail = this.stack.pop(); - const value = this.sliceSerialize(token); - const separator = destinations.has(this.stack[this.stack.length - 1]) - && DRIVE.test(tail.value) - && !STILL_ESCAPES.has(value); - tail.value += separator ? `\\${value}` : value; - tail.position.end = { line: token.end.line, column: token.end.column, offset: token.end.offset }; -} -export const windowsPathDestinations = { - enter: { resourceDestinationString: enterDestination, definitionDestinationString: enterDestination }, - exit: { characterEscapeValue: exitCharacterEscapeValue }, -}; diff --git a/.tmpexp/r.mjs b/.tmpexp/r.mjs deleted file mode 100644 index bd63d2515a..0000000000 --- a/.tmpexp/r.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { fromMarkdown } from "mdast-util-from-markdown"; -import { toHast } from "../node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/index.js"; -import { windowsPathDestinations } from "./ext.mjs"; -const md = String.raw`[Report](C:\Users\Maus\.openmausbot\release notes.md) ![c](C:\Users\Maus\chart.png)`; -const md2 = String.raw`[Report]()`; -for (const m of [md, md2]) { - const tree = fromMarkdown(m, { mdastExtensions: [windowsPathDestinations] }); - const h = toHast(tree); - const out = []; - const walk = (n) => { if (n.properties?.href) out.push(["href", n.properties.href]); if (n.properties?.src) out.push(["src", n.properties.src]); (n.children||[]).forEach(walk); }; - walk(h); - console.log(JSON.stringify(m), out); -} -// markdownImageName behaviour -const name = (src) => { - try { const p = decodeURIComponent(new URL(src, "https://openmausbot.invalid").pathname); return [p, p.split(/[\\/]/).filter(Boolean).at(-1), p.split("/").filter(Boolean).at(-1)]; } catch (e) { return ["THREW", String(e)]; } -}; -console.log("name C:\\..\\chart.png ->", name("C:\\Users\\Maus\\chart.png")); -console.log("name C:\\..\\my chart.png ->", name("C:\\Users\\Maus\\my%20chart.png")); -console.log("name D:\\.hidden\\a.png ->", name("D:\\.hidden\\a.png")); diff --git a/.tmpexp/r.mjs.bak b/.tmpexp/r.mjs.bak deleted file mode 100644 index 5e2cd47002..0000000000 --- a/.tmpexp/r.mjs.bak +++ /dev/null @@ -1,20 +0,0 @@ -import { fromMarkdown } from "mdast-util-from-markdown"; -import { toHast } from "mdast-util-to-hast"; -import { windowsPathDestinations } from "./ext.mjs"; -const md = String.raw`[Report](C:\Users\Maus\.openmausbot\release notes.md) ![c](C:\Users\Maus\chart.png)`; -const md2 = String.raw`[Report]()`; -for (const m of [md, md2]) { - const tree = fromMarkdown(m, { mdastExtensions: [windowsPathDestinations] }); - const h = toHast(tree); - const out = []; - const walk = (n) => { if (n.properties?.href) out.push(["href", n.properties.href]); if (n.properties?.src) out.push(["src", n.properties.src]); (n.children||[]).forEach(walk); }; - walk(h); - console.log(JSON.stringify(m), out); -} -// markdownImageName behaviour -const name = (src) => { - try { const p = decodeURIComponent(new URL(src, "https://openmausbot.invalid").pathname); return [p, p.split(/[\\/]/).filter(Boolean).at(-1), p.split("/").filter(Boolean).at(-1)]; } catch (e) { return ["THREW", String(e)]; } -}; -console.log("name C:\\..\\chart.png ->", name("C:\\Users\\Maus\\chart.png")); -console.log("name C:\\..\\my chart.png ->", name("C:\\Users\\Maus\\my%20chart.png")); -console.log("name D:\\.hidden\\a.png ->", name("D:\\.hidden\\a.png")); diff --git a/.tmpexp/r.mjs.bak2 b/.tmpexp/r.mjs.bak2 deleted file mode 100644 index 0f4ff8cb34..0000000000 --- a/.tmpexp/r.mjs.bak2 +++ /dev/null @@ -1,20 +0,0 @@ -import { fromMarkdown } from "mdast-util-from-markdown"; -import { toHast } from "../node_modules/react-markdown/node_modules/mdast-util-to-hast/index.js"; -import { windowsPathDestinations } from "./ext.mjs"; -const md = String.raw`[Report](C:\Users\Maus\.openmausbot\release notes.md) ![c](C:\Users\Maus\chart.png)`; -const md2 = String.raw`[Report]()`; -for (const m of [md, md2]) { - const tree = fromMarkdown(m, { mdastExtensions: [windowsPathDestinations] }); - const h = toHast(tree); - const out = []; - const walk = (n) => { if (n.properties?.href) out.push(["href", n.properties.href]); if (n.properties?.src) out.push(["src", n.properties.src]); (n.children||[]).forEach(walk); }; - walk(h); - console.log(JSON.stringify(m), out); -} -// markdownImageName behaviour -const name = (src) => { - try { const p = decodeURIComponent(new URL(src, "https://openmausbot.invalid").pathname); return [p, p.split(/[\\/]/).filter(Boolean).at(-1), p.split("/").filter(Boolean).at(-1)]; } catch (e) { return ["THREW", String(e)]; } -}; -console.log("name C:\\..\\chart.png ->", name("C:\\Users\\Maus\\chart.png")); -console.log("name C:\\..\\my chart.png ->", name("C:\\Users\\Maus\\my%20chart.png")); -console.log("name D:\\.hidden\\a.png ->", name("D:\\.hidden\\a.png")); diff --git a/.tmpexp/run.mjs b/.tmpexp/run.mjs deleted file mode 100644 index 1d0854bea9..0000000000 --- a/.tmpexp/run.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import { fromMarkdown } from "mdast-util-from-markdown"; -import { windowsPathDestinations } from "./ext.mjs"; -const cases = [ - String.raw`[a](C:\Users\Maus\.openmausbot\report.md)`, - String.raw`![b]()`, - String.raw`[c]: C:\.cache\notes.md`, - String.raw`[a](C:\Apps\x\(1\).md)`, - String.raw`[u](\\nas\share\.private\report.md)`, - String.raw`[t](C:\a\b.md "C:\.title")`, - String.raw`[s](C:\Users\Maus\report.md) and prose C:\Users\Maus\.x`, - String.raw`[q](C:\Users\Maus\"quoted".md)`, - String.raw`[n](C:\Users\Maus\.a\.b\.c.md)`, - String.raw`[e](C:\)`, - String.raw`[f](C:\.\rel.md)`, - String.raw`[g](c:\users\x\_y\-z\!w.md)`, -]; -for (const c of cases) { - const tree = fromMarkdown(c, { mdastExtensions: [windowsPathDestinations] }); - const urls = []; - const walk = (n) => { if (n.url !== undefined) urls.push(n.url); (n.children||[]).forEach(walk); }; - walk(tree); - console.log(JSON.stringify(c), "=>", JSON.stringify(urls)); -} diff --git a/electron/app-permissions.node-test.mjs b/electron/app-permissions.node-test.mjs index 75a5c8934d..bb51819868 100644 --- a/electron/app-permissions.node-test.mjs +++ b/electron/app-permissions.node-test.mjs @@ -79,10 +79,11 @@ test("web links reject embedded credentials and non-web schemes", () => { }); test("both external-link entry points use the policy and IPC retains the local-origin gate", () => { - const main = readFileSync(new URL("./main.mjs", import.meta.url), "utf8"); - assert.match(main, /ipcMain\.handle\("desktop:open-external", localOnly\("desktop:open-external"/); - assert.match(main, /shell\.openExternal\(externalWebUrl\(rawUrl\)\)/); - assert.match(main, /shell\.openExternal\(externalWebUrl\(url\)\)/); + const createWindowModule = readFileSync(new URL("./main/create-window.mjs", import.meta.url), "utf8"); + const desktopIpc = readFileSync(new URL("./main/desktop-ipc.mjs", import.meta.url), "utf8"); + assert.match(desktopIpc, /ipcMain\.handle\("desktop:open-external", localOnly\("desktop:open-external"/); + assert.match(desktopIpc, /shell\.openExternal\(externalWebUrl\(rawUrl\)\)/); + assert.match(createWindowModule, /shell\.openExternal\(externalWebUrl\(url\)\)/); }); test("fails closed on unparsable or opaque origins", () => { diff --git a/electron/company-backup-main.node-test.mjs b/electron/company-backup-main.node-test.mjs index b448dcf223..72e9588062 100644 --- a/electron/company-backup-main.node-test.mjs +++ b/electron/company-backup-main.node-test.mjs @@ -12,15 +12,15 @@ import { createCompanyBackupSchedule } from "./company-backup-schedule.mjs"; // importing Electron main (which would start the app). All IO, connection state, // and transfer results are synthetic; these tests do not prove archive transport, // OS keychain storage, or a renderer workflow. -const mainSource = readFileSync(new URL("./main.mjs", import.meta.url), "utf8"); -function section(start, end) { - const from = mainSource.indexOf(start); - const to = mainSource.indexOf(end, from + start.length); +const moduleSource = readFileSync(new URL("./main/company-backup.mjs", import.meta.url), "utf8"); +function section(start, end, includeEnd = false) { + const from = moduleSource.indexOf(start); + const to = moduleSource.indexOf(end, from + start.length); assert.ok(from >= 0 && to > from, `Main-process test section moved: ${start}`); - return mainSource.slice(from, to); + return moduleSource.slice(from, includeEnd ? to + end.length : to); } const functions = section("function ensureManagedDesktop()", "function syncDesktopMutationToken("); -const registrations = section("const workspaceOnly =", "const savedWorkspace ="); +const registrations = section("const workspaceOnly =", " } finally { companyRestoreCommitting = false; }\n}));", true); const ORIGIN = "http://127.0.0.1:48799"; const STAGE = "11111111-1111-4111-8111-111111111111"; const DEVICE = "22222222-2222-4222-8222-222222222222"; diff --git a/electron/diagnostics.test.mjs b/electron/diagnostics.test.mjs index 655fc120d2..7f5e3bac21 100644 --- a/electron/diagnostics.test.mjs +++ b/electron/diagnostics.test.mjs @@ -18,12 +18,13 @@ const { } = require("./diagnostics.mjs"); // The desktop shell cannot import TypeScript, so its credential list is a -// hand copy of server/config.ts WORKSPACE_CREDENTIAL_ENV. This test is the +// hand copy of WORKSPACE_CREDENTIAL_ENV (server/config/credentials.ts). +// This test is the // drift alarm: a name added server-side without updating the copy here would // otherwise ship an unredacted export path. -describe("credential env parity with server/config.ts", () => { +describe("credential env parity with server/config/credentials.ts", () => { it("matches WORKSPACE_CREDENTIAL_ENV exactly", () => { - const config = readFileSync(new URL("../server/config.ts", import.meta.url), "utf8"); + const config = readFileSync(new URL("../server/config/credentials.ts", import.meta.url), "utf8"); const match = config.match(/WORKSPACE_CREDENTIAL_ENV = \[([\s\S]*?)\] as const/); expect(match).not.toBeNull(); const names = [...match[1].matchAll(/"([A-Z0-9_]+)"/g)].map((m) => m[1]); diff --git a/electron/main.mjs b/electron/main.mjs index 497310574b..515ebdbb84 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -1,2346 +1,313 @@ -import { app, autoUpdater as nativeAutoUpdater, BrowserWindow, WebContentsView, clipboard, desktopCapturer, dialog, ipcMain, Menu, nativeImage, powerMonitor, powerSaveBlocker, safeStorage, screen, session, shell, systemPreferences, utilityProcess } from "electron"; +import { app, autoUpdater as nativeAutoUpdater, BrowserWindow, desktopCapturer, dialog, ipcMain, Menu, powerMonitor, powerSaveBlocker, safeStorage, screen, session } from "electron"; import { createRequire } from "node:module"; -import { randomBytes, randomUUID } from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; +import { randomUUID } from "node:crypto"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { startCua, stopCua, registerCuaIpc, setCuaStateListener } from "./cua.mjs"; -import { createAndroidDeviceController } from "./android-device.mjs"; -import { finishSpeech, startSpeech, stopSpeech } from "./speech.mjs"; -import { openBlankTerminal } from "./terminal-launch.mjs"; -import { pasteMenuItem } from "./paste-menu-item.mjs"; -import { attachUpdaterWindow, startUpdater, registerUpdaterIpc } from "./updater.mjs"; -import { - buildDiagnosticsReport, - diagnosticsFileName, - formatDesktopCrashRecord, - installDesktopCrashListeners, - readSafeLogTail, -} from "./diagnostics.mjs"; -import { migrateWorkspaceCredentials, workspaceCredentialEnv } from "./workspace-credentials.mjs"; +import { stopSpeech } from "./speech.mjs"; +import { startUpdater, registerUpdaterIpc } from "./updater.mjs"; +import { installDesktopCrashListeners } from "./diagnostics.mjs"; import { activateExistingWindow, releaseSingleInstanceLock } from "./single-instance.mjs"; -import { pollServerIdentity } from "./server-boot-probe.mjs"; import { createServerSupervisor } from "./server-supervisor.mjs"; -import { packageUrlFromCommandLine, packageUrlFromDeepLink } from "./package-link.mjs"; -import { windowChromeOptions } from "./window-chrome.mjs"; -import { collisionFreeDownloadPath, defaultSaveName, withSavableFile } from "./save-file.mjs"; -import { desktopViewerPermissionAllowed } from "./desktop-viewer-permissions.mjs"; -import { appPermissionAllowed, externalWebUrl } from "./app-permissions.mjs"; -import { - ensureManagedComposioCredentials, - managedComposioAccess, - managedComposioChildEnvironment, - normalizeManagedComposioBrokerUrl, -} from "./managed-composio.mjs"; -import { - createManagedCompanionTunnel, - managedCompanionTunnelAccess, - resolveCloudflaredBinary, - resolveManagedCompanionGuardian, - withManagedCompanionTunnelAccess, - withoutManagedCompanionTunnelAccess, -} from "./managed-companion-tunnel.mjs"; -import { createSecureCredentialState } from "./secure-credential-state.mjs"; -import { - createPhoneSecretSaveCoordinator, - createPhoneSecretIdentity, - decodePhoneSecretSaveRequest, - phoneSecretPrivateKeyMessage, - readPhoneSecretIdentity, - withPhoneSecretIdentity, -} from "./phone-secret-identity.mjs"; +import { packageUrlFromCommandLine } from "./package-link.mjs"; +import { collisionFreeDownloadPath } from "./save-file.mjs"; +import { appPermissionAllowed } from "./app-permissions.mjs"; +import { ensureManagedComposioCredentials } from "./managed-composio.mjs"; import { desktopCompanionAccess, - desktopCompanionRendererArguments, pairDesktopCompanion, startDesktopCompanionRelay, withDesktopCompanionAccess, withoutDesktopCompanionAccess, } from "./desktop-companion-client.mjs"; -import { isKnownSkin, skinChrome } from "./skin-overlay.cjs"; -import { readSecureCredentials } from "./secure-credentials.mjs"; -import { createControlPlaneClient } from "./control-plane-client.mjs"; -import { - companionAccountCleanupPending, - createCompanionAccountService, - resolveCompanionControlPlaneURL, -} from "./companion-account-service.mjs"; import capabilitiesModule from "./capabilities.cjs"; -import environmentsModule from "./environments.cjs"; import localOriginModule from "./local-origin.cjs"; -import { buildApplicationMenu } from "./menu.mjs"; -import { createComputerSharing, validateSharedFolders } from "./computer-sharing.mjs"; -import { acquireDataDirLease } from "./data-dir-lease.mjs"; -import { createManagedDesktopClient, createManagedDesktopRelay, createManagedDesktopStore } from "./managed-desktop.mjs"; -import { createCompanyBackups } from "./company-backups.mjs"; -import { createCompanyBackupSchedule } from "./company-backup-schedule.mjs"; - -const { desktopCapabilities, nativeDesktopActions } = capabilitiesModule; -const nativeActions = nativeDesktopActions(process.platform); -const require = createRequire(import.meta.url); -const { createDisplayMediaGuard, invokeDisplayMediaCallback, selectCaptureSource } = require( - "./screen-preview.cjs", -); -const { STAGE_PREFIX: APPIMAGE_CUA_STAGE_PREFIX } = require("./cua-linux-bundle.cjs"); -const { desktopViewerUrl, sameDesktopViewerOrigin } = require("./desktop-viewer.cjs"); -const { createDesktopWorkspaceManager } = require("./desktop-workspace.cjs"); -const { createTrustedApprovalModeCoordinator } = require("./approval-trusted-mode.cjs"); -const { DESKTOP_MUTATION_HEADER, desktopServerHeaders } = require("./desktop-server-auth.cjs"); -const { MIN_BOUNDS, normalizeUnreadCount, parseWindowState, resolveWindowState } = require("./window-state.cjs"); - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// 127.0.0.1 explicitly — vite binds IPv4; a bare "localhost" here can -// resolve to ::1 and paint a black window -const DEV_URL = process.env.ELECTRON_START_URL ?? "http://127.0.0.1:5199"; -const DEFAULT_COMPOSIO_BROKER_URL = "https://openmausbot-composio.milindsoni201.workers.dev"; -let SERVER_PORT = 8799; -const APP_ICON = path.join(__dirname, "resources/app-icon.png"); -let desktopViewerWindow = null; -let desktopViewerOwner = null; -let desktopViewerContextId = null; -let desktopWorkspaceManager = null; -let desktopWorkspaceOwner = null; -let pendingPackageInstallUrl = packageUrlFromCommandLine(process.argv); -let mainWindow = null; -const serverUnavailableWindows = new WeakSet(); -let unreadCount = 0; -let unreadOverlayIcon = null; - -function windowStateFile() { - return path.join(app.getPath("userData"), "window-state.json"); -} - -function readWindowState() { - try { - return parseWindowState(fs.readFileSync(windowStateFile(), "utf8")); - } catch { - return null; - } -} - -function writeWindowState(win) { - if (!win || win.isDestroyed()) return; - const file = windowStateFile(); - const temporary = `${file}.${process.pid}.tmp`; - try { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync( - temporary, - JSON.stringify({ bounds: win.getNormalBounds(), maximized: win.isMaximized() }), - { mode: 0o600 }, - ); - fs.renameSync(temporary, file); - } catch (error) { - try { - fs.rmSync(temporary, { force: true }); - } catch {} - slog(`window state save failed: ${error?.message ?? error}`); - } -} - -function installWindowStatePersistence(win) { - let timer = null; - const flush = () => { - if (timer) clearTimeout(timer); - timer = null; - writeWindowState(win); - }; - const schedule = () => { - if (timer) clearTimeout(timer); - timer = setTimeout(flush, 250); - timer.unref?.(); - }; - win.on("resize", schedule); - win.on("move", schedule); - // The renderer's caption buttons track the native maximize state (the - // restore/maximize glyph flips); a lost push just leaves a stale glyph - // until the next toggle, so a send failure is not fatal. - const pushMaximized = () => { - try { - if (!win.isDestroyed()) win.webContents.send("window:maximized-changed", win.isMaximized()); - } catch {} - }; - win.on("maximize", pushMaximized); - win.on("unmaximize", pushMaximized); - win.on("maximize", schedule); - win.on("unmaximize", schedule); - win.on("close", flush); -} - -function applyUnreadBadge(win = mainWindow) { - const count = normalizeUnreadCount(unreadCount); - if (process.platform === "win32") { - if (!win || win.isDestroyed()) return; - unreadOverlayIcon ??= nativeImage.createFromPath(APP_ICON).resize({ width: 16, height: 16 }); - win.setOverlayIcon( - count > 0 && !unreadOverlayIcon.isEmpty() ? unreadOverlayIcon : null, - count > 0 ? `${count} unread conversation${count === 1 ? "" : "s"}` : "No unread conversations", - ); - return; - } - if (process.platform === "darwin" || process.platform === "linux") app.setBadgeCount(count); -} - -// GNOME groups the window with its installed desktop entry only when both -// identities match. This must run before Electron becomes ready. Ubuntu also -// uses Chromium's software renderer: the supported machine reproduced two -// NVIDIA/libGLES GPU-process crashes that left an invisible focused window -// intercepting input. This app is not graphics-heavy, so reliability wins. -if (process.platform === "linux") { - app.disableHardwareAcceleration(); - app.setDesktopName("com.openmausbot.app.desktop"); -} - -// One instance per user: without this lock a second launch forks a second -// harness server on a fallback port and splits data dirs in two. The loser -// exits before any child or window exists; the winner surfaces itself. -if (!app.requestSingleInstanceLock()) { - console.log("[desktop] OpenMausBot is already running — focusing that window"); - process.exit(0); -} - -// An update install can start the new build while this process is still -// inside the deferred before-quit cleanup further down, still holding the -// lock; the relaunched copy then loses the check above and exits, leaving a -// dead Starting window with no server. Electron's native autoUpdater emits -// before-quit-for-update only when an update drives the quit (the vendored -// electron-updater re-emits it on the same object before app.quit()), so the -// lock is released on that event — never in before-quit, where a normal quit -// would allow a concurrent second instance. -nativeAutoUpdater.on("before-quit-for-update", () => releaseSingleInstanceLock(app)); - -function deliverPackageInstall(win) { - if (!pendingPackageInstallUrl || !win || win.isDestroyed()) return; - if (win.webContents.isLoadingMainFrame()) return; - // A package installs into THIS computer's workspace, so it is handed to the - // local UI only. Showing a remote server: switch back to Local first; the - // pending link is delivered when that page finishes loading. - let showingLocal = false; - try { - showingLocal = new URL(win.webContents.getURL()).origin === rendererOrigin(); - } catch {} - if (!showingLocal) { - if (activeEnvironment(environmentsState)) void workspaceMenuAction(() => switchEnvironment(LOCAL_ID)); - return; - } - win.webContents.send("package:install", pendingPackageInstallUrl); - pendingPackageInstallUrl = null; -} - -function queuePackageInstall(rawLink) { - const packageUrl = packageUrlFromDeepLink(rawLink); - if (!packageUrl) return false; - pendingPackageInstallUrl = packageUrl; - activateExistingWindow(BrowserWindow.getAllWindows()); - const target = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()); - deliverPackageInstall(target); - return true; -} - -app.on("open-url", (event, url) => { - if (!queuePackageInstall(url)) return; - event.preventDefault(); -}); - -app.on("second-instance", (_event, commandLine) => { - const packageUrl = packageUrlFromCommandLine(commandLine); - if (packageUrl) pendingPackageInstallUrl = packageUrl; - activateExistingWindow(BrowserWindow.getAllWindows()); - const target = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()); - deliverPackageInstall(target); -}); - -// Packaged: the harness server ships in Resources (compiled JS, zero deps) -// and runs on Electron's own Node via utilityProcess. It serves the built -// UI too, so the window talks to one origin and there is no dev proxy. -// A stray server on the default port must not brick the app — fall back to -// alternate ports until one binds AND identifies as ours (the probe checks -// our API shape, not just a 200). -let serverProc = null; -let serverReady = !app.isPackaged; -let secureCredentials = {}; -let secureCredentialState = null; -let desktopDataDirLease = null; -let managedDesktop = null; -let companyBackupController = null; -let companyBackupState = { busy: false }; -let preparedCompanyRestore = null; -let companyBackupSchedule = null; -let companyBackupClientStateRequest = null; -let companyRestoreCommitting = false; -let companyBackupConfigurationRevision = 0; -const managedDesktopRelay = createManagedDesktopRelay(); -const utilityServerExits = new WeakMap(); -const UTILITY_SERVER_STOP_TIMEOUT_MS = 6_500; -const trustedApprovalMode = createTrustedApprovalModeCoordinator({ randomId: randomUUID }); -const desktopMutationToken = randomBytes(32).toString("base64url"); -const companionMutationToken = randomBytes(32).toString("base64url"); -const serverSupervisor = createServerSupervisor({ - restart: () => startServerOn(SERVER_PORT), - stop: stopUtilityServer, - onReady(proc) { - serverProc = proc; - serverReady = true; - serverStartConflictOnly = false; - slog(`server ready pid=${proc.pid} port=${SERVER_PORT}`); - // Re-read the latest account credentials; registration may have completed - // while the replacement child's health probe was pending. - syncManagedComposioCredentials(); - if (managedDesktop) void managedDesktop.refresh().catch(() => {}); - routineWake.start(); - // Existing chat windows reconnect in place, preserving unsent drafts. - // A window opened during the outage is still on our error page instead. - for (const win of BrowserWindow.getAllWindows()) { - if (!serverUnavailableWindows.has(win) || activeEnvironment(environmentsState)) continue; - void win.loadURL(`http://127.0.0.1:${SERVER_PORT}`).then(() => { - serverUnavailableWindows.delete(win); - }).catch((error) => { - slog(`recovered server window failed to load: ${error?.message ?? error}`); - }); - } - }, - onUnavailable() { - serverReady = false; - serverProc = null; - companyBackupSchedule?.reconcile(); - // nothing to hold for while the scheduler is down; polling resumes on ready - routineWake.stop(); - }, - onExhausted() { - slog("server recovery paused after repeated failures; quit and reopen to retry"); - dialog.showErrorBox( - "The bot server stopped", - "Automatic recovery could not restart the background server. Quit and reopen OpenMausBot to try again. Interrupted chat turns were not resent.\n\n" + - `Server log: ${path.join(LOG_DIR, "server.log")}`, - ); - }, - log: slog, -}); - -function desktopDataDir() { - // Match the historical desktop fallback for an unset or empty override, - // then pass this exact resolved path to the utility child. server/config.ts - // intentionally treats an empty OMB_DATA_DIR differently, so inheriting it - // without normalization would lease one directory and write another. - return process.env.OMB_DATA_DIR || path.join(app.getPath("home"), ".openmausbot"); -} - -async function stopUtilityServer(proc, timeoutMs = UTILITY_SERVER_STOP_TIMEOUT_MS) { - if (!proc) return true; - const exited = utilityServerExits.get(proc); - if (!exited) return false; - try { - proc.kill(); - } catch { - // The tracked exit promise below is still the authority. A throw can mean - // the process crossed the exit boundary immediately before kill(). - } - let timer; - return Promise.race([ - exited.then(() => true), - new Promise((resolve) => { - timer = setTimeout(() => resolve(false), timeoutMs); - timer.unref?.(); - }), - ]).finally(() => clearTimeout(timer)); -} -let phoneSecretIdentity = null; -let desktopRemoteAccess = null; -let desktopCompanionRelay = null; - -const CREDENTIALS_FILE = path.join(app.getPath("userData"), "credentials.bin"); - -/** Set once per launch: true when the store could not be READ, which is not - * the same as the user having saved nothing. Everything downstream — the - * server's view of "configured", and whether we may register a fresh - * installation — keys off this rather than off an empty object. */ -let credentialStoreUnavailable = false; - -async function loadSecureCredentials() { - const result = await readSecureCredentials({ - exists: () => fs.existsSync(CREDENTIALS_FILE), - isAvailable: () => safeStorage.isAsyncEncryptionAvailable(), - readFile: () => fs.readFileSync(CREDENTIALS_FILE), - decrypt: (buffer) => safeStorage.decryptStringAsync(buffer), - sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), - }); - credentialStoreUnavailable = result.status === "unavailable"; - if (credentialStoreUnavailable) { - // Deliberately loud. A silent {} here is what made a keychain hiccup - // look like "your connected apps are gone". - slog(`credential store unreadable after retries (${result.error}); saved keys are not loaded this launch`); - } - return result.credentials; -} - -async function saveSecureCredentials(credentials) { - // A failed read means we do not know what the existing encrypted document - // contains. Never derive a replacement from that incomplete view: boot - // migrations must leave plaintext in place so a later launch can retry. - if (credentialStoreUnavailable) { - throw new Error("The operating-system credential store could not be read this launch"); - } - if (!(await safeStorage.isAsyncEncryptionAvailable())) { - throw new Error("The operating-system credential store is unavailable"); - } - fs.mkdirSync(path.dirname(CREDENTIALS_FILE), { recursive: true }); - const encrypted = await safeStorage.encryptStringAsync(JSON.stringify(credentials)); - const temporary = `${CREDENTIALS_FILE}.${process.pid}.tmp`; - fs.writeFileSync(temporary, encrypted, { mode: 0o600 }); - fs.renameSync(temporary, CREDENTIALS_FILE); -} - -async function secureComposioConfig() { - const dataDir = desktopDataDir(); - const configPath = path.join(dataDir, "config.json"); - try { - const config = JSON.parse(fs.readFileSync(configPath, "utf8")); - if (!config?.composio || typeof config.composio !== "object") return; - let changed = false; - const apiKey = config?.composio?.apiKey; - if (typeof apiKey === "string" && apiKey.trim().startsWith("ak_")) { - if (!secureCredentials.composioApiKey) { - secureCredentials.composioApiKey = apiKey.trim(); - await saveSecureCredentials(secureCredentials); - } - config.composio.apiKey = ""; - changed = true; - } else if (typeof apiKey === "string" && apiKey.trim()) { - config.composio.apiKey = ""; - changed = true; - } - // These were the old Connect credential and endpoint. They are no longer - // read; remove them during the upgrade so an unused secret is not left in - // plaintext indefinitely. - for (const field of ["key", "url"]) { - if (Object.hasOwn(config.composio, field)) { - delete config.composio[field]; - changed = true; - } - } - if (!changed) return; - const temporary = `${configPath}.${process.pid}.tmp`; - fs.writeFileSync(temporary, JSON.stringify(config, null, 2), { mode: 0o600 }); - fs.renameSync(temporary, configPath); - } catch (error) { - if (error?.code !== "ENOENT") slog(`credential migration failed: ${error?.message ?? error}`); - } -} - -// The remaining workspace credentials (xai/box/voice/OpenCode keys) get -// the same at-rest treatment as the Composio key above. New packaged-app -// saves go straight through credential:set below; this boot-time sweep also -// migrates plaintext left by older versions or direct development clients. -// See workspace-credentials.mjs for the exact rules. -async function secureWorkspaceConfig() { - const dataDir = desktopDataDir(); - const configPath = path.join(dataDir, "config.json"); - try { - const config = JSON.parse(fs.readFileSync(configPath, "utf8")); - const migrated = migrateWorkspaceCredentials(config, secureCredentials); - // credentials.bin first: if the OS store cannot take the secrets, the - // plaintext stays put and the next boot retries — losing the only copy - // is the one unacceptable outcome - if (migrated.credentialsChanged) await saveSecureCredentials(migrated.credentials); - secureCredentials = migrated.credentials; - if (!migrated.configChanged) return; - const temporary = `${configPath}.${process.pid}.tmp`; - fs.writeFileSync(temporary, JSON.stringify(migrated.config, null, 2), { mode: 0o600 }); - fs.renameSync(temporary, configPath); - } catch (error) { - if (error?.code !== "ENOENT") slog(`credential migration failed: ${error?.message ?? error}`); - } -} - -function composioBrokerUrl() { - const configured = process.env.OMB_COMPOSIO_BROKER_URL?.trim(); - return normalizeManagedComposioBrokerUrl( - configured || (app.isPackaged ? DEFAULT_COMPOSIO_BROKER_URL : ""), - ); -} - -// The packaged app has no terminal: everything about the server child's life -// goes to server.log in the OS log dir (~/Library/Logs/OpenMausBot on macOS, -// Console.app-visible; %APPDATA%\OpenMausBot\logs on Windows), which is also -// why stdio is piped, not inherited — under a Finder/Explorer launch the -// parent's stdio leads nowhere and a failed boot is otherwise undiagnosable. -const LOG_DIR = app.getPath("logs"); -const DESKTOP_CRASH_LOG = path.join(LOG_DIR, "desktop-crashes.log"); -const DESKTOP_CRASH_LOG_MAX_BYTES = 512 * 1024; -let logStream = null; -let desktopShutdownStarted = false; -import { - companionAdvertisedHostedUrl, - companionEnabledAtRest, - companionOriginTarget, - companionPairing, - companionRefreshTailscale, - companionCloudDesktopAccess, - companionRevoke, - companionRunning, - companionState, - rememberCompanionEnabled, - rememberCompanionKeepAwake, - setCompanionHostedUrl, - setCompanionLifecycleListener, - startCompanion, - stopCompanion, -} from "./companion.mjs"; -import { createRoutineWakeHold, rememberRoutineWake, routineWakeSettings } from "./routine-wake.mjs"; - -/** IPC that controls this computer, its files, its logins or its updater is - * answered only for the local server's UI (electron/local-origin.cjs). A - * remote server's page gets a reduced bridge (preload.cjs) in the first - * place; this is the second wall, shared with cua.mjs, updater.mjs and - * android-device.mjs. Declared before any handler registration below: a - * const declared later would be in its temporal dead zone at module load. - */ -const { isLocalSender: senderIsLocal, localOnly, localOnlySync, setLocalOrigin } = localOriginModule; - -let companionPowerBlocker = null; - -function syncCompanionKeepAwake(companionEnabled, keepAwake) { - const shouldBlock = companionEnabled && keepAwake; - if (shouldBlock && companionPowerBlocker === null) { - companionPowerBlocker = powerSaveBlocker.start("prevent-app-suspension"); - } else if (!shouldBlock && companionPowerBlocker !== null) { - if (powerSaveBlocker.isStarted(companionPowerBlocker)) powerSaveBlocker.stop(companionPowerBlocker); - companionPowerBlocker = null; - } -} - -// Keep this computer awake for scheduled routines (electron/routine-wake.mjs): -// the scheduler lives in the local server, which cannot run while the Mac -// sleeps. One power assertion, held for the hour before a due routine and -// while a run is in flight, plugged in only; the server says when. -const routineWake = createRoutineWakeHold({ - fetchStatus: () => (serverReady - ? fetch(`http://127.0.0.1:${SERVER_PORT}/api/routines/wake`, { signal: AbortSignal.timeout(5_000), redirect: "error", credentials: "omit" }) - .then((response) => (response.ok ? response.json() : null)) - : Promise.resolve(null)), - isOnBattery: () => { - try { - return powerMonitor.isOnBatteryPower(); - } catch { - return false; - } - }, - blocker: powerSaveBlocker, - settings: () => routineWakeSettings(app.getPath("userData")), - log: (line) => slog(line), -}); - -function slog(line) { - try { - if (!logStream) { - fs.mkdirSync(LOG_DIR, { recursive: true }); - logStream = fs.createWriteStream(path.join(LOG_DIR, "server.log"), { flags: "a" }); - } - logStream.write(`[${new Date().toISOString()}] ${line}\n`); - } catch { - /* logging must never break startup */ - } -} - -// The server stream is intentionally asynchronous, but a fatal main-process -// exception may terminate Electron before such a write is flushed. Crash -// metadata gets its own tiny synchronous file. The formatter admits only a -// fixed set of fields, so renderer URLs, page titles, exception messages and -// absolute paths never land on disk or in a public bug report. -function recordDesktopCrash(event) { - let handle = null; - try { - const record = formatDesktopCrashRecord(event); - if (!record) return; - fs.mkdirSync(LOG_DIR, { recursive: true }); - - const flags = - fs.constants.O_WRONLY | - fs.constants.O_APPEND | - (process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW); - let before = null; - try { - before = fs.lstatSync(DESKTOP_CRASH_LOG); - if (!before.isFile() || before.nlink !== 1) return; - handle = fs.openSync(DESKTOP_CRASH_LOG, flags); - } catch (error) { - if (error?.code !== "ENOENT") return; - // O_EXCL makes first creation race-safe on Windows, where O_NOFOLLOW is - // unavailable, as well as on POSIX. - try { - handle = fs.openSync( - DESKTOP_CRASH_LOG, - flags | fs.constants.O_CREAT | fs.constants.O_EXCL, - 0o600, - ); - } catch { - return; - } - } - - const stats = fs.fstatSync(handle); - // A hard-linked or non-regular target is not an app-owned crash log. - if (!stats.isFile() || stats.nlink !== 1) return; - if (before && (before.dev !== stats.dev || before.ino !== stats.ino)) return; - // A renderer crash loop must not grow a persistent log without bound. - // The diagnostics export reads only a bounded tail, so dropping older - // crash metadata here preserves the useful part of the record. - if (stats.size >= DESKTOP_CRASH_LOG_MAX_BYTES) fs.ftruncateSync(handle, 0); - if (process.platform !== "win32") fs.fchmodSync(handle, 0o600); - fs.writeFileSync(handle, `[${new Date().toISOString()}] ${record}\n`, "utf8"); - } catch { - /* crash diagnostics must never change app lifecycle */ - } finally { - if (handle !== null) { - try { - fs.closeSync(handle); - } catch {} - } - } -} - -// uncaughtExceptionMonitor observes Node's fatal path without converting it -// into a handled exception. In particular, an unhandled rejection still -// follows Node's normal exit behaviour after its metadata is persisted. -installDesktopCrashListeners({ - appTarget: app, - processTarget: process, - record: recordDesktopCrash, - isShuttingDown: () => desktopShutdownStarted, - mainWebContents: () => mainWindow?.webContents ?? null, -}); - -// ── managed companion connection ─────────────────────────────────────── -// Account onboarding provisions one remote Cloudflare Tunnel per desktop, -// then calls reconcileManagedCompanionEndpointProvision below. Only the -// endpoint is public state. The connector token stays in credentials.bin and -// is passed to cloudflared through a private token file by the lifecycle -// module — never through IPC, argv, the environment, or logs. -let managedCompanionConnector = null; -let companionAccountService = null; -let companionDesiredThisLaunch = false; -let companionLaunchGeneration = 0; -let advertisementTransition = Promise.resolve(); - -/** The one serialized credential mutation hook. Account onboarding and every - * other runtime credential writer share this state, so persisting a tunnel - * token can never overwrite an API key saved at the same time (or vice - * versa). */ -export async function updateSecureCredentialDocument(derive, afterPersist) { - if (!secureCredentialState) throw new Error("Secure credentials are not ready"); - try { - return await secureCredentialState.update(derive, afterPersist); - } finally { - secureCredentials = secureCredentialState.read(); - } -} - -async function ensurePhoneSecretIdentity() { - const existing = readPhoneSecretIdentity(secureCredentialState?.read() ?? secureCredentials); - if (existing) { - phoneSecretIdentity = existing; - return existing; - } - try { - const created = await createPhoneSecretIdentity(); - await updateSecureCredentialDocument((credentials) => - withPhoneSecretIdentity(credentials, created), - ); - phoneSecretIdentity = created; - return created; - } catch (error) { - // Companion chat remains available. Pairing simply omits the public key, - // and mobile cards explain that secure entry needs the desktop until the - // OS credential store is available on a later launch. - phoneSecretIdentity = null; - slog(`phone credential key unavailable: ${error?.message ?? error}`); - return null; - } -} - -function publicManagedCompanionState() { - const access = managedCompanionTunnelAccess(secureCredentials); - const status = managedCompanionConnector?.getStatus(); - if (status) { - const publicState = { - status: status.status, - configured: status.configured, - ready: status.ready, - }; - if (status.endpoint) publicState.url = status.endpoint; - if (status.retryInMs) publicState.retryInMs = status.retryInMs; - if (status.error) publicState.error = status.error; - return publicState; - } - return access - ? { status: "stopped", configured: true, ready: false, url: access.endpoint } - : { status: "unconfigured", configured: false, ready: false }; -} - -function decorateDesktopCompanionState(state) { - // The panel polls this state, so a sidecar that exited on its own releases - // the blocker within one poll instead of keeping the computer awake forever. - syncCompanionKeepAwake(state.enabled && !state.error, state.keepAwake === true); - return { ...state, managedConnection: publicManagedCompanionState() }; -} - -async function desktopCompanionState() { - return decorateDesktopCompanionState(await companionState()); -} - -function companionLaunchOptions(hostedUrl = null) { - return { - resourcesPath: process.resourcesPath, - harnessPort: SERVER_PORT, - mutationToken: companionMutationToken, - hostedUrl, - // Only an embedded server receives the private half over its utility - // port. A dev server launched in another terminal cannot decrypt, so it - // must not advertise a public key and strand the phone on a dead path. - secretPublicKey: app.isPackaged && serverProc ? phoneSecretIdentity?.publicKey ?? null : null, - log: slog, - }; -} - -function ensureManagedCompanionConnector() { - if (managedCompanionConnector) return managedCompanionConnector; - managedCompanionConnector = createManagedCompanionTunnel({ - binaryPath: resolveCloudflaredBinary({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - appPath: app.getAppPath(), - }), - guardianEntry: resolveManagedCompanionGuardian({ appPath: app.getAppPath() }), - runtimeExecutable: process.execPath, - runtimeRoot: path.join(app.getPath("userData"), "managed-companion-tunnel"), - onChange: (status) => { - slog(`managed companion connection ${status.status}`); - if (!companionDesiredThisLaunch) return; - void reconcileCompanionAdvertisement(status.ready ? status.endpoint : null); - }, - log: slog, - }); - return managedCompanionConnector; -} - -/** Publish a hosted address only after its connector has passed public health - * verification. Updating the owned sidecar in place preserves the exact - * private origin generation and cannot invalidate an open pairing window. */ -function reconcileCompanionAdvertisement( - endpoint, - ownedGeneration = companionLaunchGeneration, -) { - const normalizedEndpoint = endpoint || null; - const work = advertisementTransition.then(async () => { - if ( - ownedGeneration !== companionLaunchGeneration || - !companionDesiredThisLaunch || - !companionRunning() || - companionAdvertisedHostedUrl() === normalizedEndpoint - ) { - return desktopCompanionState(); - } - const updated = await setCompanionHostedUrl(normalizedEndpoint); - return { ...updated, managedConnection: publicManagedCompanionState() }; - }); - advertisementTransition = work.then( - () => {}, - () => {}, - ); - return work; -} - -async function startManagedCompanionConnection({ waitForVerification = true } = {}) { - if (companionAccountCleanupPending(secureCredentials)) { - return publicManagedCompanionState(); - } - const access = managedCompanionTunnelAccess(secureCredentials); - if (!access) return publicManagedCompanionState(); - const target = companionOriginTarget(); - if (!target) return publicManagedCompanionState(); - const operation = ensureManagedCompanionConnector().start({ ...access, originTarget: target }); - if (!waitForVerification) { - void operation.catch(() => {}); - return publicManagedCompanionState(); - } - const status = await operation; - await reconcileCompanionAdvertisement(status.ready ? status.endpoint : null); - return publicManagedCompanionState(); -} - -async function startDesktopCompanion({ waitForHosted = true, remember = true } = {}) { - companionDesiredThisLaunch = true; - companionLaunchGeneration += 1; - // Direct LAN comes up first. The hosted endpoint is added in place only - // after the guardian has verified the public route to this exact sidecar. - const localState = await startCompanion(companionLaunchOptions()); - if (!localState.enabled || localState.error) { - companionDesiredThisLaunch = false; - return desktopCompanionState(); - } - if (remember) rememberCompanionEnabled(true); - await startManagedCompanionConnection({ waitForVerification: waitForHosted }); - return desktopCompanionState(); -} - -async function stopDesktopCompanion({ remember = true } = {}) { - companionDesiredThisLaunch = false; - companionLaunchGeneration += 1; - if (remember) rememberCompanionEnabled(false); - syncCompanionKeepAwake(false, false); - await managedCompanionConnector?.stop(); - await stopCompanion(); - return desktopCompanionState(); -} - -async function refreshDesktopCompanionTailscale() { - if (!companionRunning()) { - const started = await startDesktopCompanion({ waitForHosted: false }); - if (!started.enabled || started.error) return started; - } - return decorateDesktopCompanionState(await companionRefreshTailscale()); -} - -setCompanionLifecycleListener(({ expected, pid }) => { - if (expected) return; - slog(`owned companion exited unexpectedly pid=${pid ?? "unknown"}`); - companionDesiredThisLaunch = false; - companionLaunchGeneration += 1; - syncCompanionKeepAwake(false, false); - // stop() invalidates the guardian's owner pipe synchronously, before the - // sidecar module removes this generation's private socket. - void managedCompanionConnector?.stop().catch(() => {}); -}); - -/** Narrow main-process hook for the account onboarding flow. Its return value - * is explicitly secret-free and can be used to refresh the settings panel. */ -export async function reconcileManagedCompanionEndpointProvision(provision) { - await updateSecureCredentialDocument((credentials) => - withManagedCompanionTunnelAccess(credentials, provision), - ); - if (companionDesiredThisLaunch) { - await startManagedCompanionConnection({ waitForVerification: true }); - } - return publicManagedCompanionState(); -} - -/** Called only after the control plane has revoked/deleted the endpoint. */ -export async function clearManagedCompanionEndpointCredentials() { - await updateSecureCredentialDocument((credentials) => - withoutManagedCompanionTunnelAccess(credentials), - ); - await managedCompanionConnector?.stop(); - if (companionDesiredThisLaunch) await reconcileCompanionAdvertisement(null); - return publicManagedCompanionState(); -} - -/** Account sign-out must stop advertising the hosted route before it asks - * the control plane to revoke anything, but it must not erase the retry - * credentials until that remote cleanup is durably scheduled. */ -async function stopManagedCompanionEndpointLocally() { - await managedCompanionConnector?.stop(); - if (companionDesiredThisLaunch) await reconcileCompanionAdvertisement(null); - return publicManagedCompanionState(); -} - -async function activatePersistedManagedCompanionEndpoint() { - if (companionDesiredThisLaunch) { - return startManagedCompanionConnection({ waitForVerification: true }); - } - return publicManagedCompanionState(); -} - -function installationDisplayName() { - const hostname = [...os.hostname()] - .filter((character) => character.codePointAt(0) >= 32 && character.codePointAt(0) !== 127) - .join("") - .trim(); - return hostname.slice(0, 80) || "This computer"; -} - -function ensureCompanionAccountService() { - if (companionAccountService) return companionAccountService; - const baseURL = resolveCompanionControlPlaneURL({ - isPackaged: app.isPackaged, - environment: process.env, - }); - let client = null; - if (baseURL) { - try { - client = createControlPlaneClient({ baseURL }); - } catch { - // An invalid explicit override disables hosted access. Direct LAN, - // Bonjour, and Tailscale pairing remain completely independent. - } - } - companionAccountService = createCompanionAccountService({ - client, - readCredentials: () => secureCredentialState?.read() ?? secureCredentials, - updateCredentials: updateSecureCredentialDocument, - identity: { - name: installationDisplayName(), - platform: - process.platform === "win32" - ? "windows" - : process.platform === "darwin" - ? "darwin" - : "linux", - appVersion: app.getVersion().slice(0, 64), - }, - newClientInstanceId: randomUUID, - activatePersistedEndpoint: activatePersistedManagedCompanionEndpoint, - stopManagedEndpoint: stopManagedCompanionEndpointLocally, - managedConnectionState: publicManagedCompanionState, - companionIsOn: () => companionDesiredThisLaunch, - }); - return companionAccountService; -} - -// Everything the bug-report bundle needs. The config summary comes from the -// server's own booleans-only /api/config status (credentials are never -// echoed), and the log goes through the redactor in diagnostics.mjs — so the -// file is safe to paste into a public issue even if a future log line ever -// carried a secret. -async function gatherDiagnostics() { - const serverStatus = await fetch(`http://127.0.0.1:${SERVER_PORT}/api/config`, { - signal: AbortSignal.timeout(3_000), - }) - .then((res) => (res.ok ? res.json() : null)) - .catch(() => null); - const logPath = path.join(LOG_DIR, "server.log"); - const log = readSafeLogTail(logPath); - const desktopLog = readSafeLogTail(DESKTOP_CRASH_LOG); - const updaterLog = readSafeLogTail(path.join(LOG_DIR, "updater.log")); - return buildDiagnosticsReport({ - appInfo: { - version: app.getVersion(), - platform: process.platform, - arch: process.arch, - electron: process.versions.electron, - node: process.versions.node, - packaged: app.isPackaged, - uptimeSeconds: Math.round(process.uptime()), - }, - configSummary: serverStatus ?? {}, - desktopLogTail: desktopLog?.tail ?? "", - updaterLogTail: updaterLog?.tail ?? "", - logTail: log?.tail ?? "", - }); -} - -// Set by startServerPackaged: true only when every failing candidate port was -// taken by another process — decides which error-page message renders. -let serverStartConflictOnly = false; - - - - - - - -/** Run one private cleanup request at most once and acknowledge only after - * Chromium confirms its session data is gone. Duplicate retries join the - * same promise; a retry whose success ACK was lost receives a cached ACK. */ - -function syncPhoneSecretKey(proc) { - const message = phoneSecretPrivateKeyMessage(phoneSecretIdentity); - if (!message) return; - try { - proc.postMessage(message); - } catch (error) { - slog(`phone credential key sync failed: ${error?.message ?? error}`); - } -} - -function ensureManagedDesktop() { - if (managedDesktop) return managedDesktop; - if (!app.isPackaged || desktopRemoteAccess) throw new Error("Organisation sign-in requires the installed desktop app running on this computer."); - const store = createManagedDesktopStore({ - file: path.join(app.getPath("userData"), "company-connection.bin"), - encryption: { - available: async () => (await safeStorage.isAsyncEncryptionAvailable()) && - (process.platform !== "linux" || safeStorage.getSelectedStorageBackend() !== "basic_text"), - encrypt: value => safeStorage.encryptStringAsync(value), - decrypt: value => safeStorage.decryptStringAsync(value), - }, - }); - managedDesktop = createManagedDesktopClient({ - store, platform: process.platform, deviceName: os.hostname().slice(0, 100) || "My computer", - applyConnection: connection => managedDesktopRelay.send(serverProc, connection), - openBrowser: url => shell.openExternal(url), - onState: state => { - if (["signed-out", "reauth-required"].includes(state.status) || (state.status === "connected" && !state.cloudBackups)) { - companyBackupConfigurationRevision++; - companyBackupController?.abort(); - preparedCompanyRestore = null; - void companyBackupSchedule?.forget().catch(() => {}); - publishCompanyBackupState({ busy: Boolean(companyBackupController) }); - } - companyBackupSchedule?.reconcile(); - // Remote pages never receive local identity events, even if they were - // loaded in this window after an earlier local subscription. - if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.mainFrame.url.startsWith(`${rendererOrigin()}/`) && - !activeEnvironment(environmentsState) && !desktopRemoteAccess) { - mainWindow.webContents.send("organization:state-changed", state); - } - }, - }); - companyBackupSchedule = createCompanyBackupSchedule({ - store: createManagedDesktopStore({ - file: path.join(app.getPath("userData"), "company-backup-schedule.bin"), - encryption: { - available: async () => (await safeStorage.isAsyncEncryptionAvailable()) && - (process.platform !== "linux" || safeStorage.getSelectedStorageBackend() !== "basic_text"), - encrypt: value => safeStorage.encryptStringAsync(value), - decrypt: value => safeStorage.decryptStringAsync(value), - }, - }), - scope: companyBackupScope, - run: async (signal, scope) => { - if (companyBackupController || preparedCompanyRestore || companyRestoreCommitting || desktopShutdownStarted) throw companyBackupDeferred(); - const proc = serverProc; - const status = await localBackupStatus(proc); - if (status.busy || status.pendingRestore) throw companyBackupDeferred(); - const clientState = await collectCompanyBackupClientState(signal, scope, proc); - signal.throwIfAborted(); - const current = companyBackupScope(); - if (!current || current.key !== scope.key || current.generation !== scope.generation || proc !== serverProc || preparedCompanyRestore || companyRestoreCommitting) throw companyBackupDeferred(); - return runCompanyBackup("backup", { clientState }, { signal, scope }); - }, - onState: schedule => publishCompanyBackupState({ ...companyBackupState, schedule }), - }); - return managedDesktop; -} - -function companyBackupScope() { - if (desktopShutdownStarted || desktopRemoteAccess || !serverReady || !serverProc || activeEnvironment(environmentsState)) return null; - const client = managedDesktop, connection = client?.connection(), state = client?.state(); - if (!connection || state?.status !== "connected" || !state.cloudBackups || connection.expiresAt <= Date.now()) return null; - return { key: JSON.stringify([connection.portalOrigin, connection.organizationId, connection.email, connection.deviceId, path.resolve(desktopDataDir())]), - generation: client.backupGeneration() }; -} - -function companyBackupDeferred() { - return Object.assign(new Error("Wait for the local workspace to be available for its daily backup."), { code: "workspace_busy" }); -} - -function collectCompanyBackupClientState(signal, scope, proc) { - const win = mainWindow, contents = win?.webContents, frame = contents?.mainFrame; - if (companyBackupClientStateRequest || !win || win.isDestroyed() || desktopRemoteAccess || activeEnvironment(environmentsState) || - !frame?.url.startsWith(`${rendererOrigin()}/`)) return Promise.reject(companyBackupDeferred()); - return new Promise((resolve, reject) => { - const requestId = randomUUID(); - const finish = (error, value) => { - if (companyBackupClientStateRequest?.requestId !== requestId) return; - companyBackupClientStateRequest = null; - clearTimeout(timer); signal.removeEventListener("abort", abort); - if (error) reject(error); else resolve(value); - }; - const abort = () => finish(companyBackupDeferred()); - const timer = setTimeout(abort, 10_000); timer.unref?.(); - companyBackupClientStateRequest = { requestId, win, contents, frame, url: frame.url, scope, proc, finish }; - signal.addEventListener("abort", abort, { once: true }); - if (signal.aborted) { abort(); return; } - try { contents.send("company-backups:collect-client-state", { requestId }); } - catch { abort(); } - }); -} - -function receiveCompanyBackupClientState(event, input) { - const pending = companyBackupClientStateRequest; - if (!pending || input?.requestId !== pending.requestId || event.sender !== pending.contents || event.senderFrame !== pending.frame) return; - const scope = companyBackupScope(); - if (pending.win !== mainWindow || mainWindow.isDestroyed() || pending.url !== pending.frame.url || - !workspaceSenderAllowed(event, mainWindow.webContents, environmentsState, rendererOrigin()) || - procUnavailable() || !scope || scope.key !== pending.scope.key || scope.generation !== pending.scope.generation) { - pending.finish(companyBackupDeferred()); return; - } - function procUnavailable() { return pending.proc !== serverProc || !serverReady || desktopRemoteAccess || desktopShutdownStarted; } - const value = input.clientState; - if (input.unavailable || !value || typeof value !== "object" || Array.isArray(value) || - Object.values(value).some(entry => typeof entry !== "string") || Buffer.byteLength(JSON.stringify(value)) > 2 * 1024 ** 2) { - pending.finish(companyBackupDeferred()); return; - } - pending.finish(null, Object.fromEntries(Object.entries(value))); -} - -function publishCompanyBackupState(value) { - companyBackupState = { ...value, ...(companyBackupSchedule ? { schedule: companyBackupSchedule.state() } : {}) }; - if (mainWindow && !mainWindow.isDestroyed() && !activeEnvironment(environmentsState) && !desktopRemoteAccess && - mainWindow.webContents.mainFrame.url.startsWith(`${rendererOrigin()}/`)) mainWindow.webContents.send("company-backups:state-changed", companyBackupState); -} - -function localBackupRequest(proc, route, init = {}) { - if (!proc || proc !== serverProc || !serverReady || !/^\/api\/workspace-backup\/(?:status|export|upload|preview|restore|download\/[A-Za-z0-9_-]+)$/.test(route)) { - throw new Error("The local workspace changed. Start this backup operation again."); - } - return fetch(`http://127.0.0.1:${SERVER_PORT}${route}`, { ...init, redirect: "error", credentials: "omit", - headers: { ...Object.fromEntries(new Headers(init.headers)), [DESKTOP_MUTATION_HEADER]: desktopMutationToken } }); -} - -async function localBackupStatus(proc) { - const response = await localBackupRequest(proc, "/api/workspace-backup/status", { signal: AbortSignal.timeout(10_000) }); - if (!response.ok) throw new Error("Local backup status is unavailable. Try again when the workspace is ready."); - const status = await response.json(); - if (proc !== serverProc || typeof status.busy !== "boolean" || typeof status.pendingRestore !== "boolean") throw new Error("The workspace changed. Check backup status again."); - return status; -} - -async function runCompanyBackup(kind, input, scheduled = null) { - if (companyBackupController || companyRestoreCommitting || desktopShutdownStarted || (scheduled && preparedCompanyRestore)) throw companyBackupDeferred(); - const client = ensureManagedDesktop(), connection = client.connection(); - if (!connection || !client.state().cloudBackups) throw new Error("Connect your organisation and ask its administrator to enable cloud backups first."); - const generation = client.backupGeneration(); - if (scheduled) { - scheduled.signal.throwIfAborted(); - const scope = companyBackupScope(); - if (!scope || scope.key !== scheduled.scope.key || scope.generation !== scheduled.scope.generation) throw companyBackupDeferred(); - } - const proc = serverProc, controller = new AbortController(); - const cancelScheduled = () => controller.abort(); - scheduled?.signal.addEventListener("abort", cancelScheduled, { once: true }); - const deadline = setTimeout(() => controller.abort(), 2 * 60 * 60_000); deadline.unref?.(); - companyBackupController = controller; - preparedCompanyRestore = null; - publishCompanyBackupState({ busy: true, kind }); - const progress = progress => publishCompanyBackupState({ busy: true, kind, progress }); - try { - const status = await localBackupStatus(proc); - if (status.pendingRestore) { - publishCompanyBackupState({ busy: false, pendingRestore: true }); - throw new Error("Restart OpenMausBot to finish the pending restore before starting another backup operation."); - } - if (status.busy) throw companyBackupDeferred(); - const transfers = createCompanyBackups({ - tempRoot: path.join(app.getPath("temp"), "openmaus-company-backups"), - localRequest: (route, init) => localBackupRequest(proc, route, init), - portalRequest: (route, options) => client.requestBackup(route, { ...options, generation }), - availableBytes: async temporary => { - const volumes = await Promise.all([fs.promises.statfs(temporary), fs.promises.statfs(desktopDataDir())]); - return Math.min(...volumes.map(volume => volume.bavail * volume.bsize)); - }, - }); - const result = kind === "backup" ? await transfers.backup({ ...input, appVersion: app.getVersion() }, controller.signal, progress) - : await transfers.prepareRestore(input, controller.signal, progress); - controller.signal.throwIfAborted(); - if (proc !== serverProc || client.backupGeneration() !== generation || client.connection()?.deviceId !== connection.deviceId) throw new Error("The workspace connection changed."); - if (kind === "restore") preparedCompanyRestore = { id: result.id, proc, deviceId: connection.deviceId }; - publishCompanyBackupState({ busy: false, ...(kind === "backup" ? { lastBackupAt: Date.now() } : {}) }); - return result; - } catch (error) { - const message = controller.signal.aborted ? "Cloud backup cancelled. Your workspace has not been replaced." - : error?.name === "CompanyBackupError" ? error.message : "Cloud backup could not complete. Check your organisation connection and available disk space, then try again."; - publishCompanyBackupState({ busy: false, pendingRestore: companyBackupState.pendingRestore, message }); - throw Object.assign(new Error(message), error?.code === "workspace_busy" ? { code: "workspace_busy" } : {}); - } finally { - clearTimeout(deadline); - scheduled?.signal.removeEventListener("abort", cancelScheduled); - if (companyBackupController === controller) companyBackupController = null; - } -} - -function syncDesktopMutationToken(proc) { - try { - proc.postMessage({ - type: "openmausbot:desktop-mutation-token", - token: desktopMutationToken, - companionToken: companionMutationToken, - }); - } catch (error) { - slog(`desktop mutation capability sync failed: ${error?.message ?? error}`); - } -} - -function installDesktopMutationHeader() { - session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { - let ownsTarget = false; - try { - const target = new URL(details.url); - ownsTarget = serverReady && target.protocol === "http:" && - target.hostname === "127.0.0.1" && - Number(target.port || 80) === SERVER_PORT; - } catch {} - if (!ownsTarget) { - callback({ requestHeaders: details.requestHeaders }); - return; - } - callback({ - requestHeaders: { - ...details.requestHeaders, - [DESKTOP_MUTATION_HEADER]: desktopMutationToken, - }, - }); - }); -} - -const savePhoneSecretOnce = createPhoneSecretSaveCoordinator((target, value) => - saveWorkspaceCredential(target, value), -); - -function receivePhoneSecretSave(proc, rawMessage) { - const request = decodePhoneSecretSaveRequest(rawMessage); - if (!request) return false; - void savePhoneSecretOnce(request).then((result) => { - try { - proc.postMessage(result); - } catch (error) { - slog(`phone credential save result failed: ${error?.message ?? error}`); - } - }); - return true; -} - -async function startServerOn(port) { - if (desktopShutdownStarted) return { proc: null, abort: true }; - const entry = path.join(process.resourcesPath, "server", "index.js"); - const childEnv = managedComposioChildEnvironment(composioBrokerUrl(), secureCredentials, { - ...process.env, - // The desktop parent owns the durable data-directory lease. Each utility - // server gets only a private capability that validates that same live - // owner; fallback-port children must not race to replace the parent lease. - ...desktopDataDirLease.utilityServerLeaseEnvironment(), - OMB_DATA_DIR: desktopDataDir(), - // A packaged utility child must never fall back to a descriptor inherited - // from the launching shell. It starts fail-closed until this exact main - // process sends the private in-memory connection after spawn. - OMB_DESKTOP_PARENT: "1", - OMB_STATIC_DIR: path.join(process.resourcesPath, "ui"), - OMB_RESOURCES_PATH: process.resourcesPath, - OMB_SKILLS_DIR: path.join(process.resourcesPath, "skills"), - OMB_PORT: String(port), - // the server advertises this to remote clients so version skew is visible - OMB_APP_VERSION: app.getVersion(), - OMB_USER_DATA: app.getPath("userData"), - ...(secureCredentials.composioApiKey - ? { COMPOSIO_API_KEY: secureCredentials.composioApiKey } - : {}), - // "we could not read your keys" must not reach the UI as "you have none" - OMB_CREDENTIAL_STORE: credentialStoreUnavailable ? "unavailable" : "ok", - // one env var per stored workspace secret (xai/box/voice/OpenCode Go); - // the server prefers these over config.json, whose plaintext fields - // the boot migration has deleted - ...workspaceCredentialEnv(secureCredentials), - }); - delete childEnv.OMB_BROWSER_CONNECTION; - slog(`fork ${entry} port=${port}`); - const proc = utilityProcess.fork(entry, [], { - env: childEnv, - stdio: ["ignore", "pipe", "pipe"], - }); - let resolveServerExit; - utilityServerExits.set(proc, new Promise((resolve) => { - resolveServerExit = resolve; - })); - proc.stdout?.on("data", (d) => slog(`[out] ${String(d).trimEnd()}`)); - proc.stderr?.on("data", (d) => slog(`[err] ${String(d).trimEnd()}`)); - proc.on("message", (message) => { - if (!serverSupervisor.isCurrent(proc)) return; - try { - if (trustedApprovalMode.receive(proc, message)) return; - if (managedDesktopRelay.receive(proc, message)) return; - if (receivePhoneSecretSave(proc, message)) return; - } catch (error) { - slog(`desktop private sync rejected: ${error?.message ?? error}`); - } - }); - proc.once("spawn", () => { - slog(`spawned pid=${proc.pid}`); - if (!serverSupervisor.isCurrent(proc)) return; - syncDesktopMutationToken(proc); - syncPhoneSecretKey(proc); - }); - let exited = false; - proc.once("exit", (code) => { - exited = true; - trustedApprovalMode.rejectProcess(proc); - managedDesktopRelay.rejectProcess(proc); - resolveServerExit(); - slog(`exited code=${code}`); - }); - serverSupervisor.watch(proc); - // wait for the port to answer (fresh machine: first boot writes data dirs). - // Identity check is by PID: a dev harness server has the same API shape, - // so only the child we actually forked (matching pid + static serving) - // counts as ours. - // The budget is wall-clock, not a fixed poll count: a healthy boot can take - // well past 20s on cold machines or when pre-listen network calls stall - // (issue #506), and reaping an about-to-listen child reads to the user as - // "something else is using its ports" even though nothing was on them. - // The probe itself is deadline-bounded (a hung health endpoint cannot wedge - // us here forever) and reports WHY it gave up, so the error page can tell - // port conflict apart from slow startup. - const identity = await pollServerIdentity({ - port, - // Getter, not value: proc.pid stays undefined until the async `spawn` - // event fires, and capturing it here would make the probe judge our own - // child a "foreign owner" on its first health answer. - pid: () => proc.pid, - bootTimeoutMs: SERVER_BOOT_TIMEOUT_MS, - isExited: () => exited || desktopShutdownStarted, - }); - if (identity.outcome === "ready" && serverSupervisor.isCurrent(proc)) return { proc }; - if (identity.outcome === "exited") { - slog(`child on port ${port} exited before answering /api/health`); - } else { - slog( - identity.outcome === "foreign-owner" - ? `port ${port} answered health checks from another process` - : `child on port ${port} did not answer /api/health within ${SERVER_BOOT_TIMEOUT_MS / 1000}s`, - ); - } - const stopped = await stopUtilityServer(proc); - if (!stopped) { - slog(`child on port ${port} did not exit after termination; refusing to start a sibling server`); - } - return { proc: null, reason: stopped ? identity.outcome : "stuck-child", abort: !stopped }; -} - -async function startServerPackaged() { - // two passes: a quit-and-reopen relaunch can race the dying instance's - // server during teardown — one settle-and-retry covers it - let everyPortForeignOwned = true; - for (let attempt = 0; attempt < 2; attempt++) { - for (const port of [8799, 18799, 28799]) { - if (desktopShutdownStarted) return false; - const started = await startServerOn(port); - if (started.proc) { - SERVER_PORT = port; - if (serverSupervisor.ready(started.proc)) return true; - } - if (started.abort) return false; - // A child that exited or timed out is not evidence of a port conflict — - // only "another process answered health checks" is. - if (started.reason !== "foreign-owner") everyPortForeignOwned = false; - } - await new Promise((r) => setTimeout(r, 2500)); - } - serverStartConflictOnly = everyPortForeignOwned; - return false; -} - -function syncManagedComposioCredentials() { - if (!serverProc) return; - try { - serverProc.postMessage({ - type: "openmausbot:managed-composio", - access: managedComposioAccess(composioBrokerUrl(), secureCredentials), - }); - } catch (error) { - slog(`connected-apps credential sync failed: ${error?.message ?? error}`); - } -} - -// The page is built at failure time (not import time): the message depends on -// how the boot failed, and the log path comes from LOG_DIR so Windows and -// Linux users see their real location instead of a macOS guess. The link -// opens the log through the window's setWindowOpenHandler, which routes to -// the platform handler. -function escapeHtml(value) { - return value.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`); -} - -function buildErrorPage({ allPortsOccupied }) { - const serverLogPath = path.join(LOG_DIR, "server.log"); - const serverLogHref = pathToFileURL(serverLogPath).href; - const reason = allPortsOccupied - ? "Every OpenMausBot port answered health checks from another process — likely a second copy of the app, or another program on ports 8799–28799. Quit that program, then quit and reopen OpenMausBot." - : "The background server didn't come up in time — this is usually slow startup, not a port conflict. Quit and reopen OpenMausBot."; - return ( - "data:text/html;charset=utf-8," + - encodeURIComponent( - `
🐭

Couldn't start the bot server

${escapeHtml(reason)} If it keeps happening, check ${escapeHtml(serverLogPath)}.

`, - ) - ); -} - -// How long one packaged-server child gets to answer /api/health before the -// parent reaps it and tries the next port. Wall-clock, deliberately generous: -// first boots write data dirs and pre-listen network calls (managed composio, -// workspace credentials) can stall a healthy child far past 20s on some -// machines, which used to surface as the misleading "ports are busy" page. -const SERVER_BOOT_TIMEOUT_MS = 60_000; - -let cuaReady = Promise.resolve({ mode: "unavailable", reason: "not-started" }); -const androidDevice = createAndroidDeviceController({ resourcesPath: process.resourcesPath }); -const displayMediaGuard = createDisplayMediaGuard(); -let displayMediaRequestCount = 0; - -function rendererOrigin() { - return new URL(app.isPackaged || desktopRemoteAccess ? `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); - } -} - -function notifyDesktopViewer(open) { - if (!desktopViewerOwner?.isDestroyed()) { - desktopViewerOwner.send("desktop-viewer:state", { - open, - contextId: desktopViewerContextId, - }); - } -} - -function desktopViewerErrorPage(message, retryUrl) { - const escape = (value) => - String(value) - .replaceAll("&", "&") - .replaceAll('"', """) - .replaceAll("<", "<") - .replaceAll(">", ">"); - return ( - "data:text/html;charset=utf-8," + - encodeURIComponent(`Desktop unavailable - -

Couldn't open the live desktop

-

${escape(message)}

- Open in browser
- `) - ); -} - -function openDesktopViewer(owner, rawUrl, rawTitle, contextId) { - if (!owner || owner.isDestroyed()) throw new Error("The OpenMausBot window is unavailable"); - const url = desktopViewerUrl(rawUrl); - const titleCandidate = Object.prototype.toString.call(rawTitle) === "[object String]" ? rawTitle.trim() : ""; - const title = titleCandidate ? titleCandidate.slice(0, 80) : "Live desktop"; - - const nextContextId = - Object.prototype.toString.call(contextId) === "[object String]" ? contextId.slice(0, 120) : null; - - // Desktop URLs contain rotating access tokens. A newly minted URL replaces - // the old viewer instead of being retained anywhere after its window closes. - // Clear the ref first so the stale window's close handler no-ops; on a bot - // change, tell the previous bot to release (same-bot reopen stays quiet). - if (desktopViewerWindow && !desktopViewerWindow.isDestroyed()) { - const previous = desktopViewerWindow; - const previousOwner = desktopViewerOwner; - const previousContextId = desktopViewerContextId; - desktopViewerWindow = null; - previous.close(); - if (previousContextId !== nextContextId && previousOwner && !previousOwner.isDestroyed()) { - previousOwner.send("desktop-viewer:state", { open: false, contextId: previousContextId }); - } - } - desktopViewerOwner = owner.webContents; - desktopViewerContextId = nextContextId; - - const viewer = new BrowserWindow({ - width: 1220, - height: 820, - minWidth: 760, - minHeight: 520, - parent: owner, - // Not modal: the person still needs the app's "Hand control back" button - // while the desktop is open. `parent` keeps it floating above the app. - modal: false, - show: false, - title, - icon: APP_ICON, - backgroundColor: "#070707", - autoHideMenuBar: true, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - sandbox: true, - // Keep provider cookies away from the app renderer and discard them on - // app exit. The secret-bearing URL is sufficient to authenticate. - partition: "openmausbot-desktop-viewer", - }, - }); - desktopViewerWindow = viewer; - const viewerOrigin = url.origin; - - // VNC needs rendering, keyboard/mouse input and WebSockets, plus the few - // permission-gated input capabilities a viewer page asks for: keyboard and - // pointer capture, the clipboard for paste, full screen. Those go to the - // viewer's own origin only — never camera, microphone, geolocation, - // notifications, USB, or any other privileged browser capability in this - // remote-content window (see desktop-viewer-permissions.mjs). - viewer.webContents.session.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => - desktopViewerPermissionAllowed(permission, requestingOrigin, viewerOrigin), - ); - viewer.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => - callback(desktopViewerPermissionAllowed(permission, details?.requestingUrl || webContents.getURL(), viewerOrigin)), - ); - - // A child window floats above the app but does not take the keyboard until - // it is focused: clicks land in the VNC canvas either way, keystrokes only - // reach the key window. Left unfocused, typing "into the VM" lands in the - // composer and ⌘1–9 switch bots while the mouse appears to work. - viewer.once("ready-to-show", () => { - if (viewer.isDestroyed()) return; - viewer.show(); - viewer.focus(); - viewer.webContents.focus(); - }); - viewer.on("closed", () => { - if (desktopViewerWindow !== viewer) return; - desktopViewerWindow = null; - // The panel drops its "viewer open" state and releases control on this. - notifyDesktopViewer(false); - desktopViewerOwner = null; - desktopViewerContextId = null; - }); - viewer.on("page-title-updated", (event) => { - event.preventDefault(); - viewer.setTitle(title); - }); - viewer.webContents.setWindowOpenHandler(({ url: target }) => { - try { - const external = desktopViewerUrl(target); - void shell.openExternal(external.toString()); - } catch { - // Ignore non-web and insecure URLs from the remote viewer. - } - return { action: "deny" }; - }); - viewer.webContents.on("will-navigate", (event, target) => { - if (sameDesktopViewerOrigin(target, viewerOrigin)) return; - event.preventDefault(); - try { - void shell.openExternal(desktopViewerUrl(target).toString()); - } catch { - // Keep privileged or malformed navigation out of the viewer. - } - }); - viewer.webContents.on("did-fail-load", (_event, code, description, failedUrl, isMainFrame) => { - if (!isMainFrame || code === -3 || viewer.isDestroyed() || failedUrl.startsWith("data:")) return; - void viewer.loadURL(desktopViewerErrorPage(description || "The viewer did not respond.", url.toString())); - }); - - notifyDesktopViewer(true); - void viewer.loadURL(url.toString()).catch((error) => { - if (viewer.isDestroyed()) return; - void viewer.loadURL(desktopViewerErrorPage(error?.message ?? "The viewer did not respond.", url.toString())); - }); - return true; -} - -function ensureDesktopWorkspace(owner) { - if (!owner || owner.isDestroyed()) throw new Error("The OpenMausBot window is unavailable"); - if (desktopWorkspaceManager) { - if (desktopWorkspaceOwner !== owner) { - throw new Error("The desktop workspace belongs to another app window"); - } - return desktopWorkspaceManager; - } - - desktopWorkspaceOwner = owner; - const manager = createDesktopWorkspaceManager({ - owner, - createView: (options) => new WebContentsView(options), - partitionPrefix: `openmausbot-desktop-workspace-${randomUUID()}`, - notify: (state) => { - if (!owner.isDestroyed() && !owner.webContents.isDestroyed()) { - owner.webContents.send("desktop-workspace:state", state); - } - }, - }); - desktopWorkspaceManager = manager; - - // Native child views outlive the renderer DOM unless we explicitly tear - // them down. Reloads, renderer crashes and owner destruction all close both - // panes without retaining their secret-bearing noVNC URLs. - owner.webContents.on("did-start-navigation", (_event, _url, isInPlace, isMainFrame) => { - if (isMainFrame && !isInPlace) manager.closeAll(); - }); - owner.webContents.on("render-process-gone", () => manager.closeAll()); - owner.once("closed", () => { - manager.closeAll(); - if (desktopWorkspaceManager === manager) { - desktopWorkspaceManager = null; - desktopWorkspaceOwner = null; - } - }); - return manager; -} - -function desktopWorkspaceForEvent(event, create = false) { - const owner = mainWindow; - if (!owner || owner.isDestroyed() || event.sender !== owner.webContents) { - throw new Error("The desktop workspace is available only to the main app window"); - } - if (desktopWorkspaceManager && desktopWorkspaceOwner !== owner) { - throw new Error("The desktop workspace belongs to another app window"); - } - return create ? ensureDesktopWorkspace(owner) : desktopWorkspaceManager; -} - - -ipcMain.on("screen:preview-intent", localOnlySync("screen:preview-intent", (event) => { - event.returnValue = displayMediaGuard.begin(event.senderFrame); -})); - -ipcMain.on("desktop:unread-count", (event, value) => { - const sender = BrowserWindow.fromWebContents(event.sender); - if (!sender || sender !== mainWindow || sender.isDestroyed()) return; - unreadCount = normalizeUnreadCount(value); - applyUnreadBadge(sender); -}); - -// ── environments: this computer's server, or a paired remote one ────── -// The app switches by loading the chosen server's own UI (electron/menu.mjs). -// Only {id, name, origin} is stored here; the session credential is the -// HttpOnly cookie /pair set for that origin, kept by Chromium's cookie jar. -const { LOCAL_ID, activeEnvironment, allowedOrigins, parseEnvironments, parseHostedWorkspaceLink, serializeEnvironments, withActive, withEnvironment, withoutEnvironment, workspaceMenuTemplate, workspaceNavigationAllowed, workspaceSenderAllowed, workspaceSummary } = environmentsModule; -let environmentsState = { environments: [], activeId: LOCAL_ID }; -let computerSharing; -const sharingPrompts = new Set(); - -// Opt-in computer sharing is gated by the server this desktop runs, the same -// way every other server setting reaches this process: the booleans-only -// /api/config status (server/index.ts configStatus → features). It is read -// before the connector could start and again whenever a workspace control is -// used, so a maintainer who edits config.json and restarts the server does not -// have to reinstall the app. Unreachable or older server → off. -let sharedComputersAllowed = false; - -async function refreshSharedComputersAllowed() { - sharedComputersAllowed = await fetch(`http://127.0.0.1:${SERVER_PORT}/api/config`, { signal: AbortSignal.timeout(3_000) }) - .then((res) => (res.ok ? res.json() : null)) - .then((status) => status?.features?.sharedComputers === true) - .catch(() => false); - return sharedComputersAllowed; -} - -/** Refuse a workspace sharing control the server would refuse anyway. */ -async function requireSharedComputers() { - if (await refreshSharedComputersAllowed()) return; - throw new Error("Computer sharing is turned off on this server."); -} - -function sharingController() { - computerSharing ??= createComputerSharing({ - file: path.join(app.getPath("userData"), "computer-sharing.json"), - // The harness server's data directory holds provider API keys and - // sessions.json, so a broad share must never reach it either. - protectedPaths: [desktopDataDir()], - fetch: (...args) => session.defaultSession.fetch(...args), - environments: () => environmentsState.environments, - enabled: refreshSharedComputersAllowed, - cuaConnection: () => cuaReady, - hostControl: async (id, signal) => { - const lease = async action => { - const response = await fetch(`http://127.0.0.1:${SERVER_PORT}/api/desktop/shared-computer-control`, { - method: "POST", - headers: desktopServerHeaders({ "content-type": "application/json" }, { packaged: app.isPackaged, token: desktopMutationToken }), - body: JSON.stringify({ id, action }), - signal: action === "release" ? AbortSignal.timeout(3000) : AbortSignal.any([signal, AbortSignal.timeout(3000)]), - }); - if (!response.ok) throw new Error("This computer is in use locally or held by a person. Wait, then observe it again before acting."); - }; - await lease("acquire"); - return { renew: () => lease("acquire"), release: () => lease("release") }; - }, - }); - return computerSharing; -} - -async function offerComputerSharing(win) { - const env = activeEnvironment(environmentsState); - if (!env || sharingPrompts.has(env.id) || win.isDestroyed()) return; - // Never offer a grant this build's server will not honour. - if (!(await refreshSharedComputersAllowed()) || win.isDestroyed()) return; - sharingPrompts.add(env.id); - try { - const info = await sharingController().observe(env); - if (!info || win.isDestroyed() || activeEnvironment(environmentsState)?.id !== env.id || new URL(win.webContents.getURL()).origin !== env.origin) return; - const choice = await dialog.showMessageBox(win, { - type: "question", message: `Share this computer with ${env.name}?`, - detail: "Let this workspace’s bots use folders and capabilities you choose while this desktop app is running. Nothing is shared unless you enable it. You can change this later in Settings → Connected workspaces.", - buttons: ["Choose access", "Not now"], defaultId: 1, cancelId: 1, - }); - sharingController().decline(env, info); - if (choice.response === 0) openWorkspaceSettings(env.id); - } catch { /* Not paired yet, an older server, or offline: no grant, no prompt. */ } - finally { sharingPrompts.delete(env.id); } -} - -function environmentsFile() { - return path.join(app.getPath("userData"), "environments.json"); -} - -function readEnvironments() { - try { - return parseEnvironments(fs.readFileSync(environmentsFile(), "utf8")); - } catch { - return { environments: [], activeId: LOCAL_ID }; - } -} - -function writeEnvironments(state) { - const file = environmentsFile(); - const temporary = `${file}.${process.pid}.tmp`; - try { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(temporary, serializeEnvironments(state), { mode: 0o600 }); - fs.renameSync(temporary, file); - } catch (error) { - try { - fs.rmSync(temporary, { force: true }); - } catch {} - slog(`environments save failed: ${error?.message ?? error}`); - throw new Error("Could not save workspace connections on this computer. Please try again."); - } -} - -/** Where the main window should be: the active remote server, else Local. */ -function activeOrigin() { - return activeEnvironment(environmentsState)?.origin ?? rendererOrigin(); -} - - -function refreshApplicationMenu() { - Menu.setApplicationMenu( - buildApplicationMenu({ - environments: environmentsState.environments, - activeId: environmentsState.activeId, - onSwitch: (id) => void workspaceMenuAction(() => switchEnvironment(id)), - onAddFromClipboard: () => void addServerFromClipboard(), - onConnect: () => void workspaceMenuAction(openWorkspaceSettings), - onForget: (id) => void workspaceMenuAction(() => forgetEnvironment(id)), - onOpenSettings: () => { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("app:open-settings"); - }, - }), - ); -} - -function persistEnvironments(next) { - writeEnvironments(next); - environmentsState = next; - refreshApplicationMenu(); -} +import { validateSharedFolders } from "./computer-sharing.mjs"; +import { acquireDataDirLease } from "./data-dir-lease.mjs"; +import { + LOG_DIR, + recordDesktopCrash, + slog, +} from "./main/crash-log.mjs"; +import { + composioBrokerUrl, + credentialStoreUnavailable, + desktopDataDir, + initializeSecureCredentialStore, + secureCredentials, + updateSecureCredentialDocument, +} from "./main/secure-config.mjs"; +import { + SERVER_PORT, + serverProc, + serverReady, + stopUtilityServer, + setServerPort, + setServerReady, + adoptUtilityServer, + markServerUnavailable, +} from "./main/server-runtime.mjs"; +import { APP_ICON } from "./main/desktop-viewer.mjs"; +import { + decorateDesktopCompanionState, + desktopCompanionState, + ensureCompanionAccountService, + ensurePhoneSecretIdentity, + installationDisplayName, + refreshDesktopCompanionTailscale, + startDesktopCompanion, + stopDesktopCompanion, + syncCompanionKeepAwake, +} from "./main/companion-connection.mjs"; + +export { + clearManagedCompanionEndpointCredentials, + reconcileManagedCompanionEndpointProvision, +} from "./main/companion-connection.mjs"; + +import { mainWindow } from "./main/main-window.mjs"; +import { wireDesktopIpc } from "./main/desktop-ipc.mjs"; +import { createWindow, wireCreateWindowDeps } from "./main/create-window.mjs"; +import { + LOCAL_ID, + activeEnvironment, + computerSharing, + connectHostedWorkspace, + environmentsState, + forgetEnvironment, + openWorkspaceSettings, + readEnvironments, + refreshApplicationMenu, + refreshSharedComputersAllowed, + rendererOrigin, + requireSharedComputers, + setEnvironmentsState, + sharingController, + switchEnvironment, + workspaceMenuAction, + workspaceMenuTemplate, + workspaceSummary, + wireEnvironmentsDeps, +} from "./main/environments.mjs"; +import { + companyBackupController, + companyBackupSchedule, + desktopMutationToken, + desktopRemoteAccess, + desktopShutdownStarted, + ensureManagedDesktop, + localWorkspaceOnly, + managedDesktop, + setDesktopRemoteAccess, + setDesktopShutdownStarted, + workspaceOnly, +} from "./main/company-backup.mjs"; +import { + deliverPackageInstall, + queuePackageInstall, + serverUnavailableWindows, + setPendingPackageInstallUrl, +} from "./main/unread-badge.mjs"; +import { + installDesktopMutationHeader, + startServerOn, + startServerPackaged, + syncManagedComposioCredentials, + wireServerBootDeps, +} from "./main/server-boot.mjs"; +import { + androidDevice, + bumpDisplayMediaRequestCount, + cuaReady, + displayMediaGuard, + getCuaReady, + respondToDisplayMediaRequest, + setCuaReady, +} from "./main/cua-media.mjs"; -async function workspaceMenuAction(action) { - try { await action(); } catch (error) { - await dialog.showMessageBox({ type: "error", message: "Could not update workspaces", detail: error.message }); - } -} +const { desktopCapabilities, nativeDesktopActions } = capabilitiesModule; +const nativeActions = nativeDesktopActions(process.platform); +const require = createRequire(import.meta.url); +const { selectCaptureSource } = require("./screen-preview.cjs"); +const { createTrustedApprovalModeCoordinator } = require("./approval-trusted-mode.cjs"); +const { desktopServerHeaders } = require("./desktop-server-auth.cjs"); -function navigateMainWindow(url) { - if (!mainWindow || mainWindow.isDestroyed()) return; - // did-fail-load shows the connection error and returns to the local app. - void mainWindow.loadURL(url).catch(() => {}); -} +const __dirname = path.dirname(fileURLToPath(import.meta.url)); -function switchEnvironment(id) { - if (id === environmentsState.activeId || (id !== LOCAL_ID && !environmentsState.environments.some((entry) => entry.id === id))) return; - persistEnvironments(withActive(environmentsState, id)); - navigateMainWindow(activeOrigin()); +// GNOME groups the window with its installed desktop entry only when both +// identities match. This must run before Electron becomes ready. Ubuntu also +// uses Chromium's software renderer: the supported machine reproduced two +// NVIDIA/libGLES GPU-process crashes that left an invisible focused window +// intercepting input. This app is not graphics-heavy, so reliability wins. +if (process.platform === "linux") { + app.disableHardwareAcceleration(); + app.setDesktopName("com.openmausbot.app.desktop"); } -function openWorkspaceSettings(computerId) { - if (!mainWindow || mainWindow.isDestroyed()) return; - if (senderIsLocal({ sender: mainWindow.webContents })) { - mainWindow.webContents.send("workspaces:open-settings", typeof computerId === "string" ? computerId : null); - } else { - persistEnvironments(withActive(environmentsState, LOCAL_ID)); - navigateMainWindow(`${rendererOrigin()}/?desktop-settings=workspaces${typeof computerId === "string" ? `&share-computer=${encodeURIComponent(computerId)}` : ""}`); - } +// One instance per user: without this lock a second launch forks a second +// harness server on a fallback port and splits data dirs in two. The loser +// exits before any child or window exists; the winner surfaces itself. +if (!app.requestSingleInstanceLock()) { + console.log("[desktop] OpenMausBot is already running — focusing that window"); + process.exit(0); } -async function addServerFromClipboard() { - try { - return await connectHostedWorkspace(clipboard.readText()); - } catch (error) { - await dialog.showMessageBox({ type: "info", message: "Could not connect workspace", detail: `${error.message}\nYou can also choose Connect hosted workspace to enter an address in Settings.` }); - return false; - } -} +// An update install can start the new build while this process is still +// inside the deferred before-quit cleanup further down, still holding the +// lock; the relaunched copy then loses the check above and exits, leaving a +// dead Starting window with no server. Electron's native autoUpdater emits +// before-quit-for-update only when an update drives the quit (the vendored +// electron-updater re-emits it on the same object before app.quit()), so the +// lock is released on that event — never in before-quit, where a normal quit +// would allow a concurrent second instance. +nativeAutoUpdater.on("before-quit-for-update", () => releaseSingleInstanceLock(app)); -async function connectHostedWorkspace(input, name) { - const link = parseHostedWorkspaceLink(input); - if (!link) { - throw new Error("Enter an HTTPS workspace address or a full pairing link. Keep the pairing code after #, not in the URL query."); - } - const host = new URL(link.origin).host; - const { response } = await dialog.showMessageBox({ - type: "question", - buttons: ["Connect", "Cancel"], - defaultId: 0, - cancelId: 1, - message: `Connect to ${host}?`, - detail: link.code - ? "The pairing code in the link is used once, then this app stays signed in to that server." - : "The link has no pairing code; the server will ask for one.", - }); - if (response !== 0) return false; - let next = withEnvironment(environmentsState, { origin: link.origin, name }, () => randomUUID()); - const added = next.environments.find((e) => e.origin === link.origin); - next = withActive(next, added.id); - persistEnvironments(next); - navigateMainWindow(link.url); - return true; -} +app.on("open-url", (event, url) => { + if (!queuePackageInstall(url)) return; + event.preventDefault(); +}); -async function forgetEnvironment(id) { - const env = environmentsState.environments.find((e) => e.id === id); - if (!env) return; - const { response } = await dialog.showMessageBox({ - type: "warning", - buttons: ["Forget", "Cancel"], - defaultId: 1, - cancelId: 1, - message: `Forget “${env.name}”?`, - detail: "This app signs out of that server. The server keeps its own session list; revoke it there too if the device is gone.", - }); - if (response !== 0) return; - sharingController().forget(env); - const wasActive = environmentsState.activeId === id; - persistEnvironments(withoutEnvironment(environmentsState, id)); - // Leave a removed workspace immediately; forgetting an inactive connection - // must not reload the local app or discard a Settings form/chat draft. - if (wasActive) navigateMainWindow(activeOrigin()); - try { - // Revoke the session on the server while the cookie is still here. - const response = await session.defaultSession.fetch(`${env.origin}/api/auth/logout`, { method: "POST", credentials: "include", headers: { origin: env.origin }, signal: AbortSignal.timeout(5_000) }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - } catch (error) { - slog(`forget server: logout skipped (${error?.message ?? error})`); - await dialog.showMessageBox({ type: "warning", message: "Connection forgotten; server sign-out could not be confirmed", detail: "Computer sharing is stopped. Revoke this desktop’s session on that server when it is reachable again." }); - } - try { - // Logout clears this server's exact cookie. Cookie storage is host-wide, - // not port-scoped: clearing it here would sign out other saved workspaces. - await session.defaultSession.clearStorageData({ origin: env.origin, storages: ["localstorage", "indexdb", "serviceworkers", "cachestorage"] }); - } catch (error) { - slog(`forget server: storage clear failed: ${error?.message ?? error}`); - } -} +app.on("second-instance", (_event, commandLine) => { + const packageUrl = packageUrlFromCommandLine(commandLine); + if (packageUrl) setPendingPackageInstallUrl(packageUrl); + activateExistingWindow(BrowserWindow.getAllWindows()); + const target = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()); + deliverPackageInstall(target); +}); -/** - * Displays the native context menu for editable fields, links, and selections, - * enabling paste if text or a clipboard image is available. - * - * @param {Electron.BrowserWindow} win - Target browser window. - * @param {Electron.ContextMenuParams} params - Context menu parameters from Electron. - * @returns {void} - */ -function showContextMenu(win, params) { - // nothing actionable here — no menu at all, rather than a wall of - // disabled items - if (!params.isEditable && !params.linkURL && !params.misspelledWord && !params.selectionText) return; - const menuItems = []; - if (params.misspelledWord) { - for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { - menuItems.push({ - label: suggestion, - click: () => win.webContents.replaceMisspelling(suggestion), +let desktopDataDirLease = null; +const trustedApprovalMode = createTrustedApprovalModeCoordinator({ randomId: randomUUID }); +const serverSupervisor = createServerSupervisor({ + restart: () => startServerOn(SERVER_PORT), + stop: stopUtilityServer, + onReady(proc) { + adoptUtilityServer(proc); + serverStartConflictOnly = false; + slog(`server ready pid=${proc.pid} port=${SERVER_PORT}`); + // Re-read the latest account credentials; registration may have completed + // while the replacement child's health probe was pending. + syncManagedComposioCredentials(); + if (managedDesktop) void managedDesktop.refresh().catch(() => {}); + routineWake.start(); + // Existing chat windows reconnect in place, preserving unsent drafts. + // A window opened during the outage is still on our error page instead. + for (const win of BrowserWindow.getAllWindows()) { + if (!serverUnavailableWindows.has(win) || activeEnvironment(environmentsState)) continue; + void win.loadURL(`http://127.0.0.1:${SERVER_PORT}`).then(() => { + serverUnavailableWindows.delete(win); + }).catch((error) => { + slog(`recovered server window failed to load: ${error?.message ?? error}`); }); } - if (menuItems.length) menuItems.push({ type: "separator" }); - } - if (params.linkURL) { - menuItems.push( - { label: "Copy Link", click: () => clipboard.writeText(params.linkURL) }, - { type: "separator" }, + }, + onUnavailable() { + markServerUnavailable(); + companyBackupSchedule?.reconcile(); + // nothing to hold for while the scheduler is down; polling resumes on ready + routineWake.stop(); + }, + onExhausted() { + slog("server recovery paused after repeated failures; quit and reopen to retry"); + dialog.showErrorBox( + "The bot server stopped", + "Automatic recovery could not restart the background server. Quit and reopen OpenMausBot to try again. Interrupted chat turns were not resent.\n\n" + + `Server log: ${path.join(LOG_DIR, "server.log")}`, ); - } - menuItems.push( - { label: "Undo", role: "undo", enabled: params.editFlags.canUndo }, - { label: "Redo", role: "redo", enabled: params.editFlags.canRedo }, - { type: "separator" }, - { label: "Cut", role: "cut", enabled: params.editFlags.canCut }, - { label: "Copy", role: "copy", enabled: params.editFlags.canCopy }, - pasteMenuItem(params, clipboard, win.webContents), - { label: "Paste and Match Style", role: "pasteAndMatchStyle", enabled: params.editFlags.canPaste }, - { type: "separator" }, - { label: "Select All", role: "selectAll", enabled: params.editFlags.canSelectAll }, - ); - Menu.buildFromTemplate(menuItems).popup({ window: win, frame: params.frame }); -} + }, + log: slog, +}); + +let desktopCompanionRelay = null; +import { + companionEnabledAtRest, + companionPairing, + companionCloudDesktopAccess, + companionRevoke, + rememberCompanionKeepAwake, +} from "./companion.mjs"; +import { createRoutineWakeHold, rememberRoutineWake, routineWakeSettings } from "./routine-wake.mjs"; -/** - * Creates and initializes the primary Electron browser window and configures - * its lifecycle hooks, context menus, and navigation guards. - * - * @returns {void} +/** IPC that controls this computer, its files, its logins or its updater is + * answered only for the local server's UI (electron/local-origin.cjs). A + * remote server's page gets a reduced bridge (preload.cjs) in the first + * place; this is the second wall, shared with cua.mjs, updater.mjs and + * android-device.mjs. Declared before any handler registration below: a + * const declared later would be in its temporal dead zone at module load. */ -function createWindow() { - const waitsForSkinSync = process.platform === "win32"; - const primary = screen.getPrimaryDisplay(); - const displays = [primary, ...screen.getAllDisplays().filter((display) => display.id !== primary.id)]; - const restored = resolveWindowState(readWindowState(), displays.map((display) => display.workArea)); - const win = new BrowserWindow({ - ...restored.bounds, - minWidth: MIN_BOUNDS.width, - minHeight: MIN_BOUNDS.height, - // The renderer restores its persisted skin before mounting React and - // mirrors it over desktop:skin. Keep Windows hidden until that handshake - // recolors the native caption-button overlay, otherwise a saved light - // skin still flashes the Midnight-black block on every cold start. - show: !waitsForSkinSync, - icon: APP_ICON, - backgroundColor: "#070707", - autoHideMenuBar: process.platform !== "darwin", - ...windowChromeOptions(process.platform), - webPreferences: { - contextIsolation: true, - preload: path.join(__dirname, "preload.cjs"), - // The preload exposes the full bridge only to this origin (see preload.cjs). - // Companion client mode still serves the bundled UI from its own - // loopback relay, so it is a trusted local page while also needing the - // renderer's remote-only feature gates. Keep the two facts independent: - // upstream's origin boundary must not erase the client-mode marker. - additionalArguments: [...desktopCompanionRendererArguments(rendererOrigin(), desktopRemoteAccess), - ...(app.isPackaged && !desktopRemoteAccess ? ["--omb-company-desktop=1"] : [])], - }, - }); - mainWindow = win; - attachUpdaterWindow(win); - if (waitsForSkinSync) { - // A broken renderer or preload must not strand the app as an invisible - // process. Normal startup shows from desktop:skin almost immediately; - // this is only the bounded recovery path. - const skinSyncFallback = setTimeout(() => { - if (!win.isDestroyed() && !win.isVisible()) win.show(); - }, 5_000); - skinSyncFallback.unref?.(); - const clearSkinSyncFallback = () => clearTimeout(skinSyncFallback); - win.once("show", clearSkinSyncFallback); - win.once("closed", clearSkinSyncFallback); - } - installWindowStatePersistence(win); - applyUnreadBadge(win); - if (restored.maximized) win.maximize(); - win.once("closed", () => { - if (mainWindow === win) mainWindow = null; - }); +const { isLocalSender: senderIsLocal, localOnly, setLocalOrigin } = localOriginModule; - win.webContents.setWindowOpenHandler(({ url }) => { +// Keep this computer awake for scheduled routines (electron/routine-wake.mjs): +// the scheduler lives in the local server, which cannot run while the Mac +// sleeps. One power assertion, held for the hour before a due routine and +// while a run is in flight, plugged in only; the server says when. +const routineWake = createRoutineWakeHold({ + fetchStatus: () => (serverReady + ? fetch(`http://127.0.0.1:${SERVER_PORT}/api/routines/wake`, { signal: AbortSignal.timeout(5_000), redirect: "error", credentials: "omit" }) + .then((response) => (response.ok ? response.json() : null)) + : Promise.resolve(null)), + isOnBattery: () => { try { - void shell.openExternal(externalWebUrl(url)).catch(() => { - console.warn("The external web link could not be opened"); - }); + return powerMonitor.isOnBatteryPower(); } catch { - // Reject non-web links and embedded credentials without opening them. - } - return { action: "deny" }; - }); - // Only the selected workspace may navigate this window. Switching is a - // native action, not a redirect/link from a remote page to the local bridge. - const guardNavigation = (event, url) => { - let origin = null; - try { - origin = new URL(url).origin; - } catch {} - if (workspaceNavigationAllowed(url, environmentsState, rendererOrigin())) return; - event.preventDefault(); - slog(`blocked navigation to ${origin ?? "an invalid address"}`); - }; - win.webContents.on("will-navigate", guardNavigation); - win.webContents.on("will-redirect", guardNavigation); - // Subframes: a page may not embed the local server, or any other saved - // server, inside this preload-bearing window. - win.webContents.on("will-frame-navigate", (details) => { - if (details.isMainFrame) return; - let target = null; - let page = null; - try { - target = new URL(details.url).origin; - page = new URL(win.webContents.getURL()).origin; - } catch {} - if (!target || !page || target === page) return; - if (target === rendererOrigin() || allowedOrigins(environmentsState, rendererOrigin()).has(target)) { - details.preventDefault(); - slog(`blocked subframe navigation to ${details.url}`); - } - }); - win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { - if (!isMainFrame || errorCode === -3) return; // -3: aborted by a newer navigation - const remote = activeEnvironment(environmentsState); - if (!remote) return; - let origin = null; - try { - origin = new URL(validatedURL).origin; - } catch {} - if (origin !== remote.origin) return; - slog(`remote server unreachable (${errorDescription}); back to Local`); - void dialog.showMessageBox({ - type: "warning", - message: `${remote.name} is not reachable`, - detail: `${errorDescription}. Showing the local server instead; choose it again from the Server menu when it is back.`, - }); - void workspaceMenuAction(() => switchEnvironment(LOCAL_ID)); - }); - win.webContents.on("did-finish-load", () => deliverPackageInstall(win)); - win.webContents.on("did-finish-load", () => void offerComputerSharing(win)); - - // Native context menu for text inputs — without this, right-click does - // nothing in the Electron window (no Cut/Copy/Paste/Select All). - win.webContents.on("context-menu", (_event, params) => { - showContextMenu(win, params); - }); - - // Packaged CI smoke hook. It validates the real renderer/preload bridge and - // same-origin embedded server, then follows the normal window-close path. - // No debugging port or sandbox override is needed. - if (process.env.OMB_SMOKE_TEST === "1") { - win.webContents.once("did-finish-load", async () => { - try { - const result = await win.webContents.executeJavaScript(` - (async () => { - if (!window.ogb?.getCapabilities) throw new Error("desktop preload bridge is unavailable"); - 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, ownerMutationResponse] = await Promise.all([ - window.ogb.getCapabilities(), - fetch("/api/health"), - fetch("/api/auth/stream-ticket", { method: "POST" }), - ]); - if (!healthResponse.ok) { - throw new Error(\`health request failed: \${healthResponse.status} \${healthResponse.statusText}\`); - } - const health = await healthResponse.json(); - if (!ownerMutationResponse.ok) { - throw new Error( - \`desktop mutation capability failed: \${ownerMutationResponse.status} \${ownerMutationResponse.statusText}\`, - ); - } - 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}/`; - if (result.location !== expectedLocation) { - throw new Error( - `unexpected packaged renderer URL: ${result.location} (expected ${expectedLocation})`, - ); - } - if (process.env.OMB_SMOKE_BUNDLED_CUA === "1") { - const connection = await cuaReady; - const expectedDriver = path.join( - process.resourcesPath, - "cua-linux-x64", - "cua-driver", - ); - let exactBundledPath = false; - try { - exactBundledPath = - Boolean(connection?.driver?.path) && - fs.realpathSync(connection.driver.path) === fs.realpathSync(expectedDriver); - } catch {} - result.cuaRuntime = { - driverSource: connection?.driver?.source, - exactBundledPath, - appImagePrivateStage: - Boolean(process.env.APPIMAGE) && - connection?.driver?.path !== expectedDriver && - path.basename(path.dirname(connection?.driver?.path ?? "")).startsWith( - APPIMAGE_CUA_STAGE_PREFIX, - ), - driverPath: connection?.driver?.path, - driverVersion: connection?.driver?.version, - daemonPid: connection?.daemon?.pid, - socketPath: connection?.daemon?.socketPath, - pidFile: connection?.daemon?.socketPath - ? path.join(path.dirname(connection.daemon.socketPath), "driver.pid") - : undefined, - mcpEnv: connection?.mcp?.env, - }; - } - result.hardwareAccelerationEnabled = app.isHardwareAccelerationEnabled(); - result.displayMediaRequests = displayMediaRequestCount; - console.log(`[smoke] renderer-ready ${JSON.stringify(result)}`); - } catch (error) { - console.error(`[smoke] renderer-failed ${error?.stack ?? error}`); - } finally { - if (process.env.OMB_SMOKE_KEEP_OPEN !== "1") win.close(); - } - }); - } - - const remote = activeEnvironment(environmentsState); - if (!serverReady && (desktopRemoteAccess || (app.isPackaged && !remote))) serverUnavailableWindows.add(win); - if (desktopRemoteAccess) { - win.loadURL(serverReady ? `http://127.0.0.1:${SERVER_PORT}` : buildErrorPage({ allPortsOccupied: serverStartConflictOnly })); - } else if (remote) { - void win.loadURL(remote.origin).catch(() => {}); - } else if (app.isPackaged) { - win.loadURL(serverReady ? `http://127.0.0.1:${SERVER_PORT}` : buildErrorPage({ allPortsOccupied: serverStartConflictOnly })); - } else { - win.loadURL(DEV_URL); - } - return win; -} - -// 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", localOnly("screen:frame", async () => { - if (process.platform !== "darwin") return null; - const sources = await desktopCapturer.getSources({ - types: ["screen"], - thumbnailSize: { width: 1280, height: 800 }, - }); - return sources[0]?.thumbnail.toDataURL() ?? null; -})); - -// Onboarding permission checks. Status reads are free; the mic request -// pops the real TCC prompt attributed to the app. -// -// Screen Recording deliberately has NO request path here. On macOS 15+ -// every pre-grant mechanism is broken: getMediaAccessStatus("screen") -// wraps CGPreflightScreenCaptureAccess, which caches per-process (stays -// "denied" for the whole session after the user grants); a helper child -// binary gets TCC-attributed to ITSELF on macOS 26, not the app, and -// plain executables no longer appear in the Settings pane at all; and -// Sequoia+ re-prompts periodically regardless, so a pre-grant expires. -// The one reliable path is the first real in-process capture -// (screen:frame above / getDisplayMedia via the handler below) — macOS -// prompts then, attributed correctly, at the moment of actual use. The -// perm:open-settings deep link stays as the repair path for denials. -// Copy the engine command, then open a blank terminal. Renderer-controlled -// text must never become a process argument: the user reviews and pastes it. -// Returns false when the renderer should show the clipboard fallback. -ipcMain.handle("engine:open-terminal", localOnly("engine:open-terminal", async (_event, command) => { - if (typeof command !== "string" || !command.trim()) return false; - clipboard.writeText(command); - return openBlankTerminal(); -})); - -// OAuth/connect links are returned asynchronously, after Chromium's direct -// click gesture has ended. Opening them through window.open can therefore be -// rejected as a popup before setWindowOpenHandler ever sees the URL. Keep the -// renderer sandboxed and let the main process open only ordinary web links. -// A bot's working folder: the native picker, so the path is real and the -// user never types one. Returns null when they cancel. -ipcMain.handle("desktop:pick-folder", localOnly("desktop:pick-folder", async (event, current) => { - const win = BrowserWindow.fromWebContents(event.sender) ?? undefined; - const result = await dialog.showOpenDialog(win, { - title: "Choose a working folder", - properties: ["openDirectory", "createDirectory"], - ...(typeof current === "string" && current ? { defaultPath: current } : {}), - }); - return result.canceled ? null : (result.filePaths[0] ?? null); -})); - -// One-click bug-report bundle. Secrets are never read; the report is -// redacted again on the way out (diagnostics.mjs). null means the user -// cancelled the save dialog. -ipcMain.handle("desktop:export-diagnostics", localOnly("desktop:export-diagnostics", async (event) => { - const owner = BrowserWindow.fromWebContents(event.sender) ?? undefined; - const report = await gatherDiagnostics(); - const result = await dialog.showSaveDialog(owner, { - title: "Export diagnostics", - defaultPath: diagnosticsFileName(), - filters: [{ name: "Text", extensions: ["txt"] }], - }); - if (result.canceled || !result.filePath) return null; - if (process.platform === "win32") { - fs.writeFileSync(result.filePath, report, { mode: 0o600 }); - } else { - const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW; - const handle = fs.openSync(result.filePath, flags, 0o600); - try { - fs.fchmodSync(handle, 0o600); - fs.writeFileSync(handle, report, "utf8"); - } finally { - fs.closeSync(handle); - } - } - return result.filePath; -})); - -// Bots hand users files as markdown links to paths inside the OpenMausBot -// home (workspaces, attachments). As plain anchors those resolved against the -// page origin, so the click opened http://127.0.0.1:8799 in the default -// browser and the server's SPA fallback answered with index.html — a second -// copy of the chat UI instead of the file. Ask where to put it and copy it -// there instead: a save dialog tells the user the file landed somewhere and -// where, which a silent copy into ~/Downloads does not. The path is -// renderer-controlled, so it must resolve inside ~/.openmausbot and be a -// regular file — never a symlink escape or directory. -ipcMain.handle("desktop:save-file", localOnly("desktop:save-file", async (event, rawPath) => { - return withSavableFile(rawPath, { home: os.homedir() }, async ({ defaultName, copyTo }) => { - const parent = BrowserWindow.fromWebContents(event.sender); - const defaultPath = await defaultSaveName(app.getPath("downloads"), defaultName); - const choice = await dialog.showSaveDialog(parent ?? undefined, { - title: "Where do you want to save it?", - message: "Where do you want to save it?", - defaultPath, - buttonLabel: "Save", - properties: ["createDirectory", "showOverwriteConfirmation"], - }); - // Cancelling is a decision, not a failure — the bubble stays quiet. - if (choice.canceled || !choice.filePath) return null; - await copyTo(choice.filePath); - shell.showItemInFolder(choice.filePath); - return choice.filePath; - }); -})); - -// The renderer owns the skin, including the Windows caption buttons it draws -// itself (titleBarStyle hidden, no native overlay). Keep syncing the window -// background so a light skin never flashes the Midnight-black cold start. -ipcMain.handle("desktop:skin", (event, skin) => { - if (!isKnownSkin(skin)) return false; - try { - const { color } = skinChrome(skin); - const win = BrowserWindow.fromWebContents(event.sender) ?? mainWindow; - if (win && !win.isDestroyed()) { - try { win.setBackgroundColor(color); } catch {} + return false; } - } catch {} - return true; + }, + blocker: powerSaveBlocker, + settings: () => routineWakeSettings(app.getPath("userData")), + log: (line) => slog(line), }); -// Caption controls for the overlay-less frameless window. The renderer's -// buttons are the only way to act on the window, so the channels stay -// open for the local page; a remote server's page never has them. -for (const [channel, act] of [ - ["window:minimize", (win) => win.minimize()], - ["window:toggle-maximize", (win) => (win.isMaximized() ? win.unmaximize() : win.maximize())], - ["window:close", (win) => win.close()], -]) { - ipcMain.handle(channel, (event) => { - const win = BrowserWindow.fromWebContents(event.sender) ?? mainWindow; - if (!win || win.isDestroyed()) return false; - act(win); - return true; - }); -} -ipcMain.handle("window:state", (event) => { - const win = BrowserWindow.fromWebContents(event.sender) ?? mainWindow; - return { maximized: Boolean(win && !win.isDestroyed() && win.isMaximized()) }; +// uncaughtExceptionMonitor observes Node's fatal path without converting it +// into a handled exception. In particular, an unhandled rejection still +// follows Node's normal exit behaviour after its metadata is persisted. +installDesktopCrashListeners({ + appTarget: app, + processTarget: process, + record: recordDesktopCrash, + isShuttingDown: () => desktopShutdownStarted, + mainWebContents: () => mainWindow?.webContents ?? null, }); -ipcMain.handle("desktop:open-external", localOnly("desktop:open-external", async (_event, rawUrl) => { - await shell.openExternal(externalWebUrl(rawUrl)); - return true; -})); - -// The Box VNC viewer must be a top-level page for its token exchange. A -// sandboxed modal BrowserWindow satisfies that requirement while keeping the -// live desktop inside OpenMausBot instead of sending the person to a browser. -ipcMain.handle("desktop-viewer:open", localOnly("desktop-viewer:open", (event, rawUrl, title, contextId) => { - const owner = BrowserWindow.fromWebContents(event.sender); - return openDesktopViewer(owner, rawUrl, title, contextId); -})); - -// Two Local VM desktops share the existing app BrowserWindow. The renderer -// supplies only layout and intent; URL validation, sandboxing, session -// isolation and the one-interactive-pane invariant stay in the main process. -ipcMain.handle("desktop-workspace:open", localOnly("desktop-workspace:open", (event, input) => - desktopWorkspaceForEvent(event, true).open(input), -)); -ipcMain.handle("desktop-workspace:layout", localOnly("desktop-workspace:layout", (event, items) => { - const manager = desktopWorkspaceForEvent(event); - if (!manager) return false; - return manager.layout(items); -})); -ipcMain.handle("desktop-workspace:set-interactive", localOnly("desktop-workspace:set-interactive", (event, contextId) => { - const manager = desktopWorkspaceForEvent(event); - if (!manager) return contextId == null; - return manager.setInteractive(contextId); -})); -ipcMain.handle("desktop-workspace:close", localOnly("desktop-workspace:close", (event, contextId) => { - const manager = desktopWorkspaceForEvent(event); - if (!manager) return true; - return manager.close(contextId); -})); - -// Close only when the caller owns the current viewer — otherwise one bot's -// "Hand control back" would close (and release) another bot's viewer. -ipcMain.handle("desktop-viewer:close", localOnly("desktop-viewer:close", (_event, contextId) => { - const scoped = Object.prototype.toString.call(contextId) === "[object String]" ? contextId : null; - if (scoped !== desktopViewerContextId) return false; - if (desktopViewerWindow && !desktopViewerWindow.isDestroyed()) desktopViewerWindow.close(); - return true; -})); - -// Lets a (re)mounted panel seed viewer-open state instead of defaulting to false. -ipcMain.handle("desktop-viewer:state-now", localOnly("desktop-viewer:state-now", () => ({ - open: Boolean(desktopViewerWindow && !desktopViewerWindow.isDestroyed()), - contextId: desktopViewerContextId, -}))); +// Set by startServerPackaged: true only when every failing candidate port was +// taken by another process — decides which error-page message renders. +let serverStartConflictOnly = false; -ipcMain.handle("perm:status", () => ({ - mic: - nativeActions.appleMediaPermissions - ? systemPreferences.getMediaAccessStatus?.("microphone") ?? "unknown" - : "unsupported", -})); -ipcMain.handle("perm:request-mic", localOnly("perm:request-mic", async () => { - if (!nativeActions.appleMediaPermissions) return false; - try { - return await systemPreferences.askForMediaAccess("microphone"); - } catch { - return false; - } -})); +// server-boot.mjs reads these main-owned bindings through wiring-time +// deps: the supervisor, trusted-approval coordinator and data-dir lease are +// created above, saveWorkspaceCredential is the hoisted function further +// down, and serverStartConflictOnly is assigned from both files (the +// supervisor's onReady resets it here; startServerPackaged sets it inside +// server-boot.mjs). +wireServerBootDeps({ + setServerStartConflictOnly: (value) => { + serverStartConflictOnly = value; + }, + desktopDataDirLease: () => desktopDataDirLease, + serverSupervisor: () => serverSupervisor, + trustedApprovalMode: () => trustedApprovalMode, + saveWorkspaceCredential, +}); -// 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", localOnly("perm:open-settings", (_event, pane) => { - if (!nativeActions.applePrivacySettings) return false; - const panes = { - mic: "Privacy_Microphone", - screen: "Privacy_ScreenCapture", - speech: "Privacy_SpeechRecognition", - accessibility: "Privacy_Accessibility", - }; - // own-property lookup only — a renderer-supplied "__proto__"/"constructor" - // would otherwise resolve up the prototype chain to a truthy object - const anchor = Object.hasOwn(panes, pane) ? panes[pane] : "Privacy"; - return shell.openExternal(`x-apple.systempreferences:com.apple.preference.security?${anchor}`); -})); +// environments.mjs reads these live bindings through getters — cross-module +// let reads need the accessor boundary server-runtime.mjs established for +// writes. desktopRemoteAccess is reassigned by later regions of this file; +// cuaReady is owned by cua-media.mjs, and the getter keeps the moved code +// live. +wireEnvironmentsDeps({ + desktopRemoteAccess: () => desktopRemoteAccess, + desktopMutationToken: () => desktopMutationToken, + cuaReady: () => getCuaReady(), +}); -ipcMain.handle("speech:start", localOnly("speech:start", (event, options) => { - const win = BrowserWindow.fromWebContents(event.sender); - if (!win) return; - if (!nativeActions.appleSpeech) { - win.webContents.send("speech:end", { code: 2, reason: "unsupported-platform" }); - return; - } - startSpeech(win, options); -})); -ipcMain.handle("speech:stop", localOnly("speech:stop", () => { - if (nativeActions.appleSpeech) stopSpeech(); -})); -ipcMain.handle("speech:finish", localOnly("speech:finish", () => { - if (nativeActions.appleSpeech) finishSpeech(); -})); +// desktop-ipc.mjs owns the desktop-surface IPC family that used to live +// here — screen preview, engine terminal, folder picking, diagnostics +// export, save-file, skins and caption controls, the desktop viewer and +// workspace panes, macOS permissions and speech. It registers its +// channels at import time (the company-backup.mjs precedent) and takes +// main.mjs's platform dispatch table through the same wiring boundary +// server-boot.mjs uses, because nativeActions is also read by the quit +// path further down. +wireDesktopIpc({ nativeActions }); // ── companion sidecar ────────────────────────────────────────────────── // The renderer gets these five and nothing else: it can turn the companion @@ -2407,14 +374,14 @@ ipcMain.handle("desktop-remote:pair", localOnly("desktop-remote:pair", async (ev deviceName: `${installationDisplayName()} desktop`, }); await updateSecureCredentialDocument((credentials) => withDesktopCompanionAccess(credentials, access)); - desktopRemoteAccess = access; + setDesktopRemoteAccess(access); relaunchAfterDesktopRemoteChange(); return publicDesktopRemoteState(); })); ipcMain.handle("desktop-remote:disconnect", localOnly("desktop-remote:disconnect", async (event) => { requireMainWindowSender(event); await updateSecureCredentialDocument(withoutDesktopCompanionAccess); - desktopRemoteAccess = null; + setDesktopRemoteAccess(null); relaunchAfterDesktopRemoteChange(); return { active: false }; })); @@ -2431,69 +398,6 @@ ipcMain.handle("companion-account:verify-code", localOnly("companion-account:ver ipcMain.handle("companion-account:retry", localOnly("companion-account:retry", () => ensureCompanionAccountService().retry())); ipcMain.handle("companion-account:sign-out", localOnly("companion-account:sign-out", () => ensureCompanionAccountService().signOut())); -const workspaceOnly = (handler) => (event, ...args) => { - if (!workspaceSenderAllowed(event, mainWindow?.webContents, environmentsState, rendererOrigin())) throw new Error("Workspace controls are only available in the main desktop window"); - return handler(event, ...args); -}; -const localWorkspaceOnly = (channel, handler) => localOnly(channel, workspaceOnly(handler)); -ipcMain.handle("organization:state", localWorkspaceOnly("organization:state", () => ensureManagedDesktop().state())); -ipcMain.handle("organization:begin", localWorkspaceOnly("organization:begin", (_event, input) => ensureManagedDesktop().begin(input))); -ipcMain.handle("organization:cancel", localWorkspaceOnly("organization:cancel", () => ensureManagedDesktop().cancelEnrollment())); -ipcMain.handle("organization:refresh", localWorkspaceOnly("organization:refresh", () => ensureManagedDesktop().refresh())); -ipcMain.handle("organization:disconnect", localWorkspaceOnly("organization:disconnect", () => { - companyBackupConfigurationRevision++; - companyBackupController?.abort(); preparedCompanyRestore = null; - const client = ensureManagedDesktop(); - // The schedule reports its own failure to forget the stored secret; a file - // error there must not present a completed disconnect as failed. - return Promise.allSettled([companyBackupSchedule.forget(), client.disconnect()]).then(([, disconnect]) => { - if (disconnect.status === "rejected") throw disconnect.reason; - return disconnect.value; - }); -})); -ipcMain.on("company-backups:client-state", receiveCompanyBackupClientState); -ipcMain.handle("company-backups:configure-schedule", localWorkspaceOnly("company-backups:configure-schedule", async (_event, input) => { - ensureManagedDesktop(); - const revision = ++companyBackupConfigurationRevision; - if (input?.enabled === true) { - const scope = companyBackupScope(), proc = serverProc; - if (!scope || companyBackupController || preparedCompanyRestore || companyRestoreCommitting) throw companyBackupDeferred(); - const status = await localBackupStatus(proc), current = companyBackupScope(); - if (revision !== companyBackupConfigurationRevision || status.busy || status.pendingRestore || !current || scope.key !== current.key || scope.generation !== current.generation || - companyBackupController || preparedCompanyRestore || companyRestoreCommitting) throw companyBackupDeferred(); - } - await companyBackupSchedule.configure(input); - return { ...companyBackupState, schedule: companyBackupSchedule.state() }; -})); -ipcMain.handle("company-backups:state", localWorkspaceOnly("company-backups:state", async () => { - const status = await localBackupStatus(serverProc); - return { ...companyBackupState, pendingRestore: status.pendingRestore }; -})); -ipcMain.handle("company-backups:list", localWorkspaceOnly("company-backups:list", () => ensureManagedDesktop().requestBackup("/api/desktop/backups"))); -ipcMain.handle("company-backups:create", localWorkspaceOnly("company-backups:create", (_event, input) => runCompanyBackup("backup", input))); -ipcMain.handle("company-backups:preview", localWorkspaceOnly("company-backups:preview", (_event, input) => runCompanyBackup("restore", input))); -ipcMain.handle("company-backups:cancel", localWorkspaceOnly("company-backups:cancel", () => { companyBackupController?.abort(); })); -ipcMain.handle("company-backups:delete", localWorkspaceOnly("company-backups:delete", (_event, input) => { - if (companyBackupController || input?.confirmation !== "DELETE" || !/^[a-f0-9-]{36}$/.test(input?.id)) throw new Error("Confirm the exact backup to delete when no transfer is running."); - return ensureManagedDesktop().requestBackup(`/api/desktop/backups/${input.id}`, { method: "DELETE" }); -})); -ipcMain.handle("company-backups:restore", localWorkspaceOnly("company-backups:restore", async (_event, input) => { - const client = ensureManagedDesktop(), connection = client.connection(); - if (companyBackupController || companyRestoreCommitting || !preparedCompanyRestore || preparedCompanyRestore.id !== input?.id || input?.confirmation !== "REPLACE" || - !connection || client.state().status !== "connected" || !client.state().cloudBackups || connection.expiresAt <= Date.now() || - preparedCompanyRestore.proc !== serverProc || preparedCompanyRestore.deviceId !== connection.deviceId) throw new Error("Preview this backup again and type REPLACE to confirm."); - // Consume the preview before yielding; duplicate IPC cannot commit it twice. - // On an uncertain response the existing local backup status is authoritative. - preparedCompanyRestore = null; - companyRestoreCommitting = true; - try { - const response = await localBackupRequest(serverProc, "/api/workspace-backup/restore", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: input.id, confirmation: "REPLACE" }) }); - if (!response.ok) throw new Error("The workspace could not be replaced. Check local backup status before trying again."); - publishCompanyBackupState({ busy: false, pendingRestore: true }); - return await response.json(); - } finally { companyRestoreCommitting = false; } -})); - const savedWorkspace = id => { const env = environmentsState.environments.find(entry => entry.id === id); if (!env) throw new Error("This workspace is no longer connected"); @@ -2656,7 +560,7 @@ async function broadcastDesktopCapabilities() { } setCuaStateListener((connection) => { - cuaReady = Promise.resolve(connection); + setCuaReady(Promise.resolve(connection)); void broadcastDesktopCapabilities().catch((error) => { console.error("[desktop] capability broadcast failed:", error); }); @@ -2691,32 +595,11 @@ app.whenReady().then(async () => { installDesktopMutationHeader(); } if (process.platform === "darwin") app.dock.setIcon(APP_ICON); - secureCredentials = await loadSecureCredentials(); - // The AssemblyAI key only fed the removed Teach a skill recorder, and its - // set/clear handler went with it; drop the orphaned secret rather than - // keep a third-party key at rest with no way to remove it. - if (secureCredentials && Object.hasOwn(secureCredentials, "assemblyAiApiKey") && !credentialStoreUnavailable) { - try { - const { assemblyAiApiKey: _removed, ...rest } = secureCredentials; - await saveSecureCredentials(rest); - secureCredentials = rest; - } catch (error) { - slog(`orphaned AssemblyAI key not removed: ${error?.message ?? error}`); - } - } - if (app.isPackaged) { - await secureComposioConfig(); - await secureWorkspaceConfig(); - } - // Boot migrations above are deliberately sequential. From this point on, - // every account/API-key writer must use the shared serialized state. - // An unreadable store must not become a WRITE of an empty document. - secureCredentialState = createSecureCredentialState(secureCredentials, saveSecureCredentials, { - writable: !credentialStoreUnavailable, - }); - secureCredentials = secureCredentialState.read(); + // Load credentials.bin, migrate plaintext config.json secrets, then arm + // the shared serialized credential state (electron/main/secure-config.mjs). + await initializeSecureCredentialStore(); if (app.isPackaged) await ensurePhoneSecretIdentity(); - desktopRemoteAccess = desktopCompanionAccess(secureCredentials); + setDesktopRemoteAccess(desktopCompanionAccess(secureCredentials)); const hostedAccount = desktopRemoteAccess ? null : ensureCompanionAccountService(); // Display capture remains user-initiated. The renderer first sends a // short-lived one-shot intent, then calls getDisplayMedia in the same click. @@ -2725,7 +608,7 @@ app.whenReady().then(async () => { if (process.platform === "darwin" || process.platform === "linux") { session.defaultSession.setDisplayMediaRequestHandler( (request, callback) => { - displayMediaRequestCount += 1; + bumpDisplayMediaRequestCount(); if (!displayMediaGuard.consume(request, rendererOrigin())) { respondToDisplayMediaRequest(callback, {}); return; @@ -2775,13 +658,14 @@ app.whenReady().then(async () => { // Start the CUA daemon before the window so the harness can pick up the // connection descriptor on first render. Never blocks window creation on // failure — computer use degrades to "unavailable", the rest still works. - cuaReady = + setCuaReady( !desktopRemoteAccess && (process.platform === "darwin" || process.platform === "linux" || process.platform === "win32") ? startCua().catch((e) => { console.error("[cua] start failed:", e); return { mode: "unavailable", reason: String(e) }; }) - : Promise.resolve({ mode: "unavailable", reason: "unsupported-platform" }); + : Promise.resolve({ mode: "unavailable", reason: "unsupported-platform" }), + ); if (desktopRemoteAccess) { try { desktopCompanionRelay = await startDesktopCompanionRelay({ @@ -2790,10 +674,10 @@ app.whenReady().then(async () => { ? path.join(process.resourcesPath, "ui") : path.join(app.getAppPath(), "dist"), }); - SERVER_PORT = desktopCompanionRelay.port; - serverReady = true; + setServerPort(desktopCompanionRelay.port); + setServerReady(true); } catch (error) { - serverReady = false; + setServerReady(false); slog(`desktop companion relay failed: ${error?.message ?? error}`); } } else if (app.isPackaged) { @@ -2821,10 +705,18 @@ app.whenReady().then(async () => { const requesting = requestingOrigin || contents?.getURL?.() || ""; return appPermissionAllowed(permission, requesting, rendererOrigin(), details); }); - environmentsState = readEnvironments(); + setEnvironmentsState(readEnvironments()); // The outbound connector never starts while computer sharing is off: no // poll loop, no registration, no grant replay from disk. void refreshSharedComputersAllowed().then((allowed) => { if (allowed) sharingController().start(); }); + // create-window.mjs borrows two main-owned bindings through the same + // wiring boundary server-boot.mjs uses: __dirname anchors preload.cjs + // next to this file, and serverStartConflictOnly is the conflict flag + // both main.mjs and server-boot.mjs assign. + wireCreateWindowDeps({ + __dirname, + serverStartConflictOnly: () => serverStartConflictOnly, + }); createWindow(); // Reconcile incomplete setup and resume interrupted sign-out only after the // local app is usable. This background network work never gates LAN pairing @@ -2887,7 +779,7 @@ process.once("SIGINT", requestSignalQuit); process.once("SIGTERM", requestSignalQuit); app.on("before-quit", (e) => { - desktopShutdownStarted = true; + setDesktopShutdownStarted(true); companyBackupSchedule?.close(); managedDesktop?.close(); companyBackupController?.abort(); diff --git a/electron/main/boot-error-page.mjs b/electron/main/boot-error-page.mjs new file mode 100644 index 0000000000..3014f1a3b8 --- /dev/null +++ b/electron/main/boot-error-page.mjs @@ -0,0 +1,28 @@ +// Extracted from electron/main.mjs: the data: URL error page the main window +// loads when the packaged bot server cannot start. +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { LOG_DIR } from "./crash-log.mjs"; + +// The page is built at failure time (not import time): the message depends on +// how the boot failed, and the log path comes from LOG_DIR so Windows and +// Linux users see their real location instead of a macOS guess. The link +// opens the log through the window's setWindowOpenHandler, which routes to +// the platform handler. +function escapeHtml(value) { + return value.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`); +} + +export function buildErrorPage({ allPortsOccupied }) { + const serverLogPath = path.join(LOG_DIR, "server.log"); + const serverLogHref = pathToFileURL(serverLogPath).href; + const reason = allPortsOccupied + ? "Every OpenMausBot port answered health checks from another process — likely a second copy of the app, or another program on ports 8799–28799. Quit that program, then quit and reopen OpenMausBot." + : "The background server didn't come up in time — this is usually slow startup, not a port conflict. Quit and reopen OpenMausBot."; + return ( + "data:text/html;charset=utf-8," + + encodeURIComponent( + `
🐭

Couldn't start the bot server

${escapeHtml(reason)} If it keeps happening, check ${escapeHtml(serverLogPath)}.

`, + ) + ); +} diff --git a/electron/main/companion-connection.mjs b/electron/main/companion-connection.mjs new file mode 100644 index 0000000000..0e324945c6 --- /dev/null +++ b/electron/main/companion-connection.mjs @@ -0,0 +1,335 @@ +// Extracted from electron/main.mjs: the managed companion connection — the +// phone secret identity, the per-desktop Cloudflare Tunnel connector, the +// hosted-address advertisement, and the companion account service that +// provisions them. Owns the connector/generation/advertisement state and the +// phoneSecretIdentity live binding; main.mjs keeps the IPC handlers, the +// app-ready wiring, and the quit path. +import { app, powerSaveBlocker } from "electron"; +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; +import { + companionAdvertisedHostedUrl, + companionOriginTarget, + companionRefreshTailscale, + companionRunning, + companionState, + rememberCompanionEnabled, + setCompanionHostedUrl, + setCompanionLifecycleListener, + startCompanion, + stopCompanion, +} from "../companion.mjs"; +import { + createManagedCompanionTunnel, + managedCompanionTunnelAccess, + resolveCloudflaredBinary, + resolveManagedCompanionGuardian, + withManagedCompanionTunnelAccess, + withoutManagedCompanionTunnelAccess, +} from "../managed-companion-tunnel.mjs"; +import { + createPhoneSecretIdentity, + readPhoneSecretIdentity, + withPhoneSecretIdentity, +} from "../phone-secret-identity.mjs"; +import { + companionAccountCleanupPending, + createCompanionAccountService, + resolveCompanionControlPlaneURL, +} from "../companion-account-service.mjs"; +import { createControlPlaneClient } from "../control-plane-client.mjs"; +import { + secureCredentials, + secureCredentialState, + updateSecureCredentialDocument, +} from "./secure-config.mjs"; +import { slog } from "./crash-log.mjs"; +import { SERVER_PORT, serverProc } from "./server-runtime.mjs"; + +export let phoneSecretIdentity = null; + +let companionPowerBlocker = null; + +export function syncCompanionKeepAwake(companionEnabled, keepAwake) { + const shouldBlock = companionEnabled && keepAwake; + if (shouldBlock && companionPowerBlocker === null) { + companionPowerBlocker = powerSaveBlocker.start("prevent-app-suspension"); + } else if (!shouldBlock && companionPowerBlocker !== null) { + if (powerSaveBlocker.isStarted(companionPowerBlocker)) powerSaveBlocker.stop(companionPowerBlocker); + companionPowerBlocker = null; + } +} + +// ── managed companion connection ─────────────────────────────────────── +// Account onboarding provisions one remote Cloudflare Tunnel per desktop, +// then calls reconcileManagedCompanionEndpointProvision below. Only the +// endpoint is public state. The connector token stays in credentials.bin and +// is passed to cloudflared through a private token file by the lifecycle +// module — never through IPC, argv, the environment, or logs. +let managedCompanionConnector = null; +let companionAccountService = null; +let companionDesiredThisLaunch = false; +let companionLaunchGeneration = 0; +let advertisementTransition = Promise.resolve(); + +export async function ensurePhoneSecretIdentity() { + const existing = readPhoneSecretIdentity(secureCredentialState?.read() ?? secureCredentials); + if (existing) { + phoneSecretIdentity = existing; + return existing; + } + try { + const created = await createPhoneSecretIdentity(); + await updateSecureCredentialDocument((credentials) => + withPhoneSecretIdentity(credentials, created), + ); + phoneSecretIdentity = created; + return created; + } catch (error) { + // Companion chat remains available. Pairing simply omits the public key, + // and mobile cards explain that secure entry needs the desktop until the + // OS credential store is available on a later launch. + phoneSecretIdentity = null; + slog(`phone credential key unavailable: ${error?.message ?? error}`); + return null; + } +} + +function publicManagedCompanionState() { + const access = managedCompanionTunnelAccess(secureCredentials); + const status = managedCompanionConnector?.getStatus(); + if (status) { + const publicState = { + status: status.status, + configured: status.configured, + ready: status.ready, + }; + if (status.endpoint) publicState.url = status.endpoint; + if (status.retryInMs) publicState.retryInMs = status.retryInMs; + if (status.error) publicState.error = status.error; + return publicState; + } + return access + ? { status: "stopped", configured: true, ready: false, url: access.endpoint } + : { status: "unconfigured", configured: false, ready: false }; +} + +export function decorateDesktopCompanionState(state) { + // The panel polls this state, so a sidecar that exited on its own releases + // the blocker within one poll instead of keeping the computer awake forever. + syncCompanionKeepAwake(state.enabled && !state.error, state.keepAwake === true); + return { ...state, managedConnection: publicManagedCompanionState() }; +} + +export async function desktopCompanionState() { + return decorateDesktopCompanionState(await companionState()); +} + +function companionLaunchOptions(hostedUrl = null) { + return { + resourcesPath: process.resourcesPath, + harnessPort: SERVER_PORT, + mutationToken: companionMutationToken, + hostedUrl, + // Only an embedded server receives the private half over its utility + // port. A dev server launched in another terminal cannot decrypt, so it + // must not advertise a public key and strand the phone on a dead path. + secretPublicKey: app.isPackaged && serverProc ? phoneSecretIdentity?.publicKey ?? null : null, + log: slog, + }; +} + +function ensureManagedCompanionConnector() { + if (managedCompanionConnector) return managedCompanionConnector; + managedCompanionConnector = createManagedCompanionTunnel({ + binaryPath: resolveCloudflaredBinary({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath(), + }), + guardianEntry: resolveManagedCompanionGuardian({ appPath: app.getAppPath() }), + runtimeExecutable: process.execPath, + runtimeRoot: path.join(app.getPath("userData"), "managed-companion-tunnel"), + onChange: (status) => { + slog(`managed companion connection ${status.status}`); + if (!companionDesiredThisLaunch) return; + void reconcileCompanionAdvertisement(status.ready ? status.endpoint : null); + }, + log: slog, + }); + return managedCompanionConnector; +} + +/** Publish a hosted address only after its connector has passed public health + * verification. Updating the owned sidecar in place preserves the exact + * private origin generation and cannot invalidate an open pairing window. */ +function reconcileCompanionAdvertisement( + endpoint, + ownedGeneration = companionLaunchGeneration, +) { + const normalizedEndpoint = endpoint || null; + const work = advertisementTransition.then(async () => { + if ( + ownedGeneration !== companionLaunchGeneration || + !companionDesiredThisLaunch || + !companionRunning() || + companionAdvertisedHostedUrl() === normalizedEndpoint + ) { + return desktopCompanionState(); + } + const updated = await setCompanionHostedUrl(normalizedEndpoint); + return { ...updated, managedConnection: publicManagedCompanionState() }; + }); + advertisementTransition = work.then( + () => {}, + () => {}, + ); + return work; +} + +async function startManagedCompanionConnection({ waitForVerification = true } = {}) { + if (companionAccountCleanupPending(secureCredentials)) { + return publicManagedCompanionState(); + } + const access = managedCompanionTunnelAccess(secureCredentials); + if (!access) return publicManagedCompanionState(); + const target = companionOriginTarget(); + if (!target) return publicManagedCompanionState(); + const operation = ensureManagedCompanionConnector().start({ ...access, originTarget: target }); + if (!waitForVerification) { + void operation.catch(() => {}); + return publicManagedCompanionState(); + } + const status = await operation; + await reconcileCompanionAdvertisement(status.ready ? status.endpoint : null); + return publicManagedCompanionState(); +} + +export async function startDesktopCompanion({ waitForHosted = true, remember = true } = {}) { + companionDesiredThisLaunch = true; + companionLaunchGeneration += 1; + // Direct LAN comes up first. The hosted endpoint is added in place only + // after the guardian has verified the public route to this exact sidecar. + const localState = await startCompanion(companionLaunchOptions()); + if (!localState.enabled || localState.error) { + companionDesiredThisLaunch = false; + return desktopCompanionState(); + } + if (remember) rememberCompanionEnabled(true); + await startManagedCompanionConnection({ waitForVerification: waitForHosted }); + return desktopCompanionState(); +} + +export async function stopDesktopCompanion({ remember = true } = {}) { + companionDesiredThisLaunch = false; + companionLaunchGeneration += 1; + if (remember) rememberCompanionEnabled(false); + syncCompanionKeepAwake(false, false); + await managedCompanionConnector?.stop(); + await stopCompanion(); + return desktopCompanionState(); +} + +export async function refreshDesktopCompanionTailscale() { + if (!companionRunning()) { + const started = await startDesktopCompanion({ waitForHosted: false }); + if (!started.enabled || started.error) return started; + } + return decorateDesktopCompanionState(await companionRefreshTailscale()); +} + +setCompanionLifecycleListener(({ expected, pid }) => { + if (expected) return; + slog(`owned companion exited unexpectedly pid=${pid ?? "unknown"}`); + companionDesiredThisLaunch = false; + companionLaunchGeneration += 1; + syncCompanionKeepAwake(false, false); + // stop() invalidates the guardian's owner pipe synchronously, before the + // sidecar module removes this generation's private socket. + void managedCompanionConnector?.stop().catch(() => {}); +}); + +/** Narrow main-process hook for the account onboarding flow. Its return value + * is explicitly secret-free and can be used to refresh the settings panel. */ +export async function reconcileManagedCompanionEndpointProvision(provision) { + await updateSecureCredentialDocument((credentials) => + withManagedCompanionTunnelAccess(credentials, provision), + ); + if (companionDesiredThisLaunch) { + await startManagedCompanionConnection({ waitForVerification: true }); + } + return publicManagedCompanionState(); +} + +/** Called only after the control plane has revoked/deleted the endpoint. */ +export async function clearManagedCompanionEndpointCredentials() { + await updateSecureCredentialDocument((credentials) => + withoutManagedCompanionTunnelAccess(credentials), + ); + await managedCompanionConnector?.stop(); + if (companionDesiredThisLaunch) await reconcileCompanionAdvertisement(null); + return publicManagedCompanionState(); +} + +/** Account sign-out must stop advertising the hosted route before it asks + * the control plane to revoke anything, but it must not erase the retry + * credentials until that remote cleanup is durably scheduled. */ +async function stopManagedCompanionEndpointLocally() { + await managedCompanionConnector?.stop(); + if (companionDesiredThisLaunch) await reconcileCompanionAdvertisement(null); + return publicManagedCompanionState(); +} + +async function activatePersistedManagedCompanionEndpoint() { + if (companionDesiredThisLaunch) { + return startManagedCompanionConnection({ waitForVerification: true }); + } + return publicManagedCompanionState(); +} + +export function installationDisplayName() { + const hostname = [...os.hostname()] + .filter((character) => character.codePointAt(0) >= 32 && character.codePointAt(0) !== 127) + .join("") + .trim(); + return hostname.slice(0, 80) || "This computer"; +} + +export function ensureCompanionAccountService() { + if (companionAccountService) return companionAccountService; + const baseURL = resolveCompanionControlPlaneURL({ + isPackaged: app.isPackaged, + environment: process.env, + }); + let client = null; + if (baseURL) { + try { + client = createControlPlaneClient({ baseURL }); + } catch { + // An invalid explicit override disables hosted access. Direct LAN, + // Bonjour, and Tailscale pairing remain completely independent. + } + } + companionAccountService = createCompanionAccountService({ + client, + readCredentials: () => secureCredentialState?.read() ?? secureCredentials, + updateCredentials: updateSecureCredentialDocument, + identity: { + name: installationDisplayName(), + platform: + process.platform === "win32" + ? "windows" + : process.platform === "darwin" + ? "darwin" + : "linux", + appVersion: app.getVersion().slice(0, 64), + }, + newClientInstanceId: randomUUID, + activatePersistedEndpoint: activatePersistedManagedCompanionEndpoint, + stopManagedEndpoint: stopManagedCompanionEndpointLocally, + managedConnectionState: publicManagedCompanionState, + companionIsOn: () => companionDesiredThisLaunch, + }); + return companionAccountService; +} diff --git a/electron/main/company-backup.mjs b/electron/main/company-backup.mjs new file mode 100644 index 0000000000..b93435e5a2 --- /dev/null +++ b/electron/main/company-backup.mjs @@ -0,0 +1,343 @@ +// Extracted from electron/main.mjs: the company-backup and organisation +// sign-in subsystem, verbatim — the managed desktop client, its private +// relay to the server child, the daily backup schedule, the backup and +// restore transfer paths, the desktop mutation-token sync, and every +// organisation:* and company-backups:* IPC registration, together with the +// workspaceOnly and localWorkspaceOnly wrappers those channels introduced +// (main.mjs keeps the sharing/environments/workspaces channels that reuse +// the wrappers and imports them back). The source-slice test +// electron/company-backup-main.node-test.mjs reads THIS file now: it +// executes the function family and the registration block below inside a +// VM fixture, so those two regions must stay valid plain script — no +// import/export syntax inside them, and their bare free names (mainWindow, +// environmentsState, serverProc, desktopRemoteAccess, …) must keep +// resolving, as imports or as the module-scope bindings declared here. +// What stayed in main.mjs: the app lifecycle and startup ordering, the +// before-quit teardown, every non-family IPC registration, and the +// trustedApprovalMode coordinator. This module owns the live bindings the +// pinned regions read as bare names — including desktopRemoteAccess and +// desktopShutdownStarted, which main.mjs reassigns only through the +// exported setters at the exact former assignment points, and the +// launch-time mutation tokens main.mjs still reads (the server-runtime.mjs +// accessor pattern). + +import { app, ipcMain, safeStorage, shell } from "electron"; +import { randomBytes, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { createCompanyBackups } from "../company-backups.mjs"; +import { createCompanyBackupSchedule } from "../company-backup-schedule.mjs"; +import { createManagedDesktopClient, createManagedDesktopRelay, createManagedDesktopStore } from "../managed-desktop.mjs"; +import { slog } from "./crash-log.mjs"; +import { activeEnvironment, environmentsState, rendererOrigin, workspaceSenderAllowed } from "./environments.mjs"; +import { localOnly } from "./ipc-guards.mjs"; +import { mainWindow } from "./main-window.mjs"; +import { desktopDataDir } from "./secure-config.mjs"; +import { SERVER_PORT, serverProc, serverReady } from "./server-runtime.mjs"; + +const require = createRequire(import.meta.url); +const { DESKTOP_MUTATION_HEADER } = require("../desktop-server-auth.cjs"); + +let managedDesktop = null; +let companyBackupController = null; +let companyBackupState = { busy: false }; +let preparedCompanyRestore = null; +let companyBackupSchedule = null; +let companyBackupClientStateRequest = null; +let companyRestoreCommitting = false; +let companyBackupConfigurationRevision = 0; +const managedDesktopRelay = createManagedDesktopRelay(); +const desktopMutationToken = randomBytes(32).toString("base64url"); +const companionMutationToken = randomBytes(32).toString("base64url"); +let desktopRemoteAccess = null; +let desktopShutdownStarted = false; + +function ensureManagedDesktop() { + if (managedDesktop) return managedDesktop; + if (!app.isPackaged || desktopRemoteAccess) throw new Error("Organisation sign-in requires the installed desktop app running on this computer."); + const store = createManagedDesktopStore({ + file: path.join(app.getPath("userData"), "company-connection.bin"), + encryption: { + available: async () => (await safeStorage.isAsyncEncryptionAvailable()) && + (process.platform !== "linux" || safeStorage.getSelectedStorageBackend() !== "basic_text"), + encrypt: value => safeStorage.encryptStringAsync(value), + decrypt: value => safeStorage.decryptStringAsync(value), + }, + }); + managedDesktop = createManagedDesktopClient({ + store, platform: process.platform, deviceName: os.hostname().slice(0, 100) || "My computer", + applyConnection: connection => managedDesktopRelay.send(serverProc, connection), + openBrowser: url => shell.openExternal(url), + onState: state => { + if (["signed-out", "reauth-required"].includes(state.status) || (state.status === "connected" && !state.cloudBackups)) { + companyBackupConfigurationRevision++; + companyBackupController?.abort(); + preparedCompanyRestore = null; + void companyBackupSchedule?.forget().catch(() => {}); + publishCompanyBackupState({ busy: Boolean(companyBackupController) }); + } + companyBackupSchedule?.reconcile(); + // Remote pages never receive local identity events, even if they were + // loaded in this window after an earlier local subscription. + if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.mainFrame.url.startsWith(`${rendererOrigin()}/`) && + !activeEnvironment(environmentsState) && !desktopRemoteAccess) { + mainWindow.webContents.send("organization:state-changed", state); + } + }, + }); + companyBackupSchedule = createCompanyBackupSchedule({ + store: createManagedDesktopStore({ + file: path.join(app.getPath("userData"), "company-backup-schedule.bin"), + encryption: { + available: async () => (await safeStorage.isAsyncEncryptionAvailable()) && + (process.platform !== "linux" || safeStorage.getSelectedStorageBackend() !== "basic_text"), + encrypt: value => safeStorage.encryptStringAsync(value), + decrypt: value => safeStorage.decryptStringAsync(value), + }, + }), + scope: companyBackupScope, + run: async (signal, scope) => { + if (companyBackupController || preparedCompanyRestore || companyRestoreCommitting || desktopShutdownStarted) throw companyBackupDeferred(); + const proc = serverProc; + const status = await localBackupStatus(proc); + if (status.busy || status.pendingRestore) throw companyBackupDeferred(); + const clientState = await collectCompanyBackupClientState(signal, scope, proc); + signal.throwIfAborted(); + const current = companyBackupScope(); + if (!current || current.key !== scope.key || current.generation !== scope.generation || proc !== serverProc || preparedCompanyRestore || companyRestoreCommitting) throw companyBackupDeferred(); + return runCompanyBackup("backup", { clientState }, { signal, scope }); + }, + onState: schedule => publishCompanyBackupState({ ...companyBackupState, schedule }), + }); + return managedDesktop; +} + +function companyBackupScope() { + if (desktopShutdownStarted || desktopRemoteAccess || !serverReady || !serverProc || activeEnvironment(environmentsState)) return null; + const client = managedDesktop, connection = client?.connection(), state = client?.state(); + if (!connection || state?.status !== "connected" || !state.cloudBackups || connection.expiresAt <= Date.now()) return null; + return { key: JSON.stringify([connection.portalOrigin, connection.organizationId, connection.email, connection.deviceId, path.resolve(desktopDataDir())]), + generation: client.backupGeneration() }; +} + +function companyBackupDeferred() { + return Object.assign(new Error("Wait for the local workspace to be available for its daily backup."), { code: "workspace_busy" }); +} + +function collectCompanyBackupClientState(signal, scope, proc) { + const win = mainWindow, contents = win?.webContents, frame = contents?.mainFrame; + if (companyBackupClientStateRequest || !win || win.isDestroyed() || desktopRemoteAccess || activeEnvironment(environmentsState) || + !frame?.url.startsWith(`${rendererOrigin()}/`)) return Promise.reject(companyBackupDeferred()); + return new Promise((resolve, reject) => { + const requestId = randomUUID(); + const finish = (error, value) => { + if (companyBackupClientStateRequest?.requestId !== requestId) return; + companyBackupClientStateRequest = null; + clearTimeout(timer); signal.removeEventListener("abort", abort); + if (error) reject(error); else resolve(value); + }; + const abort = () => finish(companyBackupDeferred()); + const timer = setTimeout(abort, 10_000); timer.unref?.(); + companyBackupClientStateRequest = { requestId, win, contents, frame, url: frame.url, scope, proc, finish }; + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) { abort(); return; } + try { contents.send("company-backups:collect-client-state", { requestId }); } + catch { abort(); } + }); +} + +function receiveCompanyBackupClientState(event, input) { + const pending = companyBackupClientStateRequest; + if (!pending || input?.requestId !== pending.requestId || event.sender !== pending.contents || event.senderFrame !== pending.frame) return; + const scope = companyBackupScope(); + if (pending.win !== mainWindow || mainWindow.isDestroyed() || pending.url !== pending.frame.url || + !workspaceSenderAllowed(event, mainWindow.webContents, environmentsState, rendererOrigin()) || + procUnavailable() || !scope || scope.key !== pending.scope.key || scope.generation !== pending.scope.generation) { + pending.finish(companyBackupDeferred()); return; + } + function procUnavailable() { return pending.proc !== serverProc || !serverReady || desktopRemoteAccess || desktopShutdownStarted; } + const value = input.clientState; + if (input.unavailable || !value || typeof value !== "object" || Array.isArray(value) || + Object.values(value).some(entry => typeof entry !== "string") || Buffer.byteLength(JSON.stringify(value)) > 2 * 1024 ** 2) { + pending.finish(companyBackupDeferred()); return; + } + pending.finish(null, Object.fromEntries(Object.entries(value))); +} + +function publishCompanyBackupState(value) { + companyBackupState = { ...value, ...(companyBackupSchedule ? { schedule: companyBackupSchedule.state() } : {}) }; + if (mainWindow && !mainWindow.isDestroyed() && !activeEnvironment(environmentsState) && !desktopRemoteAccess && + mainWindow.webContents.mainFrame.url.startsWith(`${rendererOrigin()}/`)) mainWindow.webContents.send("company-backups:state-changed", companyBackupState); +} + +function localBackupRequest(proc, route, init = {}) { + if (!proc || proc !== serverProc || !serverReady || !/^\/api\/workspace-backup\/(?:status|export|upload|preview|restore|download\/[A-Za-z0-9_-]+)$/.test(route)) { + throw new Error("The local workspace changed. Start this backup operation again."); + } + return fetch(`http://127.0.0.1:${SERVER_PORT}${route}`, { ...init, redirect: "error", credentials: "omit", + headers: { ...Object.fromEntries(new Headers(init.headers)), [DESKTOP_MUTATION_HEADER]: desktopMutationToken } }); +} + +async function localBackupStatus(proc) { + const response = await localBackupRequest(proc, "/api/workspace-backup/status", { signal: AbortSignal.timeout(10_000) }); + if (!response.ok) throw new Error("Local backup status is unavailable. Try again when the workspace is ready."); + const status = await response.json(); + if (proc !== serverProc || typeof status.busy !== "boolean" || typeof status.pendingRestore !== "boolean") throw new Error("The workspace changed. Check backup status again."); + return status; +} + +async function runCompanyBackup(kind, input, scheduled = null) { + if (companyBackupController || companyRestoreCommitting || desktopShutdownStarted || (scheduled && preparedCompanyRestore)) throw companyBackupDeferred(); + const client = ensureManagedDesktop(), connection = client.connection(); + if (!connection || !client.state().cloudBackups) throw new Error("Connect your organisation and ask its administrator to enable cloud backups first."); + const generation = client.backupGeneration(); + if (scheduled) { + scheduled.signal.throwIfAborted(); + const scope = companyBackupScope(); + if (!scope || scope.key !== scheduled.scope.key || scope.generation !== scheduled.scope.generation) throw companyBackupDeferred(); + } + const proc = serverProc, controller = new AbortController(); + const cancelScheduled = () => controller.abort(); + scheduled?.signal.addEventListener("abort", cancelScheduled, { once: true }); + const deadline = setTimeout(() => controller.abort(), 2 * 60 * 60_000); deadline.unref?.(); + companyBackupController = controller; + preparedCompanyRestore = null; + publishCompanyBackupState({ busy: true, kind }); + const progress = progress => publishCompanyBackupState({ busy: true, kind, progress }); + try { + const status = await localBackupStatus(proc); + if (status.pendingRestore) { + publishCompanyBackupState({ busy: false, pendingRestore: true }); + throw new Error("Restart OpenMausBot to finish the pending restore before starting another backup operation."); + } + if (status.busy) throw companyBackupDeferred(); + const transfers = createCompanyBackups({ + tempRoot: path.join(app.getPath("temp"), "openmaus-company-backups"), + localRequest: (route, init) => localBackupRequest(proc, route, init), + portalRequest: (route, options) => client.requestBackup(route, { ...options, generation }), + availableBytes: async temporary => { + const volumes = await Promise.all([fs.promises.statfs(temporary), fs.promises.statfs(desktopDataDir())]); + return Math.min(...volumes.map(volume => volume.bavail * volume.bsize)); + }, + }); + const result = kind === "backup" ? await transfers.backup({ ...input, appVersion: app.getVersion() }, controller.signal, progress) + : await transfers.prepareRestore(input, controller.signal, progress); + controller.signal.throwIfAborted(); + if (proc !== serverProc || client.backupGeneration() !== generation || client.connection()?.deviceId !== connection.deviceId) throw new Error("The workspace connection changed."); + if (kind === "restore") preparedCompanyRestore = { id: result.id, proc, deviceId: connection.deviceId }; + publishCompanyBackupState({ busy: false, ...(kind === "backup" ? { lastBackupAt: Date.now() } : {}) }); + return result; + } catch (error) { + const message = controller.signal.aborted ? "Cloud backup cancelled. Your workspace has not been replaced." + : error?.name === "CompanyBackupError" ? error.message : "Cloud backup could not complete. Check your organisation connection and available disk space, then try again."; + publishCompanyBackupState({ busy: false, pendingRestore: companyBackupState.pendingRestore, message }); + throw Object.assign(new Error(message), error?.code === "workspace_busy" ? { code: "workspace_busy" } : {}); + } finally { + clearTimeout(deadline); + scheduled?.signal.removeEventListener("abort", cancelScheduled); + if (companyBackupController === controller) companyBackupController = null; + } +} + +function syncDesktopMutationToken(proc) { + try { + proc.postMessage({ + type: "openmausbot:desktop-mutation-token", + token: desktopMutationToken, + companionToken: companionMutationToken, + }); + } catch (error) { + slog(`desktop mutation capability sync failed: ${error?.message ?? error}`); + } +} + +const workspaceOnly = (handler) => (event, ...args) => { + if (!workspaceSenderAllowed(event, mainWindow?.webContents, environmentsState, rendererOrigin())) throw new Error("Workspace controls are only available in the main desktop window"); + return handler(event, ...args); +}; +const localWorkspaceOnly = (channel, handler) => localOnly(channel, workspaceOnly(handler)); +ipcMain.handle("organization:state", localWorkspaceOnly("organization:state", () => ensureManagedDesktop().state())); +ipcMain.handle("organization:begin", localWorkspaceOnly("organization:begin", (_event, input) => ensureManagedDesktop().begin(input))); +ipcMain.handle("organization:cancel", localWorkspaceOnly("organization:cancel", () => ensureManagedDesktop().cancelEnrollment())); +ipcMain.handle("organization:refresh", localWorkspaceOnly("organization:refresh", () => ensureManagedDesktop().refresh())); +ipcMain.handle("organization:disconnect", localWorkspaceOnly("organization:disconnect", () => { + companyBackupConfigurationRevision++; + companyBackupController?.abort(); preparedCompanyRestore = null; + const client = ensureManagedDesktop(); + // The schedule reports its own failure to forget the stored secret; a file + // error there must not present a completed disconnect as failed. + return Promise.allSettled([companyBackupSchedule.forget(), client.disconnect()]).then(([, disconnect]) => { + if (disconnect.status === "rejected") throw disconnect.reason; + return disconnect.value; + }); +})); +ipcMain.on("company-backups:client-state", receiveCompanyBackupClientState); +ipcMain.handle("company-backups:configure-schedule", localWorkspaceOnly("company-backups:configure-schedule", async (_event, input) => { + ensureManagedDesktop(); + const revision = ++companyBackupConfigurationRevision; + if (input?.enabled === true) { + const scope = companyBackupScope(), proc = serverProc; + if (!scope || companyBackupController || preparedCompanyRestore || companyRestoreCommitting) throw companyBackupDeferred(); + const status = await localBackupStatus(proc), current = companyBackupScope(); + if (revision !== companyBackupConfigurationRevision || status.busy || status.pendingRestore || !current || scope.key !== current.key || scope.generation !== current.generation || + companyBackupController || preparedCompanyRestore || companyRestoreCommitting) throw companyBackupDeferred(); + } + await companyBackupSchedule.configure(input); + return { ...companyBackupState, schedule: companyBackupSchedule.state() }; +})); +ipcMain.handle("company-backups:state", localWorkspaceOnly("company-backups:state", async () => { + const status = await localBackupStatus(serverProc); + return { ...companyBackupState, pendingRestore: status.pendingRestore }; +})); +ipcMain.handle("company-backups:list", localWorkspaceOnly("company-backups:list", () => ensureManagedDesktop().requestBackup("/api/desktop/backups"))); +ipcMain.handle("company-backups:create", localWorkspaceOnly("company-backups:create", (_event, input) => runCompanyBackup("backup", input))); +ipcMain.handle("company-backups:preview", localWorkspaceOnly("company-backups:preview", (_event, input) => runCompanyBackup("restore", input))); +ipcMain.handle("company-backups:cancel", localWorkspaceOnly("company-backups:cancel", () => { companyBackupController?.abort(); })); +ipcMain.handle("company-backups:delete", localWorkspaceOnly("company-backups:delete", (_event, input) => { + if (companyBackupController || input?.confirmation !== "DELETE" || !/^[a-f0-9-]{36}$/.test(input?.id)) throw new Error("Confirm the exact backup to delete when no transfer is running."); + return ensureManagedDesktop().requestBackup(`/api/desktop/backups/${input.id}`, { method: "DELETE" }); +})); +ipcMain.handle("company-backups:restore", localWorkspaceOnly("company-backups:restore", async (_event, input) => { + const client = ensureManagedDesktop(), connection = client.connection(); + if (companyBackupController || companyRestoreCommitting || !preparedCompanyRestore || preparedCompanyRestore.id !== input?.id || input?.confirmation !== "REPLACE" || + !connection || client.state().status !== "connected" || !client.state().cloudBackups || connection.expiresAt <= Date.now() || + preparedCompanyRestore.proc !== serverProc || preparedCompanyRestore.deviceId !== connection.deviceId) throw new Error("Preview this backup again and type REPLACE to confirm."); + // Consume the preview before yielding; duplicate IPC cannot commit it twice. + // On an uncertain response the existing local backup status is authoritative. + preparedCompanyRestore = null; + companyRestoreCommitting = true; + try { + const response = await localBackupRequest(serverProc, "/api/workspace-backup/restore", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: input.id, confirmation: "REPLACE" }) }); + if (!response.ok) throw new Error("The workspace could not be replaced. Check local backup status before trying again."); + publishCompanyBackupState({ busy: false, pendingRestore: true }); + return await response.json(); + } finally { companyRestoreCommitting = false; } +})); + +// This module owns the desktopRemoteAccess and desktopShutdownStarted live +// bindings above; main.mjs imports them read-only and reassigns them only +// through these setters, at the exact points where it used to assign the +// locals directly — the server-runtime.mjs accessor pattern. +export function setDesktopRemoteAccess(access) { + desktopRemoteAccess = access; +} + +export function setDesktopShutdownStarted(started) { + desktopShutdownStarted = started; +} + +export { + companyBackupController, + companyBackupSchedule, + desktopMutationToken, + desktopRemoteAccess, + desktopShutdownStarted, + ensureManagedDesktop, + localWorkspaceOnly, + managedDesktop, + managedDesktopRelay, + syncDesktopMutationToken, + workspaceOnly, +}; diff --git a/electron/main/crash-log.mjs b/electron/main/crash-log.mjs new file mode 100644 index 0000000000..82230506fb --- /dev/null +++ b/electron/main/crash-log.mjs @@ -0,0 +1,86 @@ +// Extracted from electron/main.mjs: the main-process log stream (server.log +// in the OS log dir) and the bounded, redacted desktop crash record. slog is +// the shared log sink; main.mjs keeps the crash listener wiring. +import { app } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import { formatDesktopCrashRecord } from "../diagnostics.mjs"; + +// The packaged app has no terminal: everything about the server child's life +// goes to server.log in the OS log dir (~/Library/Logs/OpenMausBot on macOS, +// Console.app-visible; %APPDATA%\OpenMausBot\logs on Windows), which is also +// why stdio is piped, not inherited — under a Finder/Explorer launch the +// parent's stdio leads nowhere and a failed boot is otherwise undiagnosable. +export const LOG_DIR = app.getPath("logs"); +export const DESKTOP_CRASH_LOG = path.join(LOG_DIR, "desktop-crashes.log"); +const DESKTOP_CRASH_LOG_MAX_BYTES = 512 * 1024; +let logStream = null; + +export function slog(line) { + try { + if (!logStream) { + fs.mkdirSync(LOG_DIR, { recursive: true }); + logStream = fs.createWriteStream(path.join(LOG_DIR, "server.log"), { flags: "a" }); + } + logStream.write(`[${new Date().toISOString()}] ${line}\n`); + } catch { + /* logging must never break startup */ + } +} + +// The server stream is intentionally asynchronous, but a fatal main-process +// exception may terminate Electron before such a write is flushed. Crash +// metadata gets its own tiny synchronous file. The formatter admits only a +// fixed set of fields, so renderer URLs, page titles, exception messages and +// absolute paths never land on disk or in a public bug report. +export function recordDesktopCrash(event) { + let handle = null; + try { + const record = formatDesktopCrashRecord(event); + if (!record) return; + fs.mkdirSync(LOG_DIR, { recursive: true }); + + const flags = + fs.constants.O_WRONLY | + fs.constants.O_APPEND | + (process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW); + let before = null; + try { + before = fs.lstatSync(DESKTOP_CRASH_LOG); + if (!before.isFile() || before.nlink !== 1) return; + handle = fs.openSync(DESKTOP_CRASH_LOG, flags); + } catch (error) { + if (error?.code !== "ENOENT") return; + // O_EXCL makes first creation race-safe on Windows, where O_NOFOLLOW is + // unavailable, as well as on POSIX. + try { + handle = fs.openSync( + DESKTOP_CRASH_LOG, + flags | fs.constants.O_CREAT | fs.constants.O_EXCL, + 0o600, + ); + } catch { + return; + } + } + + const stats = fs.fstatSync(handle); + // A hard-linked or non-regular target is not an app-owned crash log. + if (!stats.isFile() || stats.nlink !== 1) return; + if (before && (before.dev !== stats.dev || before.ino !== stats.ino)) return; + // A renderer crash loop must not grow a persistent log without bound. + // The diagnostics export reads only a bounded tail, so dropping older + // crash metadata here preserves the useful part of the record. + if (stats.size >= DESKTOP_CRASH_LOG_MAX_BYTES) fs.ftruncateSync(handle, 0); + if (process.platform !== "win32") fs.fchmodSync(handle, 0o600); + fs.writeFileSync(handle, `[${new Date().toISOString()}] ${record}\n`, "utf8"); + } catch { + /* crash diagnostics must never change app lifecycle */ + } finally { + if (handle !== null) { + try { + fs.closeSync(handle); + } catch {} + } + } +} diff --git a/electron/main/create-window.mjs b/electron/main/create-window.mjs new file mode 100644 index 0000000000..b96d729a44 --- /dev/null +++ b/electron/main/create-window.mjs @@ -0,0 +1,307 @@ +// Extracted from electron/main.mjs: createWindow, verbatim — the primary +// window's construction with restore/maximize state, the Windows skin-sync +// show handshake, the navigation/subframe guards and input context menu, the +// packaged smoke-test hook, and the initial loadURL choice between remote, +// packaged-local and dev servers. Every sibling-module name the body reads +// imports directly here, so the live bindings (desktopRemoteAccess, +// environmentsState, serverReady, SERVER_PORT, cuaReady, +// displayMediaRequestCount) stay live exactly as before. The two +// main.mjs-local borrows cross through the wiring deps below (the +// server-boot.mjs pattern): __dirname, because preload.cjs sits next to +// main.mjs, and serverStartConflictOnly, a main.mjs let that server-boot.mjs +// also assigns, so it reads through a zero-arg getter. The source-slice test +// electron/app-permissions.node-test.mjs reads THIS file now for the +// setWindowOpenHandler entry point. +import { app, BrowserWindow, dialog, screen, shell } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { attachUpdaterWindow } from "../updater.mjs"; +import { externalWebUrl } from "../app-permissions.mjs"; +import { windowChromeOptions } from "../window-chrome.mjs"; +import { desktopCompanionRendererArguments } from "../desktop-companion-client.mjs"; +import { slog } from "./crash-log.mjs"; +import { APP_ICON } from "./desktop-viewer.mjs"; +import { mainWindow, setMainWindow } from "./main-window.mjs"; +import { installWindowStatePersistence, readWindowState } from "./window-state.mjs"; +import { SERVER_PORT, serverReady } from "./server-runtime.mjs"; +import { buildErrorPage } from "./boot-error-page.mjs"; +import { desktopRemoteAccess } from "./company-backup.mjs"; +import { applyUnreadBadge, deliverPackageInstall, serverUnavailableWindows } from "./unread-badge.mjs"; +import { cuaReady, displayMediaRequestCount } from "./cua-media.mjs"; +import { + DEV_URL, + LOCAL_ID, + activeEnvironment, + allowedOrigins, + environmentsState, + offerComputerSharing, + rendererOrigin, + showContextMenu, + switchEnvironment, + workspaceMenuAction, + workspaceNavigationAllowed, +} from "./environments.mjs"; + +const require = createRequire(import.meta.url); +const { STAGE_PREFIX: APPIMAGE_CUA_STAGE_PREFIX } = require("../cua-linux-bundle.cjs"); +const { MIN_BOUNDS, resolveWindowState } = require("../window-state.cjs"); + +// Live reads into main.mjs's module state; wired once by main.mjs right +// before the first createWindow() call. __dirname is a main.mjs const; +// serverStartConflictOnly is a let both main.mjs and server-boot.mjs assign, +// so it crosses as a zero-arg getter (the environments.mjs convention). The +// defaults would only ever apply if a call somehow preceded the wiring. +const deps = { + __dirname: "", + serverStartConflictOnly: () => false, +}; + +export function wireCreateWindowDeps(wiring) { + Object.assign(deps, wiring); +} + +/** + * Creates and initializes the primary Electron browser window and configures + * its lifecycle hooks, context menus, and navigation guards. + * + * @returns {void} + */ +export function createWindow() { + const waitsForSkinSync = process.platform === "win32"; + const primary = screen.getPrimaryDisplay(); + const displays = [primary, ...screen.getAllDisplays().filter((display) => display.id !== primary.id)]; + const restored = resolveWindowState(readWindowState(), displays.map((display) => display.workArea)); + const win = new BrowserWindow({ + ...restored.bounds, + minWidth: MIN_BOUNDS.width, + minHeight: MIN_BOUNDS.height, + // The renderer restores its persisted skin before mounting React and + // mirrors it over desktop:skin. Keep Windows hidden until that handshake + // recolors the native caption-button overlay, otherwise a saved light + // skin still flashes the Midnight-black block on every cold start. + show: !waitsForSkinSync, + icon: APP_ICON, + backgroundColor: "#070707", + autoHideMenuBar: process.platform !== "darwin", + ...windowChromeOptions(process.platform), + webPreferences: { + contextIsolation: true, + preload: path.join(deps.__dirname, "preload.cjs"), + // The preload exposes the full bridge only to this origin (see preload.cjs). + // Companion client mode still serves the bundled UI from its own + // loopback relay, so it is a trusted local page while also needing the + // renderer's remote-only feature gates. Keep the two facts independent: + // upstream's origin boundary must not erase the client-mode marker. + additionalArguments: [...desktopCompanionRendererArguments(rendererOrigin(), desktopRemoteAccess), + ...(app.isPackaged && !desktopRemoteAccess ? ["--omb-company-desktop=1"] : [])], + }, + }); + setMainWindow(win); + attachUpdaterWindow(win); + if (waitsForSkinSync) { + // A broken renderer or preload must not strand the app as an invisible + // process. Normal startup shows from desktop:skin almost immediately; + // this is only the bounded recovery path. + const skinSyncFallback = setTimeout(() => { + if (!win.isDestroyed() && !win.isVisible()) win.show(); + }, 5_000); + skinSyncFallback.unref?.(); + const clearSkinSyncFallback = () => clearTimeout(skinSyncFallback); + win.once("show", clearSkinSyncFallback); + win.once("closed", clearSkinSyncFallback); + } + installWindowStatePersistence(win); + applyUnreadBadge(win); + if (restored.maximized) win.maximize(); + win.once("closed", () => { + if (mainWindow === win) setMainWindow(null); + }); + + win.webContents.setWindowOpenHandler(({ url }) => { + try { + void shell.openExternal(externalWebUrl(url)).catch(() => { + console.warn("The external web link could not be opened"); + }); + } catch { + // Reject non-web links and embedded credentials without opening them. + } + return { action: "deny" }; + }); + // Only the selected workspace may navigate this window. Switching is a + // native action, not a redirect/link from a remote page to the local bridge. + const guardNavigation = (event, url) => { + let origin = null; + try { + origin = new URL(url).origin; + } catch {} + if (workspaceNavigationAllowed(url, environmentsState, rendererOrigin())) return; + event.preventDefault(); + slog(`blocked navigation to ${origin ?? "an invalid address"}`); + }; + win.webContents.on("will-navigate", guardNavigation); + win.webContents.on("will-redirect", guardNavigation); + // Subframes: a page may not embed the local server, or any other saved + // server, inside this preload-bearing window. + win.webContents.on("will-frame-navigate", (details) => { + if (details.isMainFrame) return; + let target = null; + let page = null; + try { + target = new URL(details.url).origin; + page = new URL(win.webContents.getURL()).origin; + } catch {} + if (!target || !page || target === page) return; + if (target === rendererOrigin() || allowedOrigins(environmentsState, rendererOrigin()).has(target)) { + details.preventDefault(); + slog(`blocked subframe navigation to ${details.url}`); + } + }); + win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame || errorCode === -3) return; // -3: aborted by a newer navigation + const remote = activeEnvironment(environmentsState); + if (!remote) return; + let origin = null; + try { + origin = new URL(validatedURL).origin; + } catch {} + if (origin !== remote.origin) return; + slog(`remote server unreachable (${errorDescription}); back to Local`); + void dialog.showMessageBox({ + type: "warning", + message: `${remote.name} is not reachable`, + detail: `${errorDescription}. Showing the local server instead; choose it again from the Server menu when it is back.`, + }); + void workspaceMenuAction(() => switchEnvironment(LOCAL_ID)); + }); + win.webContents.on("did-finish-load", () => deliverPackageInstall(win)); + win.webContents.on("did-finish-load", () => void offerComputerSharing(win)); + + // Native context menu for text inputs — without this, right-click does + // nothing in the Electron window (no Cut/Copy/Paste/Select All). + win.webContents.on("context-menu", (_event, params) => { + showContextMenu(win, params); + }); + + // Packaged CI smoke hook. It validates the real renderer/preload bridge and + // same-origin embedded server, then follows the normal window-close path. + // No debugging port or sandbox override is needed. + if (process.env.OMB_SMOKE_TEST === "1") { + win.webContents.once("did-finish-load", async () => { + try { + const result = await win.webContents.executeJavaScript(` + (async () => { + if (!window.ogb?.getCapabilities) throw new Error("desktop preload bridge is unavailable"); + 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, ownerMutationResponse] = await Promise.all([ + window.ogb.getCapabilities(), + fetch("/api/health"), + fetch("/api/auth/stream-ticket", { method: "POST" }), + ]); + if (!healthResponse.ok) { + throw new Error(\`health request failed: \${healthResponse.status} \${healthResponse.statusText}\`); + } + const health = await healthResponse.json(); + if (!ownerMutationResponse.ok) { + throw new Error( + \`desktop mutation capability failed: \${ownerMutationResponse.status} \${ownerMutationResponse.statusText}\`, + ); + } + 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}/`; + if (result.location !== expectedLocation) { + throw new Error( + `unexpected packaged renderer URL: ${result.location} (expected ${expectedLocation})`, + ); + } + if (process.env.OMB_SMOKE_BUNDLED_CUA === "1") { + const connection = await cuaReady; + const expectedDriver = path.join( + process.resourcesPath, + "cua-linux-x64", + "cua-driver", + ); + let exactBundledPath = false; + try { + exactBundledPath = + Boolean(connection?.driver?.path) && + fs.realpathSync(connection.driver.path) === fs.realpathSync(expectedDriver); + } catch {} + result.cuaRuntime = { + driverSource: connection?.driver?.source, + exactBundledPath, + appImagePrivateStage: + Boolean(process.env.APPIMAGE) && + connection?.driver?.path !== expectedDriver && + path.basename(path.dirname(connection?.driver?.path ?? "")).startsWith( + APPIMAGE_CUA_STAGE_PREFIX, + ), + driverPath: connection?.driver?.path, + driverVersion: connection?.driver?.version, + daemonPid: connection?.daemon?.pid, + socketPath: connection?.daemon?.socketPath, + pidFile: connection?.daemon?.socketPath + ? path.join(path.dirname(connection.daemon.socketPath), "driver.pid") + : undefined, + mcpEnv: connection?.mcp?.env, + }; + } + result.hardwareAccelerationEnabled = app.isHardwareAccelerationEnabled(); + result.displayMediaRequests = displayMediaRequestCount; + console.log(`[smoke] renderer-ready ${JSON.stringify(result)}`); + } catch (error) { + console.error(`[smoke] renderer-failed ${error?.stack ?? error}`); + } finally { + if (process.env.OMB_SMOKE_KEEP_OPEN !== "1") win.close(); + } + }); + } + + const remote = activeEnvironment(environmentsState); + if (!serverReady && (desktopRemoteAccess || (app.isPackaged && !remote))) serverUnavailableWindows.add(win); + if (desktopRemoteAccess) { + win.loadURL(serverReady ? `http://127.0.0.1:${SERVER_PORT}` : buildErrorPage({ allPortsOccupied: deps.serverStartConflictOnly() })); + } else if (remote) { + void win.loadURL(remote.origin).catch(() => {}); + } else if (app.isPackaged) { + win.loadURL(serverReady ? `http://127.0.0.1:${SERVER_PORT}` : buildErrorPage({ allPortsOccupied: deps.serverStartConflictOnly() })); + } else { + win.loadURL(DEV_URL); + } + return win; +} diff --git a/electron/main/cua-media.mjs b/electron/main/cua-media.mjs new file mode 100644 index 0000000000..9dfe4527d2 --- /dev/null +++ b/electron/main/cua-media.mjs @@ -0,0 +1,59 @@ +// Extracted from electron/main.mjs: the CUA/display-media subsystem, +// verbatim — the cuaReady connection promise, the android device controller, +// the display-media guard and its request counter, the display-media +// response helper, and the screen:preview-intent IPC registration (a feature +// registration, not an app lifecycle hook — the company-backup.mjs +// precedent; the registrations run at import time, which is before app +// ready, exactly as before). This module owns the cuaReady and +// displayMediaRequestCount live bindings; main.mjs imports them read-only +// and reassigns them only through the exported setters at the exact former +// assignment points — the server-runtime.mjs accessor pattern. + +import { ipcMain } from "electron"; +import { createRequire } from "node:module"; +import { createAndroidDeviceController } from "../android-device.mjs"; +import localOriginModule from "../local-origin.cjs"; + +const require = createRequire(import.meta.url); +const { createDisplayMediaGuard, invokeDisplayMediaCallback } = require("../screen-preview.cjs"); + +const { localOnlySync } = localOriginModule; + +let cuaReady = Promise.resolve({ mode: "unavailable", reason: "not-started" }); +const androidDevice = createAndroidDeviceController({ resourcesPath: process.resourcesPath }); +const displayMediaGuard = createDisplayMediaGuard(); +let displayMediaRequestCount = 0; + +export function getCuaReady() { + return cuaReady; +} + +export function setCuaReady(next) { + cuaReady = next; +} + +export function bumpDisplayMediaRequestCount() { + displayMediaRequestCount += 1; +} + +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", localOnlySync("screen:preview-intent", (event) => { + event.returnValue = displayMediaGuard.begin(event.senderFrame); +})); + +export { + androidDevice, + cuaReady, + displayMediaGuard, + displayMediaRequestCount, + respondToDisplayMediaRequest, +}; diff --git a/electron/main/desktop-ipc.mjs b/electron/main/desktop-ipc.mjs new file mode 100644 index 0000000000..d02d1a50b5 --- /dev/null +++ b/electron/main/desktop-ipc.mjs @@ -0,0 +1,273 @@ +// Extracted from electron/main.mjs: the desktop-surface IPC family, verbatim +// — the local-control screen preview, the engine terminal launch, folder +// picking, the diagnostics export, bot file saves, skins and caption +// controls, the desktop viewer and Local VM workspace panes, the macOS +// permission checks, and the speech family. The ipcMain.handle +// registrations run at module-evaluation time (the company-backup.mjs +// precedent), so this module imports its guard and helpers directly and +// never imports main.mjs. The one main.mjs-local free name in the family, +// nativeActions, crosses through wireDesktopIpc — main.mjs calls it at the +// exact former block position, and still reads the table on its own quit +// path. The source-slice test electron/app-permissions.node-test.mjs reads +// THIS file now for the desktop:open-external entry point. +import { app, BrowserWindow, clipboard, desktopCapturer, dialog, ipcMain, shell, systemPreferences } from "electron"; +import fs from "node:fs"; +import os from "node:os"; +import { externalWebUrl } from "../app-permissions.mjs"; +import { diagnosticsFileName } from "../diagnostics.mjs"; +import { defaultSaveName, withSavableFile } from "../save-file.mjs"; +import { isKnownSkin, skinChrome } from "../skin-overlay.cjs"; +import { finishSpeech, startSpeech, stopSpeech } from "../speech.mjs"; +import { openBlankTerminal } from "../terminal-launch.mjs"; +import { gatherDiagnostics } from "./diagnostics.mjs"; +import { desktopViewerContextId, desktopViewerWindow, openDesktopViewer } from "./desktop-viewer.mjs"; +import { desktopWorkspaceForEvent } from "./desktop-workspace.mjs"; +import { localOnly } from "./ipc-guards.mjs"; +import { mainWindow } from "./main-window.mjs"; + +let nativeActions = null; + +// main.mjs owns the platform dispatch table and reads it on its own quit +// path, so it hands the binding over here once at module load, through the +// same wiring boundary server-boot.mjs established (wireServerBootDeps). +// Handlers only read nativeActions when invoked, long after the wiring. +export function wireDesktopIpc({ nativeActions: boundNativeActions }) { + nativeActions = boundNativeActions; +} + +// 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", localOnly("screen:frame", async () => { + if (process.platform !== "darwin") return null; + const sources = await desktopCapturer.getSources({ + types: ["screen"], + thumbnailSize: { width: 1280, height: 800 }, + }); + return sources[0]?.thumbnail.toDataURL() ?? null; +})); + +// Onboarding permission checks. Status reads are free; the mic request +// pops the real TCC prompt attributed to the app. +// +// Screen Recording deliberately has NO request path here. On macOS 15+ +// every pre-grant mechanism is broken: getMediaAccessStatus("screen") +// wraps CGPreflightScreenCaptureAccess, which caches per-process (stays +// "denied" for the whole session after the user grants); a helper child +// binary gets TCC-attributed to ITSELF on macOS 26, not the app, and +// plain executables no longer appear in the Settings pane at all; and +// Sequoia+ re-prompts periodically regardless, so a pre-grant expires. +// The one reliable path is the first real in-process capture +// (screen:frame above / getDisplayMedia via the handler below) — macOS +// prompts then, attributed correctly, at the moment of actual use. The +// perm:open-settings deep link stays as the repair path for denials. +// Copy the engine command, then open a blank terminal. Renderer-controlled +// text must never become a process argument: the user reviews and pastes it. +// Returns false when the renderer should show the clipboard fallback. +ipcMain.handle("engine:open-terminal", localOnly("engine:open-terminal", async (_event, command) => { + if (typeof command !== "string" || !command.trim()) return false; + clipboard.writeText(command); + return openBlankTerminal(); +})); + +// OAuth/connect links are returned asynchronously, after Chromium's direct +// click gesture has ended. Opening them through window.open can therefore be +// rejected as a popup before setWindowOpenHandler ever sees the URL. Keep the +// renderer sandboxed and let the main process open only ordinary web links. +// A bot's working folder: the native picker, so the path is real and the +// user never types one. Returns null when they cancel. +ipcMain.handle("desktop:pick-folder", localOnly("desktop:pick-folder", async (event, current) => { + const win = BrowserWindow.fromWebContents(event.sender) ?? undefined; + const result = await dialog.showOpenDialog(win, { + title: "Choose a working folder", + properties: ["openDirectory", "createDirectory"], + ...(typeof current === "string" && current ? { defaultPath: current } : {}), + }); + return result.canceled ? null : (result.filePaths[0] ?? null); +})); + +// One-click bug-report bundle. Secrets are never read; the report is +// redacted again on the way out (diagnostics.mjs). null means the user +// cancelled the save dialog. +ipcMain.handle("desktop:export-diagnostics", localOnly("desktop:export-diagnostics", async (event) => { + const owner = BrowserWindow.fromWebContents(event.sender) ?? undefined; + const report = await gatherDiagnostics(); + const result = await dialog.showSaveDialog(owner, { + title: "Export diagnostics", + defaultPath: diagnosticsFileName(), + filters: [{ name: "Text", extensions: ["txt"] }], + }); + if (result.canceled || !result.filePath) return null; + if (process.platform === "win32") { + fs.writeFileSync(result.filePath, report, { mode: 0o600 }); + } else { + const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW; + const handle = fs.openSync(result.filePath, flags, 0o600); + try { + fs.fchmodSync(handle, 0o600); + fs.writeFileSync(handle, report, "utf8"); + } finally { + fs.closeSync(handle); + } + } + return result.filePath; +})); + +// Bots hand users files as markdown links to paths inside the OpenMausBot +// home (workspaces, attachments). As plain anchors those resolved against the +// page origin, so the click opened http://127.0.0.1:8799 in the default +// browser and the server's SPA fallback answered with index.html — a second +// copy of the chat UI instead of the file. Ask where to put it and copy it +// there instead: a save dialog tells the user the file landed somewhere and +// where, which a silent copy into ~/Downloads does not. The path is +// renderer-controlled, so it must resolve inside ~/.openmausbot and be a +// regular file — never a symlink escape or directory. +ipcMain.handle("desktop:save-file", localOnly("desktop:save-file", async (event, rawPath) => { + return withSavableFile(rawPath, { home: os.homedir() }, async ({ defaultName, copyTo }) => { + const parent = BrowserWindow.fromWebContents(event.sender); + const defaultPath = await defaultSaveName(app.getPath("downloads"), defaultName); + const choice = await dialog.showSaveDialog(parent ?? undefined, { + title: "Where do you want to save it?", + message: "Where do you want to save it?", + defaultPath, + buttonLabel: "Save", + properties: ["createDirectory", "showOverwriteConfirmation"], + }); + // Cancelling is a decision, not a failure — the bubble stays quiet. + if (choice.canceled || !choice.filePath) return null; + await copyTo(choice.filePath); + shell.showItemInFolder(choice.filePath); + return choice.filePath; + }); +})); + +// The renderer owns the skin, including the Windows caption buttons it draws +// itself (titleBarStyle hidden, no native overlay). Keep syncing the window +// background so a light skin never flashes the Midnight-black cold start. +ipcMain.handle("desktop:skin", (event, skin) => { + if (!isKnownSkin(skin)) return false; + try { + const { color } = skinChrome(skin); + const win = BrowserWindow.fromWebContents(event.sender) ?? mainWindow; + if (win && !win.isDestroyed()) { + try { win.setBackgroundColor(color); } catch {} + } + } catch {} + return true; +}); + +// Caption controls for the overlay-less frameless window. The renderer's +// buttons are the only way to act on the window, so the channels stay +// open for the local page; a remote server's page never has them. +for (const [channel, act] of [ + ["window:minimize", (win) => win.minimize()], + ["window:toggle-maximize", (win) => (win.isMaximized() ? win.unmaximize() : win.maximize())], + ["window:close", (win) => win.close()], +]) { + ipcMain.handle(channel, (event) => { + const win = BrowserWindow.fromWebContents(event.sender) ?? mainWindow; + if (!win || win.isDestroyed()) return false; + act(win); + return true; + }); +} +ipcMain.handle("window:state", (event) => { + const win = BrowserWindow.fromWebContents(event.sender) ?? mainWindow; + return { maximized: Boolean(win && !win.isDestroyed() && win.isMaximized()) }; +}); + +ipcMain.handle("desktop:open-external", localOnly("desktop:open-external", async (_event, rawUrl) => { + await shell.openExternal(externalWebUrl(rawUrl)); + return true; +})); + +// The Box VNC viewer must be a top-level page for its token exchange. A +// sandboxed modal BrowserWindow satisfies that requirement while keeping the +// live desktop inside OpenMausBot instead of sending the person to a browser. +ipcMain.handle("desktop-viewer:open", localOnly("desktop-viewer:open", (event, rawUrl, title, contextId) => { + const owner = BrowserWindow.fromWebContents(event.sender); + return openDesktopViewer(owner, rawUrl, title, contextId); +})); + +// Two Local VM desktops share the existing app BrowserWindow. The renderer +// supplies only layout and intent; URL validation, sandboxing, session +// isolation and the one-interactive-pane invariant stay in the main process. +ipcMain.handle("desktop-workspace:open", localOnly("desktop-workspace:open", (event, input) => + desktopWorkspaceForEvent(event, true).open(input), +)); +ipcMain.handle("desktop-workspace:layout", localOnly("desktop-workspace:layout", (event, items) => { + const manager = desktopWorkspaceForEvent(event); + if (!manager) return false; + return manager.layout(items); +})); +ipcMain.handle("desktop-workspace:set-interactive", localOnly("desktop-workspace:set-interactive", (event, contextId) => { + const manager = desktopWorkspaceForEvent(event); + if (!manager) return contextId == null; + return manager.setInteractive(contextId); +})); +ipcMain.handle("desktop-workspace:close", localOnly("desktop-workspace:close", (event, contextId) => { + const manager = desktopWorkspaceForEvent(event); + if (!manager) return true; + return manager.close(contextId); +})); + +// Close only when the caller owns the current viewer — otherwise one bot's +// "Hand control back" would close (and release) another bot's viewer. +ipcMain.handle("desktop-viewer:close", localOnly("desktop-viewer:close", (_event, contextId) => { + const scoped = Object.prototype.toString.call(contextId) === "[object String]" ? contextId : null; + if (scoped !== desktopViewerContextId) return false; + if (desktopViewerWindow && !desktopViewerWindow.isDestroyed()) desktopViewerWindow.close(); + return true; +})); + +// Lets a (re)mounted panel seed viewer-open state instead of defaulting to false. +ipcMain.handle("desktop-viewer:state-now", localOnly("desktop-viewer:state-now", () => ({ + open: Boolean(desktopViewerWindow && !desktopViewerWindow.isDestroyed()), + contextId: desktopViewerContextId, +}))); + +ipcMain.handle("perm:status", () => ({ + mic: + nativeActions.appleMediaPermissions + ? systemPreferences.getMediaAccessStatus?.("microphone") ?? "unknown" + : "unsupported", +})); +ipcMain.handle("perm:request-mic", localOnly("perm:request-mic", async () => { + if (!nativeActions.appleMediaPermissions) return false; + try { + return await systemPreferences.askForMediaAccess("microphone"); + } catch { + return false; + } +})); + +// 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", localOnly("perm:open-settings", (_event, pane) => { + if (!nativeActions.applePrivacySettings) return false; + const panes = { + mic: "Privacy_Microphone", + screen: "Privacy_ScreenCapture", + speech: "Privacy_SpeechRecognition", + accessibility: "Privacy_Accessibility", + }; + // own-property lookup only — a renderer-supplied "__proto__"/"constructor" + // would otherwise resolve up the prototype chain to a truthy object + const anchor = Object.hasOwn(panes, pane) ? panes[pane] : "Privacy"; + return shell.openExternal(`x-apple.systempreferences:com.apple.preference.security?${anchor}`); +})); + +ipcMain.handle("speech:start", localOnly("speech:start", (event, options) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return; + if (!nativeActions.appleSpeech) { + win.webContents.send("speech:end", { code: 2, reason: "unsupported-platform" }); + return; + } + startSpeech(win, options); +})); +ipcMain.handle("speech:stop", localOnly("speech:stop", () => { + if (nativeActions.appleSpeech) stopSpeech(); +})); +ipcMain.handle("speech:finish", localOnly("speech:finish", () => { + if (nativeActions.appleSpeech) finishSpeech(); +})); diff --git a/electron/main/desktop-viewer.mjs b/electron/main/desktop-viewer.mjs new file mode 100644 index 0000000000..b65f127fed --- /dev/null +++ b/electron/main/desktop-viewer.mjs @@ -0,0 +1,162 @@ +// Extracted from electron/main.mjs: the live-desktop viewer BrowserWindow — +// its single instance, owner/context tracking, failure page, and open path. +// Owns the desktopViewerWindow/desktopViewerContextId live bindings that the +// viewer IPC handlers in main.mjs read; APP_ICON is shared with main.mjs. +import { BrowserWindow, shell } from "electron"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { desktopViewerPermissionAllowed } from "../desktop-viewer-permissions.mjs"; + +const require = createRequire(import.meta.url); +const { desktopViewerUrl, sameDesktopViewerOrigin } = require("../desktop-viewer.cjs"); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const APP_ICON = path.join(__dirname, "..", "resources", "app-icon.png"); +export let desktopViewerWindow = null; +let desktopViewerOwner = null; +export let desktopViewerContextId = null; + +function notifyDesktopViewer(open) { + if (!desktopViewerOwner?.isDestroyed()) { + desktopViewerOwner.send("desktop-viewer:state", { + open, + contextId: desktopViewerContextId, + }); + } +} + +function desktopViewerErrorPage(message, retryUrl) { + const escape = (value) => + String(value) + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); + return ( + "data:text/html;charset=utf-8," + + encodeURIComponent(`Desktop unavailable + +

Couldn't open the live desktop

+

${escape(message)}

+ Open in browser
+ `) + ); +} + +export function openDesktopViewer(owner, rawUrl, rawTitle, contextId) { + if (!owner || owner.isDestroyed()) throw new Error("The OpenMausBot window is unavailable"); + const url = desktopViewerUrl(rawUrl); + const titleCandidate = Object.prototype.toString.call(rawTitle) === "[object String]" ? rawTitle.trim() : ""; + const title = titleCandidate ? titleCandidate.slice(0, 80) : "Live desktop"; + + const nextContextId = + Object.prototype.toString.call(contextId) === "[object String]" ? contextId.slice(0, 120) : null; + + // Desktop URLs contain rotating access tokens. A newly minted URL replaces + // the old viewer instead of being retained anywhere after its window closes. + // Clear the ref first so the stale window's close handler no-ops; on a bot + // change, tell the previous bot to release (same-bot reopen stays quiet). + if (desktopViewerWindow && !desktopViewerWindow.isDestroyed()) { + const previous = desktopViewerWindow; + const previousOwner = desktopViewerOwner; + const previousContextId = desktopViewerContextId; + desktopViewerWindow = null; + previous.close(); + if (previousContextId !== nextContextId && previousOwner && !previousOwner.isDestroyed()) { + previousOwner.send("desktop-viewer:state", { open: false, contextId: previousContextId }); + } + } + desktopViewerOwner = owner.webContents; + desktopViewerContextId = nextContextId; + + const viewer = new BrowserWindow({ + width: 1220, + height: 820, + minWidth: 760, + minHeight: 520, + parent: owner, + // Not modal: the person still needs the app's "Hand control back" button + // while the desktop is open. `parent` keeps it floating above the app. + modal: false, + show: false, + title, + icon: APP_ICON, + backgroundColor: "#070707", + autoHideMenuBar: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + // Keep provider cookies away from the app renderer and discard them on + // app exit. The secret-bearing URL is sufficient to authenticate. + partition: "openmausbot-desktop-viewer", + }, + }); + desktopViewerWindow = viewer; + const viewerOrigin = url.origin; + + // VNC needs rendering, keyboard/mouse input and WebSockets, plus the few + // permission-gated input capabilities a viewer page asks for: keyboard and + // pointer capture, the clipboard for paste, full screen. Those go to the + // viewer's own origin only — never camera, microphone, geolocation, + // notifications, USB, or any other privileged browser capability in this + // remote-content window (see desktop-viewer-permissions.mjs). + viewer.webContents.session.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => + desktopViewerPermissionAllowed(permission, requestingOrigin, viewerOrigin), + ); + viewer.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => + callback(desktopViewerPermissionAllowed(permission, details?.requestingUrl || webContents.getURL(), viewerOrigin)), + ); + + // A child window floats above the app but does not take the keyboard until + // it is focused: clicks land in the VNC canvas either way, keystrokes only + // reach the key window. Left unfocused, typing "into the VM" lands in the + // composer and ⌘1–9 switch bots while the mouse appears to work. + viewer.once("ready-to-show", () => { + if (viewer.isDestroyed()) return; + viewer.show(); + viewer.focus(); + viewer.webContents.focus(); + }); + viewer.on("closed", () => { + if (desktopViewerWindow !== viewer) return; + desktopViewerWindow = null; + // The panel drops its "viewer open" state and releases control on this. + notifyDesktopViewer(false); + desktopViewerOwner = null; + desktopViewerContextId = null; + }); + viewer.on("page-title-updated", (event) => { + event.preventDefault(); + viewer.setTitle(title); + }); + viewer.webContents.setWindowOpenHandler(({ url: target }) => { + try { + const external = desktopViewerUrl(target); + void shell.openExternal(external.toString()); + } catch { + // Ignore non-web and insecure URLs from the remote viewer. + } + return { action: "deny" }; + }); + viewer.webContents.on("will-navigate", (event, target) => { + if (sameDesktopViewerOrigin(target, viewerOrigin)) return; + event.preventDefault(); + try { + void shell.openExternal(desktopViewerUrl(target).toString()); + } catch { + // Keep privileged or malformed navigation out of the viewer. + } + }); + viewer.webContents.on("did-fail-load", (_event, code, description, failedUrl, isMainFrame) => { + if (!isMainFrame || code === -3 || viewer.isDestroyed() || failedUrl.startsWith("data:")) return; + void viewer.loadURL(desktopViewerErrorPage(description || "The viewer did not respond.", url.toString())); + }); + + notifyDesktopViewer(true); + void viewer.loadURL(url.toString()).catch((error) => { + if (viewer.isDestroyed()) return; + void viewer.loadURL(desktopViewerErrorPage(error?.message ?? "The viewer did not respond.", url.toString())); + }); + return true; +} diff --git a/electron/main/desktop-workspace.mjs b/electron/main/desktop-workspace.mjs new file mode 100644 index 0000000000..6e82aaa3fc --- /dev/null +++ b/electron/main/desktop-workspace.mjs @@ -0,0 +1,66 @@ +// Extracted from electron/main.mjs: the desktop workspace — the native +// WebContentsView panes a workspace bot's live desktop renders into, plus the +// manager/owner pair that pins them to the main app window. Owns the +// desktopWorkspaceManager/desktopWorkspaceOwner live bindings (assigned only +// here, at the same points as before); the workspace IPC handlers stay in +// main.mjs. +import { WebContentsView } from "electron"; +import { randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; +import { getMainWindow } from "./main-window.mjs"; + +const require = createRequire(import.meta.url); +const { createDesktopWorkspaceManager } = require("../desktop-workspace.cjs"); + +let desktopWorkspaceManager = null; +let desktopWorkspaceOwner = null; + +export function ensureDesktopWorkspace(owner) { + if (!owner || owner.isDestroyed()) throw new Error("The OpenMausBot window is unavailable"); + if (desktopWorkspaceManager) { + if (desktopWorkspaceOwner !== owner) { + throw new Error("The desktop workspace belongs to another app window"); + } + return desktopWorkspaceManager; + } + + desktopWorkspaceOwner = owner; + const manager = createDesktopWorkspaceManager({ + owner, + createView: (options) => new WebContentsView(options), + partitionPrefix: `openmausbot-desktop-workspace-${randomUUID()}`, + notify: (state) => { + if (!owner.isDestroyed() && !owner.webContents.isDestroyed()) { + owner.webContents.send("desktop-workspace:state", state); + } + }, + }); + desktopWorkspaceManager = manager; + + // Native child views outlive the renderer DOM unless we explicitly tear + // them down. Reloads, renderer crashes and owner destruction all close both + // panes without retaining their secret-bearing noVNC URLs. + owner.webContents.on("did-start-navigation", (_event, _url, isInPlace, isMainFrame) => { + if (isMainFrame && !isInPlace) manager.closeAll(); + }); + owner.webContents.on("render-process-gone", () => manager.closeAll()); + owner.once("closed", () => { + manager.closeAll(); + if (desktopWorkspaceManager === manager) { + desktopWorkspaceManager = null; + desktopWorkspaceOwner = null; + } + }); + return manager; +} + +export function desktopWorkspaceForEvent(event, create = false) { + const owner = getMainWindow(); + if (!owner || owner.isDestroyed() || event.sender !== owner.webContents) { + throw new Error("The desktop workspace is available only to the main app window"); + } + if (desktopWorkspaceManager && desktopWorkspaceOwner !== owner) { + throw new Error("The desktop workspace belongs to another app window"); + } + return create ? ensureDesktopWorkspace(owner) : desktopWorkspaceManager; +} diff --git a/electron/main/diagnostics.mjs b/electron/main/diagnostics.mjs new file mode 100644 index 0000000000..4caf04d61f --- /dev/null +++ b/electron/main/diagnostics.mjs @@ -0,0 +1,41 @@ +// Extracted from electron/main.mjs: the bug-report bundle collector. The +// desktop:export-diagnostics IPC plumbing stays in main.mjs. SERVER_PORT is +// the live binding from server-runtime.mjs, so a fallback-port boot reports +// against the port actually in use. +import { app } from "electron"; +import path from "node:path"; +import { buildDiagnosticsReport, readSafeLogTail } from "../diagnostics.mjs"; +import { DESKTOP_CRASH_LOG, LOG_DIR } from "./crash-log.mjs"; +import { SERVER_PORT } from "./server-runtime.mjs"; + +// Everything the bug-report bundle needs. The config summary comes from the +// server's own booleans-only /api/config status (credentials are never +// echoed), and the log goes through the redactor in diagnostics.mjs — so the +// file is safe to paste into a public issue even if a future log line ever +// carried a secret. +export async function gatherDiagnostics() { + const serverStatus = await fetch(`http://127.0.0.1:${SERVER_PORT}/api/config`, { + signal: AbortSignal.timeout(3_000), + }) + .then((res) => (res.ok ? res.json() : null)) + .catch(() => null); + const logPath = path.join(LOG_DIR, "server.log"); + const log = readSafeLogTail(logPath); + const desktopLog = readSafeLogTail(DESKTOP_CRASH_LOG); + const updaterLog = readSafeLogTail(path.join(LOG_DIR, "updater.log")); + return buildDiagnosticsReport({ + appInfo: { + version: app.getVersion(), + platform: process.platform, + arch: process.arch, + electron: process.versions.electron, + node: process.versions.node, + packaged: app.isPackaged, + uptimeSeconds: Math.round(process.uptime()), + }, + configSummary: serverStatus ?? {}, + desktopLogTail: desktopLog?.tail ?? "", + updaterLogTail: updaterLog?.tail ?? "", + logTail: log?.tail ?? "", + }); +} diff --git a/electron/main/environments.mjs b/electron/main/environments.mjs new file mode 100644 index 0000000000..3cb222c2cc --- /dev/null +++ b/electron/main/environments.mjs @@ -0,0 +1,331 @@ +// Extracted from electron/main.mjs: the environments subsystem — this +// computer's server or a paired remote one: environments.json persistence +// and switching, hosted-workspace connect/forget, the workspace menu, the +// computer-sharing opt-in, and the renderer origin every local page trusts. +// Owns the environmentsState/computerSharing live bindings; main.mjs imports +// them read-only and mutates only through setEnvironmentsState — the +// server-runtime.mjs accessor pattern. The main-owned lets this region reads +// (desktopRemoteAccess, the launch-time desktopMutationToken, cuaReady) stay +// in main.mjs and cross the boundary as wiring-time getters. +import { app, clipboard, dialog, Menu, session } from "electron"; +import { randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; +import fs from "node:fs"; +import path from "node:path"; +import { buildApplicationMenu } from "../menu.mjs"; +import { createComputerSharing } from "../computer-sharing.mjs"; +import { pasteMenuItem } from "../paste-menu-item.mjs"; +import environmentsModule from "../environments.cjs"; +import localOriginModule from "../local-origin.cjs"; +import { slog } from "./crash-log.mjs"; +import { desktopDataDir } from "./secure-config.mjs"; +import { SERVER_PORT } from "./server-runtime.mjs"; +import { getMainWindow } from "./main-window.mjs"; + +const require = createRequire(import.meta.url); +const { desktopServerHeaders } = require("../desktop-server-auth.cjs"); +const { isLocalSender: senderIsLocal } = localOriginModule; + +// Zero-arg live reads into main.mjs's module state; wired once by main.mjs at +// module load. The defaults mirror main.mjs's initial values and would only +// ever apply if a call somehow preceded the wiring. +const deps = { + desktopRemoteAccess: () => null, + desktopMutationToken: () => null, + cuaReady: () => Promise.resolve({ mode: "unavailable", reason: "not-started" }), +}; + +export function wireEnvironmentsDeps(reads) { + Object.assign(deps, reads); +} + +// 127.0.0.1 explicitly — vite binds IPv4; a bare "localhost" here can +// resolve to ::1 and paint a black window +export const DEV_URL = process.env.ELECTRON_START_URL ?? "http://127.0.0.1:5199"; + +export function rendererOrigin() { + return new URL(app.isPackaged || deps.desktopRemoteAccess() ? `http://127.0.0.1:${SERVER_PORT}` : DEV_URL).origin; +} + +// ── environments: this computer's server, or a paired remote one ────── +// The app switches by loading the chosen server's own UI (electron/menu.mjs). +// Only {id, name, origin} is stored here; the session credential is the +// HttpOnly cookie /pair set for that origin, kept by Chromium's cookie jar. +const { LOCAL_ID, activeEnvironment, allowedOrigins, parseEnvironments, parseHostedWorkspaceLink, serializeEnvironments, withActive, withEnvironment, withoutEnvironment, workspaceMenuTemplate, workspaceNavigationAllowed, workspaceSenderAllowed, workspaceSummary } = environmentsModule; +export let environmentsState = { environments: [], activeId: LOCAL_ID }; +export let computerSharing; +const sharingPrompts = new Set(); + +// Opt-in computer sharing is gated by the server this desktop runs, the same +// way every other server setting reaches this process: the booleans-only +// /api/config status (server/index.ts configStatus → features). It is read +// before the connector could start and again whenever a workspace control is +// used, so a maintainer who edits config.json and restarts the server does not +// have to reinstall the app. Unreachable or older server → off. +let sharedComputersAllowed = false; + +export async function refreshSharedComputersAllowed() { + sharedComputersAllowed = await fetch(`http://127.0.0.1:${SERVER_PORT}/api/config`, { signal: AbortSignal.timeout(3_000) }) + .then((res) => (res.ok ? res.json() : null)) + .then((status) => status?.features?.sharedComputers === true) + .catch(() => false); + return sharedComputersAllowed; +} + +/** Refuse a workspace sharing control the server would refuse anyway. */ +export async function requireSharedComputers() { + if (await refreshSharedComputersAllowed()) return; + throw new Error("Computer sharing is turned off on this server."); +} + +export function sharingController() { + computerSharing ??= createComputerSharing({ + file: path.join(app.getPath("userData"), "computer-sharing.json"), + // The harness server's data directory holds provider API keys and + // sessions.json, so a broad share must never reach it either. + protectedPaths: [desktopDataDir()], + fetch: (...args) => session.defaultSession.fetch(...args), + environments: () => environmentsState.environments, + enabled: refreshSharedComputersAllowed, + cuaConnection: () => deps.cuaReady(), + hostControl: async (id, signal) => { + const lease = async action => { + const response = await fetch(`http://127.0.0.1:${SERVER_PORT}/api/desktop/shared-computer-control`, { + method: "POST", + headers: desktopServerHeaders({ "content-type": "application/json" }, { packaged: app.isPackaged, token: deps.desktopMutationToken() }), + body: JSON.stringify({ id, action }), + signal: action === "release" ? AbortSignal.timeout(3000) : AbortSignal.any([signal, AbortSignal.timeout(3000)]), + }); + if (!response.ok) throw new Error("This computer is in use locally or held by a person. Wait, then observe it again before acting."); + }; + await lease("acquire"); + return { renew: () => lease("acquire"), release: () => lease("release") }; + }, + }); + return computerSharing; +} + +export async function offerComputerSharing(win) { + const env = activeEnvironment(environmentsState); + if (!env || sharingPrompts.has(env.id) || win.isDestroyed()) return; + // Never offer a grant this build's server will not honour. + if (!(await refreshSharedComputersAllowed()) || win.isDestroyed()) return; + sharingPrompts.add(env.id); + try { + const info = await sharingController().observe(env); + if (!info || win.isDestroyed() || activeEnvironment(environmentsState)?.id !== env.id || new URL(win.webContents.getURL()).origin !== env.origin) return; + const choice = await dialog.showMessageBox(win, { + type: "question", message: `Share this computer with ${env.name}?`, + detail: "Let this workspace’s bots use folders and capabilities you choose while this desktop app is running. Nothing is shared unless you enable it. You can change this later in Settings → Connected workspaces.", + buttons: ["Choose access", "Not now"], defaultId: 1, cancelId: 1, + }); + sharingController().decline(env, info); + if (choice.response === 0) openWorkspaceSettings(env.id); + } catch { /* Not paired yet, an older server, or offline: no grant, no prompt. */ } + finally { sharingPrompts.delete(env.id); } +} + +function environmentsFile() { + return path.join(app.getPath("userData"), "environments.json"); +} + +export function readEnvironments() { + try { + return parseEnvironments(fs.readFileSync(environmentsFile(), "utf8")); + } catch { + return { environments: [], activeId: LOCAL_ID }; + } +} + +function writeEnvironments(state) { + const file = environmentsFile(); + const temporary = `${file}.${process.pid}.tmp`; + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(temporary, serializeEnvironments(state), { mode: 0o600 }); + fs.renameSync(temporary, file); + } catch (error) { + try { + fs.rmSync(temporary, { force: true }); + } catch {} + slog(`environments save failed: ${error?.message ?? error}`); + throw new Error("Could not save workspace connections on this computer. Please try again."); + } +} + +/** Where the main window should be: the active remote server, else Local. */ +function activeOrigin() { + return activeEnvironment(environmentsState)?.origin ?? rendererOrigin(); +} + + +export function refreshApplicationMenu() { + Menu.setApplicationMenu( + buildApplicationMenu({ + environments: environmentsState.environments, + activeId: environmentsState.activeId, + onSwitch: (id) => void workspaceMenuAction(() => switchEnvironment(id)), + onAddFromClipboard: () => void addServerFromClipboard(), + onConnect: () => void workspaceMenuAction(openWorkspaceSettings), + onForget: (id) => void workspaceMenuAction(() => forgetEnvironment(id)), + onOpenSettings: () => { + const win = getMainWindow(); + if (win && !win.isDestroyed()) win.webContents.send("app:open-settings"); + }, + }), + ); +} + +function persistEnvironments(next) { + writeEnvironments(next); + environmentsState = next; + refreshApplicationMenu(); +} + + +/** main.mjs assigns its startup read of environments.json through this setter. */ +export function setEnvironmentsState(next) { + environmentsState = next; +} +export async function workspaceMenuAction(action) { + try { await action(); } catch (error) { + await dialog.showMessageBox({ type: "error", message: "Could not update workspaces", detail: error.message }); + } +} + +function navigateMainWindow(url) { + const win = getMainWindow(); + if (!win || win.isDestroyed()) return; + // did-fail-load shows the connection error and returns to the local app. + void win.loadURL(url).catch(() => {}); +} + +export function switchEnvironment(id) { + if (id === environmentsState.activeId || (id !== LOCAL_ID && !environmentsState.environments.some((entry) => entry.id === id))) return; + persistEnvironments(withActive(environmentsState, id)); + navigateMainWindow(activeOrigin()); +} + +export function openWorkspaceSettings(computerId) { + const win = getMainWindow(); + if (!win || win.isDestroyed()) return; + if (senderIsLocal({ sender: win.webContents })) { + win.webContents.send("workspaces:open-settings", typeof computerId === "string" ? computerId : null); + } else { + persistEnvironments(withActive(environmentsState, LOCAL_ID)); + navigateMainWindow(`${rendererOrigin()}/?desktop-settings=workspaces${typeof computerId === "string" ? `&share-computer=${encodeURIComponent(computerId)}` : ""}`); + } +} + +async function addServerFromClipboard() { + try { + return await connectHostedWorkspace(clipboard.readText()); + } catch (error) { + await dialog.showMessageBox({ type: "info", message: "Could not connect workspace", detail: `${error.message}\nYou can also choose Connect hosted workspace to enter an address in Settings.` }); + return false; + } +} + +export async function connectHostedWorkspace(input, name) { + const link = parseHostedWorkspaceLink(input); + if (!link) { + throw new Error("Enter an HTTPS workspace address or a full pairing link. Keep the pairing code after #, not in the URL query."); + } + const host = new URL(link.origin).host; + const { response } = await dialog.showMessageBox({ + type: "question", + buttons: ["Connect", "Cancel"], + defaultId: 0, + cancelId: 1, + message: `Connect to ${host}?`, + detail: link.code + ? "The pairing code in the link is used once, then this app stays signed in to that server." + : "The link has no pairing code; the server will ask for one.", + }); + if (response !== 0) return false; + let next = withEnvironment(environmentsState, { origin: link.origin, name }, () => randomUUID()); + const added = next.environments.find((e) => e.origin === link.origin); + next = withActive(next, added.id); + persistEnvironments(next); + navigateMainWindow(link.url); + return true; +} + +export async function forgetEnvironment(id) { + const env = environmentsState.environments.find((e) => e.id === id); + if (!env) return; + const { response } = await dialog.showMessageBox({ + type: "warning", + buttons: ["Forget", "Cancel"], + defaultId: 1, + cancelId: 1, + message: `Forget “${env.name}”?`, + detail: "This app signs out of that server. The server keeps its own session list; revoke it there too if the device is gone.", + }); + if (response !== 0) return; + sharingController().forget(env); + const wasActive = environmentsState.activeId === id; + persistEnvironments(withoutEnvironment(environmentsState, id)); + // Leave a removed workspace immediately; forgetting an inactive connection + // must not reload the local app or discard a Settings form/chat draft. + if (wasActive) navigateMainWindow(activeOrigin()); + try { + // Revoke the session on the server while the cookie is still here. + const response = await session.defaultSession.fetch(`${env.origin}/api/auth/logout`, { method: "POST", credentials: "include", headers: { origin: env.origin }, signal: AbortSignal.timeout(5_000) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + } catch (error) { + slog(`forget server: logout skipped (${error?.message ?? error})`); + await dialog.showMessageBox({ type: "warning", message: "Connection forgotten; server sign-out could not be confirmed", detail: "Computer sharing is stopped. Revoke this desktop’s session on that server when it is reachable again." }); + } + try { + // Logout clears this server's exact cookie. Cookie storage is host-wide, + // not port-scoped: clearing it here would sign out other saved workspaces. + await session.defaultSession.clearStorageData({ origin: env.origin, storages: ["localstorage", "indexdb", "serviceworkers", "cachestorage"] }); + } catch (error) { + slog(`forget server: storage clear failed: ${error?.message ?? error}`); + } +} + +/** + * Displays the native context menu for editable fields, links, and selections, + * enabling paste if text or a clipboard image is available. + * + * @param {Electron.BrowserWindow} win - Target browser window. + * @param {Electron.ContextMenuParams} params - Context menu parameters from Electron. + * @returns {void} + */ +export function showContextMenu(win, params) { + // nothing actionable here — no menu at all, rather than a wall of + // disabled items + if (!params.isEditable && !params.linkURL && !params.misspelledWord && !params.selectionText) return; + const menuItems = []; + if (params.misspelledWord) { + for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { + menuItems.push({ + label: suggestion, + click: () => win.webContents.replaceMisspelling(suggestion), + }); + } + if (menuItems.length) menuItems.push({ type: "separator" }); + } + if (params.linkURL) { + menuItems.push( + { label: "Copy Link", click: () => clipboard.writeText(params.linkURL) }, + { type: "separator" }, + ); + } + menuItems.push( + { label: "Undo", role: "undo", enabled: params.editFlags.canUndo }, + { label: "Redo", role: "redo", enabled: params.editFlags.canRedo }, + { type: "separator" }, + { label: "Cut", role: "cut", enabled: params.editFlags.canCut }, + { label: "Copy", role: "copy", enabled: params.editFlags.canCopy }, + pasteMenuItem(params, clipboard, win.webContents), + { label: "Paste and Match Style", role: "pasteAndMatchStyle", enabled: params.editFlags.canPaste }, + { type: "separator" }, + { label: "Select All", role: "selectAll", enabled: params.editFlags.canSelectAll }, + ); + Menu.buildFromTemplate(menuItems).popup({ window: win, frame: params.frame }); +} + +export { LOCAL_ID, activeEnvironment, allowedOrigins, workspaceMenuTemplate, workspaceNavigationAllowed, workspaceSenderAllowed, workspaceSummary }; diff --git a/electron/main/ipc-guards.mjs b/electron/main/ipc-guards.mjs new file mode 100644 index 0000000000..a1b89384ca --- /dev/null +++ b/electron/main/ipc-guards.mjs @@ -0,0 +1,11 @@ +// Extracted from electron/main.mjs: the local-origin IPC guard that wraps +// privileged handlers (electron/local-origin.cjs remains the implementation). +// The company-backup subsystem module registers its channels at +// module-evaluation time and cannot import the guard from main.mjs itself — +// that edge would be circular and temporal-dead-zone broken — so the shared +// binding lives here, one hop from both. main.mjs imports localOnly from +// this module and keeps every call site unchanged. +import localOriginModule from "../local-origin.cjs"; + +const { localOnly } = localOriginModule; +export { localOnly }; diff --git a/electron/main/main-window.mjs b/electron/main/main-window.mjs new file mode 100644 index 0000000000..bb30654630 --- /dev/null +++ b/electron/main/main-window.mjs @@ -0,0 +1,20 @@ +// Extracted from electron/main.mjs: the main app window binding. Dozens of +// main.mjs sites read the bare mainWindow name — including the textually +// pinned company-backup region, whose sliced source must keep resolving it — +// and createWindow owns the only assignments. ESM forbids assigning another +// module's let, so this module owns the live binding, exports it read-only, +// and performs the same synchronous assignments at the exact former points +// through setMainWindow — the accessor pattern server-runtime.mjs set for +// SERVER_PORT/serverReady. Extracted subsystems that cannot import main.mjs +// read the window through getMainWindow() instead. +export let mainWindow = null; + +/** The main app window, or null while it is (re)created or after it closes. */ +export function getMainWindow() { + return mainWindow; +} + +/** Assign the main app window (createWindow), or clear it on close. */ +export function setMainWindow(win) { + mainWindow = win; +} diff --git a/electron/main/secure-config.mjs b/electron/main/secure-config.mjs new file mode 100644 index 0000000000..11e3df7c79 --- /dev/null +++ b/electron/main/secure-config.mjs @@ -0,0 +1,176 @@ +// Extracted from electron/main.mjs: the encrypted credential store +// (credentials.bin via safeStorage), the boot-time migration of plaintext +// config.json secrets, and the shared serialized credential state. Owns the +// secureCredentials/secureCredentialState live bindings; main.mjs and the +// companion machinery import them read-only. +import { app, safeStorage } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import { slog } from "./crash-log.mjs"; +import { readSecureCredentials } from "../secure-credentials.mjs"; +import { migrateWorkspaceCredentials } from "../workspace-credentials.mjs"; +import { normalizeManagedComposioBrokerUrl } from "../managed-composio.mjs"; +import { createSecureCredentialState } from "../secure-credential-state.mjs"; + +const DEFAULT_COMPOSIO_BROKER_URL = "https://openmausbot-composio.milindsoni201.workers.dev"; +export let secureCredentials = {}; +export let secureCredentialState = null; + +export function desktopDataDir() { + // Match the historical desktop fallback for an unset or empty override, + // then pass this exact resolved path to the utility child. server/config.ts + // intentionally treats an empty OMB_DATA_DIR differently, so inheriting it + // without normalization would lease one directory and write another. + return process.env.OMB_DATA_DIR || path.join(app.getPath("home"), ".openmausbot"); +} + +const CREDENTIALS_FILE = path.join(app.getPath("userData"), "credentials.bin"); + +/** Set once per launch: true when the store could not be READ, which is not + * the same as the user having saved nothing. Everything downstream — the + * server's view of "configured", and whether we may register a fresh + * installation — keys off this rather than off an empty object. */ +export let credentialStoreUnavailable = false; + +async function loadSecureCredentials() { + const result = await readSecureCredentials({ + exists: () => fs.existsSync(CREDENTIALS_FILE), + isAvailable: () => safeStorage.isAsyncEncryptionAvailable(), + readFile: () => fs.readFileSync(CREDENTIALS_FILE), + decrypt: (buffer) => safeStorage.decryptStringAsync(buffer), + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + }); + credentialStoreUnavailable = result.status === "unavailable"; + if (credentialStoreUnavailable) { + // Deliberately loud. A silent {} here is what made a keychain hiccup + // look like "your connected apps are gone". + slog(`credential store unreadable after retries (${result.error}); saved keys are not loaded this launch`); + } + return result.credentials; +} + +async function saveSecureCredentials(credentials) { + // A failed read means we do not know what the existing encrypted document + // contains. Never derive a replacement from that incomplete view: boot + // migrations must leave plaintext in place so a later launch can retry. + if (credentialStoreUnavailable) { + throw new Error("The operating-system credential store could not be read this launch"); + } + if (!(await safeStorage.isAsyncEncryptionAvailable())) { + throw new Error("The operating-system credential store is unavailable"); + } + fs.mkdirSync(path.dirname(CREDENTIALS_FILE), { recursive: true }); + const encrypted = await safeStorage.encryptStringAsync(JSON.stringify(credentials)); + const temporary = `${CREDENTIALS_FILE}.${process.pid}.tmp`; + fs.writeFileSync(temporary, encrypted, { mode: 0o600 }); + fs.renameSync(temporary, CREDENTIALS_FILE); +} + +async function secureComposioConfig() { + const dataDir = desktopDataDir(); + const configPath = path.join(dataDir, "config.json"); + try { + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + if (!config?.composio || typeof config.composio !== "object") return; + let changed = false; + const apiKey = config?.composio?.apiKey; + if (typeof apiKey === "string" && apiKey.trim().startsWith("ak_")) { + if (!secureCredentials.composioApiKey) { + secureCredentials.composioApiKey = apiKey.trim(); + await saveSecureCredentials(secureCredentials); + } + config.composio.apiKey = ""; + changed = true; + } else if (typeof apiKey === "string" && apiKey.trim()) { + config.composio.apiKey = ""; + changed = true; + } + // These were the old Connect credential and endpoint. They are no longer + // read; remove them during the upgrade so an unused secret is not left in + // plaintext indefinitely. + for (const field of ["key", "url"]) { + if (Object.hasOwn(config.composio, field)) { + delete config.composio[field]; + changed = true; + } + } + if (!changed) return; + const temporary = `${configPath}.${process.pid}.tmp`; + fs.writeFileSync(temporary, JSON.stringify(config, null, 2), { mode: 0o600 }); + fs.renameSync(temporary, configPath); + } catch (error) { + if (error?.code !== "ENOENT") slog(`credential migration failed: ${error?.message ?? error}`); + } +} + +// The remaining workspace credentials (xai/box/voice/OpenCode keys) get +// the same at-rest treatment as the Composio key above. New packaged-app +// saves go straight through credential:set below; this boot-time sweep also +// migrates plaintext left by older versions or direct development clients. +// See workspace-credentials.mjs for the exact rules. +async function secureWorkspaceConfig() { + const dataDir = desktopDataDir(); + const configPath = path.join(dataDir, "config.json"); + try { + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + const migrated = migrateWorkspaceCredentials(config, secureCredentials); + // credentials.bin first: if the OS store cannot take the secrets, the + // plaintext stays put and the next boot retries — losing the only copy + // is the one unacceptable outcome + if (migrated.credentialsChanged) await saveSecureCredentials(migrated.credentials); + secureCredentials = migrated.credentials; + if (!migrated.configChanged) return; + const temporary = `${configPath}.${process.pid}.tmp`; + fs.writeFileSync(temporary, JSON.stringify(migrated.config, null, 2), { mode: 0o600 }); + fs.renameSync(temporary, configPath); + } catch (error) { + if (error?.code !== "ENOENT") slog(`credential migration failed: ${error?.message ?? error}`); + } +} + +export function composioBrokerUrl() { + const configured = process.env.OMB_COMPOSIO_BROKER_URL?.trim(); + return normalizeManagedComposioBrokerUrl( + configured || (app.isPackaged ? DEFAULT_COMPOSIO_BROKER_URL : ""), + ); +} + +/** The one serialized credential mutation hook. Account onboarding and every + * other runtime credential writer share this state, so persisting a tunnel + * token can never overwrite an API key saved at the same time (or vice + * versa). */ +export async function updateSecureCredentialDocument(derive, afterPersist) { + if (!secureCredentialState) throw new Error("Secure credentials are not ready"); + try { + return await secureCredentialState.update(derive, afterPersist); + } finally { + secureCredentials = secureCredentialState.read(); + } +} + +export async function initializeSecureCredentialStore() { + secureCredentials = await loadSecureCredentials(); + // The AssemblyAI key only fed the removed Teach a skill recorder, and its + // set/clear handler went with it; drop the orphaned secret rather than + // keep a third-party key at rest with no way to remove it. + if (secureCredentials && Object.hasOwn(secureCredentials, "assemblyAiApiKey") && !credentialStoreUnavailable) { + try { + const { assemblyAiApiKey: _removed, ...rest } = secureCredentials; + await saveSecureCredentials(rest); + secureCredentials = rest; + } catch (error) { + slog(`orphaned AssemblyAI key not removed: ${error?.message ?? error}`); + } + } + if (app.isPackaged) { + await secureComposioConfig(); + await secureWorkspaceConfig(); + } + // Boot migrations above are deliberately sequential. From this point on, + // every account/API-key writer must use the shared serialized state. + // An unreadable store must not become a WRITE of an empty document. + secureCredentialState = createSecureCredentialState(secureCredentials, saveSecureCredentials, { + writable: !credentialStoreUnavailable, + }); + secureCredentials = secureCredentialState.read(); +} diff --git a/electron/main/server-boot.mjs b/electron/main/server-boot.mjs new file mode 100644 index 0000000000..f2edd87b8e --- /dev/null +++ b/electron/main/server-boot.mjs @@ -0,0 +1,281 @@ +// Extracted from electron/main.mjs: the packaged server boot subsystem, +// verbatim — the utility-server child fork and identity probe, the +// two-pass port-fallback boot, the desktop mutation-token header, the +// phone-secret key sync and private save routing, and the managed-composio +// credential sync. Server runtime state (SERVER_PORT/serverProc/serverReady) +// lives in server-runtime.mjs and is mutated through its imported setters. +// The bindings this region only borrows — the supervisor, the +// trusted-approval coordinator, the data-dir lease, the conflict flag and +// the saveWorkspaceCredential function — are created by main.mjs top-level +// code that runs after this module evaluates, so they cross the boundary +// through the wiring-time deps below (the environments.mjs +// wireEnvironmentsDeps pattern). serverStartConflictOnly stays in main.mjs: +// both this module (startServerPackaged) and main.mjs (the supervisor's +// onReady recovery callback) assign it, so main.mjs keeps the binding and +// passes a setter here. + +import { app, session, utilityProcess } from "electron"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { + managedComposioAccess, + managedComposioChildEnvironment, +} from "../managed-composio.mjs"; +import { + createPhoneSecretSaveCoordinator, + decodePhoneSecretSaveRequest, + phoneSecretPrivateKeyMessage, +} from "../phone-secret-identity.mjs"; +import { pollServerIdentity } from "../server-boot-probe.mjs"; +import { workspaceCredentialEnv } from "../workspace-credentials.mjs"; +import { phoneSecretIdentity } from "./companion-connection.mjs"; +import { + desktopMutationToken, + desktopShutdownStarted, + managedDesktopRelay, + syncDesktopMutationToken, +} from "./company-backup.mjs"; +import { slog } from "./crash-log.mjs"; +import { + composioBrokerUrl, + credentialStoreUnavailable, + desktopDataDir, + secureCredentials, +} from "./secure-config.mjs"; +import { + SERVER_PORT, + serverProc, + serverReady, + setServerPort, + stopUtilityServer, + utilityServerExits, +} from "./server-runtime.mjs"; + +const require = createRequire(import.meta.url); +const { DESKTOP_MUTATION_HEADER } = require("../desktop-server-auth.cjs"); + +// Live reads into main.mjs's module state; wired once by main.mjs at module +// load. Value deps are zero-arg getters (the environments.mjs convention); +// saveWorkspaceCredential is a hoisted function declaration in main.mjs and +// is stored as the function itself. The defaults would only ever apply if a +// call somehow preceded the wiring. +const deps = { + setServerStartConflictOnly: () => {}, + desktopDataDirLease: () => null, + serverSupervisor: () => null, + trustedApprovalMode: () => null, + saveWorkspaceCredential: async () => { + throw new Error("server boot deps are not wired"); + }, +}; + +export function wireServerBootDeps(reads) { + Object.assign(deps, reads); +} + +/** Run one private cleanup request at most once and acknowledge only after + * Chromium confirms its session data is gone. Duplicate retries join the + * same promise; a retry whose success ACK was lost receives a cached ACK. */ + +function syncPhoneSecretKey(proc) { + const message = phoneSecretPrivateKeyMessage(phoneSecretIdentity); + if (!message) return; + try { + proc.postMessage(message); + } catch (error) { + slog(`phone credential key sync failed: ${error?.message ?? error}`); + } +} + +function installDesktopMutationHeader() { + session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { + let ownsTarget = false; + try { + const target = new URL(details.url); + ownsTarget = serverReady && target.protocol === "http:" && + target.hostname === "127.0.0.1" && + Number(target.port || 80) === SERVER_PORT; + } catch {} + if (!ownsTarget) { + callback({ requestHeaders: details.requestHeaders }); + return; + } + callback({ + requestHeaders: { + ...details.requestHeaders, + [DESKTOP_MUTATION_HEADER]: desktopMutationToken, + }, + }); + }); +} + +const savePhoneSecretOnce = createPhoneSecretSaveCoordinator((target, value) => + deps.saveWorkspaceCredential(target, value), +); + +function receivePhoneSecretSave(proc, rawMessage) { + const request = decodePhoneSecretSaveRequest(rawMessage); + if (!request) return false; + void savePhoneSecretOnce(request).then((result) => { + try { + proc.postMessage(result); + } catch (error) { + slog(`phone credential save result failed: ${error?.message ?? error}`); + } + }); + return true; +} + +async function startServerOn(port) { + if (desktopShutdownStarted) return { proc: null, abort: true }; + const entry = path.join(process.resourcesPath, "server", "index.js"); + const childEnv = managedComposioChildEnvironment(composioBrokerUrl(), secureCredentials, { + ...process.env, + // The desktop parent owns the durable data-directory lease. Each utility + // server gets only a private capability that validates that same live + // owner; fallback-port children must not race to replace the parent lease. + ...deps.desktopDataDirLease().utilityServerLeaseEnvironment(), + OMB_DATA_DIR: desktopDataDir(), + // A packaged utility child must never fall back to a descriptor inherited + // from the launching shell. It starts fail-closed until this exact main + // process sends the private in-memory connection after spawn. + OMB_DESKTOP_PARENT: "1", + OMB_STATIC_DIR: path.join(process.resourcesPath, "ui"), + OMB_RESOURCES_PATH: process.resourcesPath, + OMB_SKILLS_DIR: path.join(process.resourcesPath, "skills"), + OMB_PORT: String(port), + // the server advertises this to remote clients so version skew is visible + OMB_APP_VERSION: app.getVersion(), + OMB_USER_DATA: app.getPath("userData"), + ...(secureCredentials.composioApiKey + ? { COMPOSIO_API_KEY: secureCredentials.composioApiKey } + : {}), + // "we could not read your keys" must not reach the UI as "you have none" + OMB_CREDENTIAL_STORE: credentialStoreUnavailable ? "unavailable" : "ok", + // one env var per stored workspace secret (xai/box/voice/OpenCode Go); + // the server prefers these over config.json, whose plaintext fields + // the boot migration has deleted + ...workspaceCredentialEnv(secureCredentials), + }); + delete childEnv.OMB_BROWSER_CONNECTION; + slog(`fork ${entry} port=${port}`); + const proc = utilityProcess.fork(entry, [], { + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let resolveServerExit; + utilityServerExits.set(proc, new Promise((resolve) => { + resolveServerExit = resolve; + })); + proc.stdout?.on("data", (d) => slog(`[out] ${String(d).trimEnd()}`)); + proc.stderr?.on("data", (d) => slog(`[err] ${String(d).trimEnd()}`)); + proc.on("message", (message) => { + if (!deps.serverSupervisor().isCurrent(proc)) return; + try { + if (deps.trustedApprovalMode().receive(proc, message)) return; + if (managedDesktopRelay.receive(proc, message)) return; + if (receivePhoneSecretSave(proc, message)) return; + } catch (error) { + slog(`desktop private sync rejected: ${error?.message ?? error}`); + } + }); + proc.once("spawn", () => { + slog(`spawned pid=${proc.pid}`); + if (!deps.serverSupervisor().isCurrent(proc)) return; + syncDesktopMutationToken(proc); + syncPhoneSecretKey(proc); + }); + let exited = false; + proc.once("exit", (code) => { + exited = true; + deps.trustedApprovalMode().rejectProcess(proc); + managedDesktopRelay.rejectProcess(proc); + resolveServerExit(); + slog(`exited code=${code}`); + }); + deps.serverSupervisor().watch(proc); + // wait for the port to answer (fresh machine: first boot writes data dirs). + // Identity check is by PID: a dev harness server has the same API shape, + // so only the child we actually forked (matching pid + static serving) + // counts as ours. + // The budget is wall-clock, not a fixed poll count: a healthy boot can take + // well past 20s on cold machines or when pre-listen network calls stall + // (issue #506), and reaping an about-to-listen child reads to the user as + // "something else is using its ports" even though nothing was on them. + // The probe itself is deadline-bounded (a hung health endpoint cannot wedge + // us here forever) and reports WHY it gave up, so the error page can tell + // port conflict apart from slow startup. + const identity = await pollServerIdentity({ + port, + // Getter, not value: proc.pid stays undefined until the async `spawn` + // event fires, and capturing it here would make the probe judge our own + // child a "foreign owner" on its first health answer. + pid: () => proc.pid, + bootTimeoutMs: SERVER_BOOT_TIMEOUT_MS, + isExited: () => exited || desktopShutdownStarted, + }); + if (identity.outcome === "ready" && deps.serverSupervisor().isCurrent(proc)) return { proc }; + if (identity.outcome === "exited") { + slog(`child on port ${port} exited before answering /api/health`); + } else { + slog( + identity.outcome === "foreign-owner" + ? `port ${port} answered health checks from another process` + : `child on port ${port} did not answer /api/health within ${SERVER_BOOT_TIMEOUT_MS / 1000}s`, + ); + } + const stopped = await stopUtilityServer(proc); + if (!stopped) { + slog(`child on port ${port} did not exit after termination; refusing to start a sibling server`); + } + return { proc: null, reason: stopped ? identity.outcome : "stuck-child", abort: !stopped }; +} + +async function startServerPackaged() { + // two passes: a quit-and-reopen relaunch can race the dying instance's + // server during teardown — one settle-and-retry covers it + let everyPortForeignOwned = true; + for (let attempt = 0; attempt < 2; attempt++) { + for (const port of [8799, 18799, 28799]) { + if (desktopShutdownStarted) return false; + const started = await startServerOn(port); + if (started.proc) { + setServerPort(port); + if (deps.serverSupervisor().ready(started.proc)) return true; + } + if (started.abort) return false; + // A child that exited or timed out is not evidence of a port conflict — + // only "another process answered health checks" is. + if (started.reason !== "foreign-owner") everyPortForeignOwned = false; + } + await new Promise((r) => setTimeout(r, 2500)); + } + deps.setServerStartConflictOnly(everyPortForeignOwned); + return false; +} + +function syncManagedComposioCredentials() { + if (!serverProc) return; + try { + serverProc.postMessage({ + type: "openmausbot:managed-composio", + access: managedComposioAccess(composioBrokerUrl(), secureCredentials), + }); + } catch (error) { + slog(`connected-apps credential sync failed: ${error?.message ?? error}`); + } +} + +// How long one packaged-server child gets to answer /api/health before the +// parent reaps it and tries the next port. Wall-clock, deliberately generous: +// first boots write data dirs and pre-listen network calls (managed composio, +// workspace credentials) can stall a healthy child far past 20s on some +// machines, which used to surface as the misleading "ports are busy" page. +const SERVER_BOOT_TIMEOUT_MS = 60_000; + +export { + installDesktopMutationHeader, + startServerOn, + startServerPackaged, + syncManagedComposioCredentials, +}; diff --git a/electron/main/server-runtime.mjs b/electron/main/server-runtime.mjs new file mode 100644 index 0000000000..26bd63b24e --- /dev/null +++ b/electron/main/server-runtime.mjs @@ -0,0 +1,59 @@ +// Extracted from electron/main.mjs: the live utility-server runtime state +// (port, process handle, readiness) and the tracked, timeout-bounded stop +// path for the packaged harness server child. Owns the +// SERVER_PORT/serverProc/serverReady live bindings; main.mjs imports them +// read-only and mutates them only through the exported setters, at the exact +// points where it used to assign the locals directly. +import { app } from "electron"; + +// Packaged: the harness server ships in Resources (compiled JS, zero deps) +// and runs on Electron's own Node via utilityProcess. It serves the built +// UI too, so the window talks to one origin and there is no dev proxy. +// A stray server on the default port must not brick the app — fall back to +// alternate ports until one binds AND identifies as ours (the probe checks +// our API shape, not just a 200). +export let SERVER_PORT = 8799; +export let serverProc = null; +export let serverReady = !app.isPackaged; + +export function setServerPort(port) { + SERVER_PORT = port; +} + +export function setServerReady(ready) { + serverReady = ready; +} + +/** A health-probed utility child just became the current server. */ +export function adoptUtilityServer(proc) { + serverProc = proc; + serverReady = true; +} + +export function markServerUnavailable() { + serverReady = false; + serverProc = null; +} + +export const utilityServerExits = new WeakMap(); +const UTILITY_SERVER_STOP_TIMEOUT_MS = 6_500; + +export async function stopUtilityServer(proc, timeoutMs = UTILITY_SERVER_STOP_TIMEOUT_MS) { + if (!proc) return true; + const exited = utilityServerExits.get(proc); + if (!exited) return false; + try { + proc.kill(); + } catch { + // The tracked exit promise below is still the authority. A throw can mean + // the process crossed the exit boundary immediately before kill(). + } + let timer; + return Promise.race([ + exited.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + timer.unref?.(); + }), + ]).finally(() => clearTimeout(timer)); +} diff --git a/electron/main/unread-badge.mjs b/electron/main/unread-badge.mjs new file mode 100644 index 0000000000..75da772d5f --- /dev/null +++ b/electron/main/unread-badge.mjs @@ -0,0 +1,97 @@ +// Extracted from electron/main.mjs: the unread badge and package-install +// subsystem, verbatim — the dock/overlay badge state, the deep-link package +// install queue and its delivery into the local page, the WeakSet of windows +// left on the server-unavailable error page, and the desktop:unread-count +// IPC registration (a feature registration, not an app lifecycle hook — the +// company-backup.mjs precedent). This module owns the +// pendingPackageInstallUrl/unreadCount/unreadOverlayIcon live bindings; +// main.mjs keeps the open-url and second-instance lifecycle hooks and +// reassigns pendingPackageInstallUrl only through setPendingPackageInstallUrl +// at the exact former assignment point — the server-runtime.mjs accessor +// pattern. serverUnavailableWindows is a const WeakSet and crosses as-is. + +import { app, BrowserWindow, ipcMain, nativeImage } from "electron"; +import { createRequire } from "node:module"; +import { packageUrlFromCommandLine, packageUrlFromDeepLink } from "../package-link.mjs"; +import { activateExistingWindow } from "../single-instance.mjs"; +import { APP_ICON } from "./desktop-viewer.mjs"; +import { mainWindow } from "./main-window.mjs"; +import { + LOCAL_ID, + activeEnvironment, + environmentsState, + rendererOrigin, + switchEnvironment, + workspaceMenuAction, +} from "./environments.mjs"; + +const require = createRequire(import.meta.url); +const { normalizeUnreadCount } = require("../window-state.cjs"); + +let pendingPackageInstallUrl = packageUrlFromCommandLine(process.argv); +const serverUnavailableWindows = new WeakSet(); +let unreadCount = 0; +let unreadOverlayIcon = null; + +function applyUnreadBadge(win = mainWindow) { + const count = normalizeUnreadCount(unreadCount); + if (process.platform === "win32") { + if (!win || win.isDestroyed()) return; + unreadOverlayIcon ??= nativeImage.createFromPath(APP_ICON).resize({ width: 16, height: 16 }); + win.setOverlayIcon( + count > 0 && !unreadOverlayIcon.isEmpty() ? unreadOverlayIcon : null, + count > 0 ? `${count} unread conversation${count === 1 ? "" : "s"}` : "No unread conversations", + ); + return; + } + if (process.platform === "darwin" || process.platform === "linux") app.setBadgeCount(count); +} + +function deliverPackageInstall(win) { + if (!pendingPackageInstallUrl || !win || win.isDestroyed()) return; + if (win.webContents.isLoadingMainFrame()) return; + // A package installs into THIS computer's workspace, so it is handed to the + // local UI only. Showing a remote server: switch back to Local first; the + // pending link is delivered when that page finishes loading. + let showingLocal = false; + try { + showingLocal = new URL(win.webContents.getURL()).origin === rendererOrigin(); + } catch {} + if (!showingLocal) { + if (activeEnvironment(environmentsState)) void workspaceMenuAction(() => switchEnvironment(LOCAL_ID)); + return; + } + win.webContents.send("package:install", pendingPackageInstallUrl); + pendingPackageInstallUrl = null; +} + +function queuePackageInstall(rawLink) { + const packageUrl = packageUrlFromDeepLink(rawLink); + if (!packageUrl) return false; + pendingPackageInstallUrl = packageUrl; + activateExistingWindow(BrowserWindow.getAllWindows()); + const target = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()); + deliverPackageInstall(target); + return true; +} + +ipcMain.on("desktop:unread-count", (event, value) => { + const sender = BrowserWindow.fromWebContents(event.sender); + if (!sender || sender !== mainWindow || sender.isDestroyed()) return; + unreadCount = normalizeUnreadCount(value); + applyUnreadBadge(sender); +}); + +// This module owns the pendingPackageInstallUrl live binding; main.mjs +// reassigns it only through this setter, at the exact former assignment +// point inside its second-instance lifecycle hook. +export function setPendingPackageInstallUrl(url) { + pendingPackageInstallUrl = url; +} + +export { + applyUnreadBadge, + deliverPackageInstall, + queuePackageInstall, + serverUnavailableWindows, +}; diff --git a/electron/main/window-state.mjs b/electron/main/window-state.mjs new file mode 100644 index 0000000000..44eb2ad681 --- /dev/null +++ b/electron/main/window-state.mjs @@ -0,0 +1,72 @@ +// Extracted from electron/main.mjs: persistence for the main window's bounds +// and maximize state (window-state.json in userData), flushed on a debounced +// resize/move and on close. +import { app } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { slog } from "./crash-log.mjs"; + +const require = createRequire(import.meta.url); +const { parseWindowState } = require("../window-state.cjs"); + +function windowStateFile() { + return path.join(app.getPath("userData"), "window-state.json"); +} + +export function readWindowState() { + try { + return parseWindowState(fs.readFileSync(windowStateFile(), "utf8")); + } catch { + return null; + } +} + +function writeWindowState(win) { + if (!win || win.isDestroyed()) return; + const file = windowStateFile(); + const temporary = `${file}.${process.pid}.tmp`; + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + temporary, + JSON.stringify({ bounds: win.getNormalBounds(), maximized: win.isMaximized() }), + { mode: 0o600 }, + ); + fs.renameSync(temporary, file); + } catch (error) { + try { + fs.rmSync(temporary, { force: true }); + } catch {} + slog(`window state save failed: ${error?.message ?? error}`); + } +} + +export function installWindowStatePersistence(win) { + let timer = null; + const flush = () => { + if (timer) clearTimeout(timer); + timer = null; + writeWindowState(win); + }; + const schedule = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(flush, 250); + timer.unref?.(); + }; + win.on("resize", schedule); + win.on("move", schedule); + // The renderer's caption buttons track the native maximize state (the + // restore/maximize glyph flips); a lost push just leaves a stale glyph + // until the next toggle, so a send failure is not fatal. + const pushMaximized = () => { + try { + if (!win.isDestroyed()) win.webContents.send("window:maximized-changed", win.isMaximized()); + } catch {} + }; + win.on("maximize", pushMaximized); + win.on("unmaximize", pushMaximized); + win.on("maximize", schedule); + win.on("unmaximize", schedule); + win.on("close", flush); +} diff --git a/scripts/control-omb.ts b/scripts/control-omb.ts index acc7a433c4..c2a5f2bd2b 100644 --- a/scripts/control-omb.ts +++ b/scripts/control-omb.ts @@ -8,8 +8,9 @@ import { delimiter, dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { parseArgs, type ParseArgsOptionsConfig } from "node:util"; -import { handleToolCall, request, validateBaseUrl } from "./mcp-server.ts"; +import { handleToolCall, request } from "./mcp-server.ts"; import { launchUi, runControlOmbUi } from "./testing/control-omb-ui.ts"; +import { configuredServerUrl, validateBaseUrl } from "../shared/server-endpoint.ts"; import { removeTempDir, waitForExit } from "../server/testing/cleanup.ts"; import { freePortBlock } from "../server/testing/ports.ts"; @@ -123,7 +124,7 @@ function positiveInteger(value: unknown, name: string, fallback: number, maximum function configuredUrl(raw: unknown, env: NodeJS.ProcessEnv, requiredForMutation: boolean): string | undefined { const explicit = typeof raw === "string" && raw.trim() ? raw.trim() - : env.OPENMAUSBOT_URL?.trim() || (env.OMB_PORT ? `http://127.0.0.1:${env.OMB_PORT}` : ""); + : configuredServerUrl(env) ?? ""; if (!explicit) { if (requiredForMutation) { throw new ControlOmbError( diff --git a/scripts/mcp-server.ts b/scripts/mcp-server.ts index b60ecd5deb..176c5871a6 100644 --- a/scripts/mcp-server.ts +++ b/scripts/mcp-server.ts @@ -3,35 +3,14 @@ // Standard JSON-RPC 2.0 stdio transport for external agent orchestration (Hermes, Claude Desktop, Cursor, etc.). import readline from "node:readline"; -export function validateBaseUrl(url: string): string { - const trimmed = url.replace(/\/+$/, ""); - let parsed: URL; - try { - parsed = new URL(trimmed); - } catch { - throw new Error(`Invalid OpenMausBot URL: '${url}'`); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error("OpenMausBot URL must use http:// or https://"); - } - if (parsed.username || parsed.password) { - throw new Error("OpenMausBot URL must not contain credentials; use OPENMAUSBOT_TOKEN instead"); - } - if ((parsed.pathname !== "/" && parsed.pathname !== "") || parsed.search || parsed.hash) { - throw new Error("OpenMausBot URL must be an origin without a path, query, or fragment"); - } - const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ""); - const isLoopback = hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1"; - if (parsed.protocol === "http:" && !isLoopback && process.env.ALLOW_INSECURE_HTTP !== "true") { - throw new Error( - `Insecure cleartext HTTP origin '${parsed.origin}' is rejected. Use https:// or set ALLOW_INSECURE_HTTP=true.`, - ); - } - return parsed.origin; -} +import { configuredServerUrl, validateBaseUrl } from "../shared/server-endpoint.ts"; + +import { isRecord, ToolInputError, type ToolHandler } from "./mcp-server/context.ts"; +import { TOOL_HANDLERS, type ToolName } from "./mcp-server/registry.ts"; + +export { ToolInputError }; -const configuredUrl = process.env.OPENMAUSBOT_URL || - (process.env.OMB_PORT ? `http://127.0.0.1:${process.env.OMB_PORT}` : undefined); +const configuredUrl = configuredServerUrl(process.env); export const OMB_BASE_URL = validateBaseUrl(configuredUrl || "http://127.0.0.1:8799"); const DISCOVERY_URLS = configuredUrl @@ -118,12 +97,12 @@ export async function request(path: string, options: RequestInit = {}, baseUrl?: } export interface McpToolDefinition { - name: string; + name: ToolName; description: string; inputSchema: { type: "object"; properties: Record; - required?: string[]; + required?: readonly string[]; additionalProperties?: boolean; }; annotations?: { @@ -141,7 +120,7 @@ const MUTATING = { readOnlyHint: false, destructiveHint: false, idempotentHint: const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false } as const; const AGENT_ACTION = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true } as const; -export const TOOLS: McpToolDefinition[] = [ +export const TOOLS = [ { name: "get_system_health", description: "Check whether the OpenMausBot server is reachable.", @@ -454,16 +433,16 @@ export const TOOLS: McpToolDefinition[] = [ }, annotations: DESTRUCTIVE, }, -]; +] as const satisfies readonly McpToolDefinition[]; -function parsePositiveLimit(raw: unknown, fallback = 30, maximum = 200): number { - const parsed = Math.floor(Number(raw)); - return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback; -} +// McpToolDefinition keys the list above by the registry's ToolName, so an +// entry without a handler fails to typecheck; this constant fails the build +// the other way round — the two records cannot drift apart. +export const toolsCoverHandlers: Exclude extends never + ? true + : "every tool handler needs an entry in the tool list" + = true; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} function valueHasType(value: unknown, type: string): boolean { if (type === "null") return value === null; @@ -519,8 +498,6 @@ function schemaError(schema: Record, value: unknown, path: string): return null; } -export class ToolInputError extends Error {} - export function validateToolArguments(name: unknown, args: unknown): asserts args is Record { if (typeof name !== "string" || !name) throw new ToolInputError("tool name must be a non-empty string"); const tool = TOOLS.find((candidate) => candidate.name === name); @@ -530,255 +507,6 @@ export function validateToolArguments(name: unknown, args: unknown): asserts arg if (error) throw new ToolInputError(error); } -function stringArg(args: Record, key: string, options: { trim?: boolean; allowEmpty?: boolean; max?: number } = {}): string { - const raw = args[key]; - if (typeof raw !== "string") throw new ToolInputError(`${key} must be a string`); - const value = options.trim === false ? raw : raw.trim(); - if (!options.allowEmpty && !value) throw new ToolInputError(`${key} must not be empty`); - if (options.max && value.length > options.max) throw new ToolInputError(`${key} must be at most ${options.max} characters`); - return value; -} - -function optionalStringArg( - args: Record, - key: string, - options: { trim?: boolean; allowEmpty?: boolean; max?: number } = {}, -): string | undefined { - if (!(key in args)) return undefined; - return stringArg(args, key, options); -} - -function idArg(args: Record, key: string): string { - const value = stringArg(args, key); - if (!/^[\w-]+$/.test(value)) throw new ToolInputError(`${key} is not a valid OpenMausBot ID`); - return value; -} - -function stringArrayArg(args: Record, key: string): string[] { - const value = args[key]; - if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || !item.trim())) { - throw new ToolInputError(`${key} must be a non-empty list of IDs`); - } - return [...new Set(value.map((item) => item.trim()))]; -} - -function records(value: unknown): Array> { - return Array.isArray(value) ? value.filter(isRecord) : []; -} - -function projectTask(task: Record, activeThreadId: unknown) { - return { - taskId: task.threadId, - title: task.title, - createdAt: task.createdAt, - ...(typeof task.busy === "boolean" ? { busy: task.busy } : {}), - ...(task.activity ? { activity: task.activity } : {}), - ...(task.modelSelection ? { modelSelection: task.modelSelection } : {}), - ...(typeof activeThreadId === "string" ? { active: task.threadId === activeThreadId } : {}), - ...(task.usage ? { usage: task.usage } : {}), - }; -} - -function botTaskState(bot: Record, taskId: string) { - const task = records(bot.tasks).find((candidate) => candidate.threadId === taskId); - if (task && (typeof task.busy === "boolean" || typeof task.activity === "string")) return task; - // Older servers cannot run non-selected tasks and expose only bot activity. - return bot.threadId === taskId ? bot : { busy: false, activity: "idle" }; -} - -function projectBot(bot: Record) { - return { - id: bot.id, - name: bot.name, - title: bot.title, - description: bot.description, - section: bot.section ?? null, - chiefOfStaff: Boolean(bot.chiefOfStaff), - modelSelection: bot.modelSelection, - busy: Boolean(bot.busy), - activity: bot.activity, - unread: Boolean(bot.unread), - activeTaskId: bot.threadId, - tasks: records(bot.tasks).map((task) => projectTask(task, bot.threadId)), - }; -} - -function projectChannel(channel: Record) { - return { - id: channel.id, - name: channel.name, - memberIds: channel.memberIds, - bulletin: channel.bulletin, - defaultResponder: channel.defaultResponder, - section: channel.section ?? null, - directMessage: Boolean(channel.dm), - working: Boolean(channel.working), - busyBotId: channel.busyBotId ?? null, - activeTaskId: channel.threadId, - tasks: records(channel.tasks).map((task) => projectTask(task, channel.threadId)), - }; -} - -function projectMessage(message: Record) { - const card = isRecord(message.card) - ? { - title: message.card.title, - subtitle: message.card.subtitle, - options: message.card.options, - answered: message.card.answered, - dismissed: message.card.dismissed, - } - : undefined; - const tool = isRecord(message.tool) - ? { name: message.tool.name, ok: message.tool.ok, spoken: message.tool.spoken, setup: message.tool.setup, - ...(message.tool.terminal === true ? { terminal: true } : {}), - } - : undefined; - const connector = isRecord(message.connector) - ? { - slug: message.connector.slug, - label: message.connector.label, - description: message.connector.description, - status: message.connector.status, - dismissed: message.connector.dismissed, - resumed: message.connector.resumed, - } - : undefined; - const secret = isRecord(message.secret) - ? { - target: message.secret.target, - label: message.secret.label, - description: message.secret.description, - placeholder: message.secret.placeholder, - helpUrl: message.secret.helpUrl, - provided: message.secret.provided, - dismissed: message.secret.dismissed, - resumed: message.secret.resumed, - } - : undefined; - return { - id: message.id, - at: message.at, - role: message.role, - kind: message.kind, - text: message.text, - from: message.from, - replyToId: message.replyToId, - reactions: message.reactions, - steered: message.steered, - queued: message.queued, - ...(tool ? { tool } : {}), - ...(card ? { card } : {}), - ...(connector ? { connector } : {}), - ...(secret ? { secret } : {}), - ...(message.kind === "screen" ? { hasImage: Boolean(message.hasImage || message.png) } : {}), - }; -} - -async function fleet(fetcher: (path: string, options?: RequestInit) => Promise) { - return fetcher("/api/bots?messages=0"); -} - -function taskBelongsTo(owner: Record, taskId: string): boolean { - return owner.threadId === taskId || records(owner.tasks).some((task) => task.threadId === taskId); -} - -function messageNeedsInput(message: Record): boolean { - const card = isRecord(message.card) && message.card.requestId && !message.card.answered && !message.card.dismissed; - const connector = isRecord(message.connector) && - !message.connector.dismissed && - !message.connector.resumed && - message.connector.status !== "connected"; - const secret = isRecord(message.secret) && !message.secret.provided && !message.secret.dismissed; - return Boolean(card || connector || secret); -} - -function dispatchFailedAfterLatestUser(messages: Array>): boolean { - const lastUser = messages.findLastIndex((message) => message.role === "user"); - const turnMessages = messages.slice(lastUser + 1); - // Only an explicit terminal receipt overrides prose. Existing providers - // also emit diagnostics on intentional cancellation, which remain settled. - if (turnMessages.some((message) => message.tool?.terminal === true && message.tool.ok === false)) return true; - if (turnMessages.some((message) => message.role === "bot" && message.kind === "text" && message.text?.trim())) { - return false; - } - return turnMessages.some( - (message) => - message.kind === "activity" && - message.tool?.ok === false && - typeof message.tool?.name === "string" && - /^error:/i.test(message.tool.name.trim()), - ); -} - -async function conversationTail( - fetcher: (path: string, options?: RequestInit) => Promise, - taskId: string, - limit = 10, -) { - const page = await fetcher(`/api/threads/${encodeURIComponent(taskId)}/messages?limit=${limit}`); - const raw = records(page.messages); - return { - raw, - messages: raw.map(projectMessage), - hasMore: Boolean(page.hasMore), - }; -} - -function normalizeResponder(value: unknown): Record | undefined { - if (value === undefined) return undefined; - if (!isRecord(value)) throw new ToolInputError("default_responder must be an object"); - if (value.kind === "everyone" || value.kind === "mentions") return { kind: value.kind }; - if (value.kind === "member" && typeof value.bot_id === "string" && value.bot_id.trim()) { - return { kind: "member", botId: value.bot_id.trim() }; - } - throw new ToolInputError("default_responder is invalid"); -} - -async function checkedModelSelection( - args: Record, - fetcher: (path: string, options?: RequestInit) => Promise, -) { - const instanceId = stringArg(args, "instance_id"); - const model = stringArg(args, "model"); - const effort = optionalStringArg(args, "effort"); - const described = await fetcher("/api/instances"); - const instance = records(described.instances).find((candidate) => candidate.instanceId === instanceId); - if (!instance) throw new ToolInputError(`model instance not found: ${instanceId}`); - if (instance.snapshot?.state !== "available") throw new ToolInputError(`model instance is unavailable: ${instanceId}`); - const models = isRecord(instance.models) ? instance.models : {}; - const offered = records(models.options).map((option) => option.id).filter((id) => typeof id === "string"); - if (models.default !== model && !offered.includes(model)) { - throw new ToolInputError(`model '${model}' is not offered by instance '${instanceId}'`); - } - const efforts = Array.isArray(instance.capabilities?.effortLevels) ? instance.capabilities.effortLevels : []; - if (effort && !efforts.includes(effort)) { - throw new ToolInputError(`effort '${effort}' is not offered by instance '${instanceId}'`); - } - return { instanceId, model, ...(effort ? { effort } : {}) }; -} - -function taskRoute(targetType: unknown, targetId: string): string { - if (targetType === "bot") return `/api/bots/${encodeURIComponent(targetId)}/tasks`; - if (targetType === "channel") return `/api/groups/${encodeURIComponent(targetId)}/tasks`; - throw new ToolInputError("target_type must be bot or channel"); -} - -function sleep(ms: number, signal?: AbortSignal) { - return new Promise((resolve, reject) => { - if (signal?.aborted) return reject(signal.reason ?? new Error("Request cancelled")); - const onAbort = () => { - clearTimeout(timer); - signal?.removeEventListener("abort", onAbort); - reject(signal?.reason ?? new Error("Request cancelled")); - }; - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, ms); - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} export async function handleToolCall( name: string, @@ -790,444 +518,13 @@ export async function handleToolCall( ? (path: string, options: RequestInit = {}) => baseFetcher(path, { ...options, signal: options.signal ?? signal }) : baseFetcher; validateToolArguments(name, args); - switch (name) { - case "get_system_health": { - const res = await fetcher("/api/health"); - if (res?.app !== "openmausbot") throw new Error("The configured endpoint is not an OpenMausBot server"); - return { - status: "connected", - endpoint: discoveredBaseUrl ?? OMB_BASE_URL, - app: "openmausbot", - packaged: Boolean(res.static), - }; - } - - case "list_bots": { - const res = await fleet(fetcher); - return { bots: records(res.bots).map(projectBot) }; - } - - case "get_bot_messages": { - const botId = idArg(args, "bot_id"); - const res = await fleet(fetcher); - const bot = records(res.bots).find((candidate) => candidate.id === botId); - if (!bot) throw new Error(`Bot not found: ${botId}`); - const taskId = args.task_id === undefined ? String(bot.threadId) : idArg(args, "task_id"); - if (!taskBelongsTo(bot, taskId)) throw new Error(`Task '${taskId}' does not belong to bot '${botId}'`); - const limit = parsePositiveLimit(args.limit, 30, 200); - const page = await fetcher(`/api/threads/${encodeURIComponent(taskId)}/messages?limit=${limit}`); - return { - bot: projectBot(bot), - taskId, - messages: records(page.messages).map(projectMessage), - hasMore: Boolean(page.hasMore), - }; - } - - case "send_bot_message": { - const botId = idArg(args, "bot_id"); - const text = stringArg(args, "text", { trim: true, max: 100_000 }); - const state = await fleet(fetcher); - const bot = records(state.bots).find((candidate) => candidate.id === botId); - if (!bot) throw new Error(`Bot not found: ${botId}`); - const taskId = args.task_id === undefined ? String(bot.threadId) : idArg(args, "task_id"); - if (!taskBelongsTo(bot, taskId)) throw new Error(`Task '${taskId}' does not belong to bot '${botId}'`); - const busyChannel = records(state.groups).find((channel) => channel.busyBotId === botId); - if (busyChannel) { - throw new Error(`Bot '${botId}' is working in channel '${busyChannel.id}'; send to or interrupt that channel instead`); - } - await fetcher(`/api/bots/${encodeURIComponent(botId)}/messages`, { - method: "POST", - body: JSON.stringify({ text, threadId: taskId }), - }); - return { success: true, botId, taskId }; - } - - case "create_bot": { - const name = stringArg(args, "name", { max: 100 }); - const title = optionalStringArg(args, "title", { trim: false, allowEmpty: true, max: 200 }); - const description = optionalStringArg(args, "description", { trim: false, allowEmpty: true, max: 4_000 }); - const section = optionalStringArg(args, "section", { max: 60 }); - const wantsModel = args.instance_id !== undefined || args.model !== undefined || args.effort !== undefined; - if (wantsModel && (args.instance_id === undefined || args.model === undefined)) { - throw new ToolInputError("instance_id and model must be provided together"); - } - const selection = wantsModel ? await checkedModelSelection(args, fetcher) : undefined; - const created = await fetcher("/api/bots", { - method: "POST", - body: JSON.stringify({ - name, - ...(title !== undefined ? { title } : {}), - ...(description !== undefined ? { description } : {}), - ...(section !== undefined ? { section } : {}), - ...(selection ? { modelSelection: selection, requireAvailableModel: true } : {}), - }), - }); - if (!isRecord(created?.bot) || typeof created.bot.id !== "string") { - throw new Error("OpenMausBot did not return the created bot"); - } - return { success: true, bot: projectBot(created.bot) }; - } - - case "update_bot_profile": { - const botId = idArg(args, "bot_id"); - const patch: Record = {}; - if (args.name !== undefined) patch.name = stringArg(args, "name", { max: 100 }); - if (args.title !== undefined) patch.title = stringArg(args, "title", { trim: false, allowEmpty: true, max: 200 }); - if (args.description !== undefined) patch.description = stringArg(args, "description", { trim: false, allowEmpty: true, max: 4_000 }); - if ("section" in args) patch.section = args.section === null ? null : stringArg(args, "section", { max: 60 }); - if (!Object.keys(patch).length) throw new ToolInputError("provide at least one profile field to update"); - const result = await fetcher(`/api/bots/${encodeURIComponent(botId)}`, { - method: "PATCH", - body: JSON.stringify(patch), - }); - if (!isRecord(result?.bot)) { - throw new Error("OpenMausBot did not return the updated bot"); - } - return { success: true, bot: projectBot(result.bot) }; - } - - case "list_channels": { - const res = await fleet(fetcher); - return { channels: records(res.groups).map(projectChannel) }; - } - - case "get_channel_messages": { - const channelId = idArg(args, "channel_id"); - const res = await fleet(fetcher); - const channel = records(res.groups).find((candidate) => candidate.id === channelId); - if (!channel) throw new Error(`Channel not found: ${channelId}`); - const taskId = args.task_id === undefined ? String(channel.threadId) : idArg(args, "task_id"); - if (!taskBelongsTo(channel, taskId)) throw new Error(`Task '${taskId}' does not belong to channel '${channelId}'`); - const limit = parsePositiveLimit(args.limit, 30, 200); - const page = await fetcher(`/api/threads/${encodeURIComponent(taskId)}/messages?limit=${limit}`); - return { - channel: projectChannel(channel), - taskId, - messages: records(page.messages).map(projectMessage), - hasMore: Boolean(page.hasMore), - }; - } - - case "send_channel_message": { - const channelId = idArg(args, "channel_id"); - const text = stringArg(args, "text", { trim: true, max: 100_000 }); - const state = await fleet(fetcher); - const channel = records(state.groups).find((candidate) => candidate.id === channelId); - if (!channel) throw new Error(`Channel not found: ${channelId}`); - const taskId = args.task_id === undefined ? String(channel.threadId) : idArg(args, "task_id"); - if (!taskBelongsTo(channel, taskId)) { - throw new Error(`Task '${taskId}' does not belong to channel '${channelId}'`); - } - if (channel.threadId !== taskId) { - throw new Error(`Task '${taskId}' is not active for channel '${channelId}'; switch to it before sending`); - } - await fetcher(`/api/groups/${encodeURIComponent(channelId)}/messages`, { - method: "POST", - body: JSON.stringify({ text, threadId: taskId }), - }); - return { success: true, channelId, taskId }; - } - - case "create_channel": { - const name = stringArg(args, "name", { max: 100 }); - const memberIds = stringArrayArg(args, "member_ids"); - const section = optionalStringArg(args, "section", { max: 60 }); - const bulletin = optionalStringArg(args, "bulletin", { trim: false, allowEmpty: true, max: 12_000 }) ?? ""; - const requestedResponder = normalizeResponder(args.default_responder); - if (requestedResponder?.kind === "member" && !memberIds.includes(requestedResponder.botId)) { - throw new ToolInputError("default_responder bot must be a channel member"); - } - const responder = requestedResponder ?? { kind: "member", botId: memberIds[0] }; - const created = await fetcher("/api/groups", { - method: "POST", - body: JSON.stringify({ - name, - memberIds, - ...(section ? { section } : {}), - setup: { bulletin, defaultResponder: responder }, - }), - }); - if (!isRecord(created?.group) || typeof created.group.id !== "string") { - throw new Error("OpenMausBot did not return the created channel"); - } - return { success: true, channel: projectChannel(created.group) }; - } - - case "update_channel": { - const channelId = idArg(args, "channel_id"); - const patch: Record = {}; - if (args.name !== undefined) patch.name = stringArg(args, "name", { max: 100 }); - if (args.member_ids !== undefined) patch.memberIds = stringArrayArg(args, "member_ids"); - if (args.section !== undefined) patch.section = args.section === null ? null : stringArg(args, "section", { max: 60 }); - if (args.bulletin !== undefined) patch.bulletin = stringArg(args, "bulletin", { trim: false, allowEmpty: true, max: 12_000 }); - if (args.default_responder !== undefined) patch.defaultResponder = normalizeResponder(args.default_responder); - if (Object.keys(patch).length === 0) throw new ToolInputError("provide at least one channel field to update"); - const memberIds = patch.memberIds as string[] | undefined; - const responder = patch.defaultResponder as Record | undefined; - if (memberIds && responder?.kind === "member" && !memberIds.includes(responder.botId)) { - throw new ToolInputError("default_responder bot must be a channel member"); - } - const result = await fetcher(`/api/groups/${encodeURIComponent(channelId)}`, { - method: "PATCH", - body: JSON.stringify(patch), - }); - if (!isRecord(result?.group)) { - throw new Error("OpenMausBot did not return the updated channel"); - } - return { success: true, channel: projectChannel(result.group) }; - } - - case "create_task": { - const targetId = idArg(args, "target_id"); - const title = optionalStringArg(args, "title", { max: 80 }); - const route = taskRoute(args.target_type, targetId); - const result = await fetcher(route, { method: "POST", body: JSON.stringify(title ? { title } : {}) }); - if (!isRecord(result?.task) || typeof result.task.threadId !== "string") { - throw new Error("OpenMausBot did not return the created task"); - } - const activeTaskId = result.bot?.threadId ?? result.group?.threadId ?? result.task?.threadId; - return { - success: true, - targetType: args.target_type, - targetId, - task: projectTask(result.task, activeTaskId), - }; - } - - case "switch_task": { - const targetId = idArg(args, "target_id"); - const taskId = idArg(args, "task_id"); - const route = taskRoute(args.target_type, targetId); - const result = await fetcher(`${route}/${encodeURIComponent(taskId)}?messages=0`, { method: "POST", body: "{}" }); - const target = args.target_type === "bot" ? result.bot : result.group; - return { - success: true, - targetType: args.target_type, - targetId, - taskId, - ...(isRecord(target) - ? { target: args.target_type === "bot" ? projectBot(target) : projectChannel(target) } - : {}), - }; - } - - case "rename_task": { - const targetId = idArg(args, "target_id"); - const taskId = idArg(args, "task_id"); - const title = stringArg(args, "title", { max: 80 }); - const route = taskRoute(args.target_type, targetId); - const result = await fetcher(`${route}/${encodeURIComponent(taskId)}`, { - method: "PATCH", - body: JSON.stringify({ title }), - }); - if (!isRecord(result?.task)) { - throw new Error("OpenMausBot did not return the renamed task"); - } - return { - success: true, - targetType: args.target_type, - targetId, - task: projectTask(result.task, undefined), - }; - } - - case "search_messages": { - const query = stringArg(args, "query", { max: 500 }); - const limit = parsePositiveLimit(args.limit, 40, 100); - const params = new URLSearchParams({ q: query, limit: String(limit) }); - if (args.task_id !== undefined) params.set("threadId", idArg(args, "task_id")); - const result = await fetcher(`/api/search?${params.toString()}`); - return { hits: records(result.hits) }; - } - - case "wait_for_conversation": { - const targetType = args.target_type; - if (targetType !== "bot" && targetType !== "channel") { - throw new ToolInputError("target_type must be bot or channel"); - } - const targetId = idArg(args, "target_id"); - const timeoutSeconds = parsePositiveLimit(args.timeout_seconds, 30, 120); - const deadline = Date.now() + timeoutSeconds * 1_000; - const startupGraceDeadline = Math.min(deadline, Date.now() + 750); - let state = await fleet(fetcher); - const collection = targetType === "bot" ? records(state.bots) : records(state.groups); - let target = collection.find((candidate) => candidate.id === targetId); - if (!target) throw new Error(`${targetType === "bot" ? "Bot" : "Channel"} not found: ${targetId}`); - const taskId = args.task_id === undefined ? String(target.threadId) : idArg(args, "task_id"); - if (!taskBelongsTo(target, taskId)) { - throw new Error(`Task '${taskId}' does not belong to ${targetType} '${targetId}'`); - } - let sawBusy = false; - while (true) { - const liveCollection = targetType === "bot" ? records(state.bots) : records(state.groups); - target = liveCollection.find((candidate) => candidate.id === targetId); - if (!target) throw new Error(`${targetType === "bot" ? "Bot" : "Channel"} not found: ${targetId}`); - if (!taskBelongsTo(target, taskId)) { - throw new Error(`Task '${taskId}' no longer belongs to ${targetType} '${targetId}'`); - } - const projectedTarget = targetType === "bot" ? projectBot(target) : projectChannel(target); - const terminal = async (status: string, existingTail?: Awaited>) => { - const tail = existingTail ?? await conversationTail(fetcher, taskId); - const needsInput = tail.raw.some(messageNeedsInput); - const terminalStatus = status === "settled" && dispatchFailedAfterLatestUser(tail.raw) - ? "failed" - : status; - return { - status: needsInput ? "needs-user" : terminalStatus, - targetType, - targetId, - taskId, - target: projectedTarget, - messages: tail.messages, - hasMore: tail.hasMore, - }; - }; - - if (targetType === "bot") { - const task = botTaskState(target, taskId); - const busyChannel = task === target && records(state.groups).find((channel) => channel.busyBotId === targetId); - if (busyChannel) { - throw new Error(`Bot '${targetId}' is working in channel '${busyChannel.id}'; wait on that channel instead`); - } - if (task.activity === "waiting-on-you") return terminal("needs-user"); - if (task.activity === "dead") return terminal("failed"); - if (task.activity === "no-signal") return terminal("stalled"); - if (!task.busy) return terminal("settled"); - sawBusy = true; - } else { - if (target.threadId !== taskId) return terminal("settled"); - const tail = await conversationTail(fetcher, taskId); - if (tail.raw.some(messageNeedsInput)) { - return terminal("needs-user", tail); - } - const channelWorking = target.working === true || Boolean(target.busyBotId); - if (channelWorking) { - sawBusy = true; - const busyBotId = target.busyBotId; - if (busyBotId) { - const speaker = records(state.bots).find((bot) => bot.id === busyBotId); - if (!speaker) return terminal("stalled"); - if (speaker.activity === "waiting-on-you") return terminal("needs-user"); - if (speaker.activity === "dead") return terminal("failed"); - if (speaker.activity === "no-signal") return terminal("stalled"); - } - } else { - const latest = tail.raw.at(-1); - // New servers expose `working` synchronously before returning a - // channel send. The short grace remains only for older servers - // that have no operation-level field and report a user message - // just before their first speaker becomes busy. - if (sawBusy || target.working === false || latest?.role !== "user") { - return terminal("settled", tail); - } - if (Date.now() >= startupGraceDeadline) { - return terminal("settled", tail); - } - } - } - - if (Date.now() >= deadline) return terminal("timed-out"); - await sleep(Math.min(500, Math.max(0, deadline - Date.now())), signal); - state = await fleet(fetcher); - } - } - - case "set_bot_model": { - const botId = idArg(args, "bot_id"); - const current = await fleet(fetcher); - const bot = records(current.bots).find((candidate) => candidate.id === botId); - if (!bot) throw new Error(`Bot not found: ${botId}`); - if (args.task_id !== undefined) { - const taskId = idArg(args, "task_id"); - if (!taskBelongsTo(bot, taskId)) throw new Error(`Task '${taskId}' does not belong to bot '${botId}'`); - if (botTaskState(bot, taskId).busy) throw new Error("Interrupt the task or let it finish before changing its model"); - const selection = await checkedModelSelection(args, fetcher); - const res = await fetcher(`${taskRoute("bot", botId)}/${encodeURIComponent(taskId)}`, { - method: "PATCH", - body: JSON.stringify({ modelSelection: selection, requireAvailableModel: true }), - }); - if (!isRecord(res?.task)) throw new Error("OpenMausBot did not return the updated task"); - return { success: true, botId, task: projectTask(res.task, bot.threadId) }; - } - if (bot.busy) throw new Error("Interrupt the bot or let it finish before changing its model"); - const selection = await checkedModelSelection(args, fetcher); - const res = await fetcher(`/api/bots/${encodeURIComponent(botId)}`, { - method: "PATCH", - body: JSON.stringify({ modelSelection: selection, requireAvailableModel: true }), - }); - return { success: true, bot: projectBot(res.bot) }; - } - - case "edit_bot_message": { - const botId = idArg(args, "bot_id"); - const messageId = idArg(args, "message_id"); - const text = String(args.text ?? "").trim(); - if (!text) throw new Error("text is required"); - const current = await fleet(fetcher); - const bot = records(current.bots).find((candidate) => candidate.id === botId); - if (!bot) throw new Error(`Bot not found: ${botId}`); - let threadId: string | undefined; - if (args.task_id !== undefined) { - threadId = idArg(args, "task_id"); - if (!taskBelongsTo(bot, threadId)) throw new Error(`Task '${threadId}' does not belong to bot '${botId}'`); - if (botTaskState(bot, threadId).busy) throw new Error("Interrupt the task or let it finish before editing a message"); - } else if (bot.busy) { - // the server refuses a rewind under a live turn — branching beneath - // a dying turn is how a thread ends up with two tails - throw new Error("Interrupt the bot or let it finish before editing a message"); - } - const res = await fetcher( - `/api/bots/${encodeURIComponent(botId)}/messages/${encodeURIComponent(messageId)}/edit`, - { method: "POST", body: JSON.stringify({ text, ...(threadId ? { threadId } : {}) }) }, - ); - return { success: true, botId, message: res?.message ?? null }; - } - - case "list_available_models": { - const res = await fetcher("/api/instances"); - return { - instances: records(res.instances).map((instance) => ({ - instanceId: instance.instanceId, - driverKind: instance.driverKind, - displayName: instance.displayName, - snapshot: { state: instance.snapshot?.state }, - models: instance.models, - capabilities: instance.capabilities, - access: instance.access, - })), - }; - } - - case "interrupt_conversation": { - const targetType = args.target_type; - if (targetType !== "bot" && targetType !== "channel") { - throw new ToolInputError("target_type must be bot or channel"); - } - const targetId = idArg(args, "target_id"); - const current = await fleet(fetcher); - const target = (targetType === "bot" ? records(current.bots) : records(current.groups)) - .find((candidate) => candidate.id === targetId); - if (!target) throw new Error(`${targetType === "bot" ? "Bot" : "Channel"} not found: ${targetId}`); - const taskId = args.task_id === undefined ? String(target.threadId) : idArg(args, "task_id"); - if (!taskBelongsTo(target, taskId)) throw new Error(`Task '${taskId}' does not belong to ${targetType} '${targetId}'`); - if (targetType === "bot") { - const busyChannel = botTaskState(target, taskId) === target && records(current.groups).find((channel) => channel.busyBotId === targetId); - if (busyChannel) { - throw new Error(`Bot '${targetId}' is working in channel '${busyChannel.id}'; interrupt that channel instead`); - } - } - const route = targetType === "bot" ? "bots" : "groups"; - await fetcher(`/api/${route}/${encodeURIComponent(targetId)}/interrupt`, { - method: "POST", - body: JSON.stringify({ threadId: taskId }), - }); - return { success: true, targetType, targetId, taskId }; - } - - default: - throw new ToolInputError(`Unknown tool: ${name}`); - } + const handler = (TOOL_HANDLERS as Record)[name]; + if (!handler) throw new ToolInputError(`Unknown tool: ${name}`); + return handler(args, { + fetch: fetcher, + signal, + endpoint: () => discoveredBaseUrl ?? OMB_BASE_URL, + }); } export function formatResponse(id: string | number | null, result?: unknown, error?: { code?: number; message?: string }) { diff --git a/scripts/mcp-server/bots.ts b/scripts/mcp-server/bots.ts new file mode 100644 index 0000000000..7ff0a95640 --- /dev/null +++ b/scripts/mcp-server/bots.ts @@ -0,0 +1,111 @@ +// Bot tools: profiles plus the per-bot task and message surface. + +import { idArg, isRecord, optionalStringArg, parsePositiveLimit, stringArg, ToolInputError, type Json, type ToolContext, type ToolHandler } from "./context.ts"; +import { botTaskState, checkedModelSelection, fleet, projectBot, projectMessage, records, taskBelongsTo } from "./shared.ts"; + +export const handlers = { + async list_bots(_args: Json, ctx: ToolContext): Promise { + const res = await fleet(ctx.fetch); + return { bots: records(res.bots).map(projectBot) }; + }, + async get_bot_messages(args: Json, ctx: ToolContext): Promise { + const botId = idArg(args, "bot_id"); + const res = await fleet(ctx.fetch); + const bot = records(res.bots).find((candidate) => candidate.id === botId); + if (!bot) throw new Error(`Bot not found: ${botId}`); + const taskId = args.task_id === undefined ? String(bot.threadId) : idArg(args, "task_id"); + if (!taskBelongsTo(bot, taskId)) throw new Error(`Task '${taskId}' does not belong to bot '${botId}'`); + const limit = parsePositiveLimit(args.limit, 30, 200); + const page = await ctx.fetch(`/api/threads/${encodeURIComponent(taskId)}/messages?limit=${limit}`); + return { + bot: projectBot(bot), + taskId, + messages: records(page.messages).map(projectMessage), + hasMore: Boolean(page.hasMore), + }; + }, + async send_bot_message(args: Json, ctx: ToolContext): Promise { + const botId = idArg(args, "bot_id"); + const text = stringArg(args, "text", { trim: true, max: 100_000 }); + const state = await fleet(ctx.fetch); + const bot = records(state.bots).find((candidate) => candidate.id === botId); + if (!bot) throw new Error(`Bot not found: ${botId}`); + const taskId = args.task_id === undefined ? String(bot.threadId) : idArg(args, "task_id"); + if (!taskBelongsTo(bot, taskId)) throw new Error(`Task '${taskId}' does not belong to bot '${botId}'`); + const busyChannel = records(state.groups).find((channel) => channel.busyBotId === botId); + if (busyChannel) { + throw new Error(`Bot '${botId}' is working in channel '${busyChannel.id}'; send to or interrupt that channel instead`); + } + await ctx.fetch(`/api/bots/${encodeURIComponent(botId)}/messages`, { + method: "POST", + body: JSON.stringify({ text, threadId: taskId }), + }); + return { success: true, botId, taskId }; + }, + async create_bot(args: Json, ctx: ToolContext): Promise { + const name = stringArg(args, "name", { max: 100 }); + const title = optionalStringArg(args, "title", { trim: false, allowEmpty: true, max: 200 }); + const description = optionalStringArg(args, "description", { trim: false, allowEmpty: true, max: 4_000 }); + const section = optionalStringArg(args, "section", { max: 60 }); + const wantsModel = args.instance_id !== undefined || args.model !== undefined || args.effort !== undefined; + if (wantsModel && (args.instance_id === undefined || args.model === undefined)) { + throw new ToolInputError("instance_id and model must be provided together"); + } + const selection = wantsModel ? await checkedModelSelection(args, ctx.fetch) : undefined; + const created = await ctx.fetch("/api/bots", { + method: "POST", + body: JSON.stringify({ + name, + ...(title !== undefined ? { title } : {}), + ...(description !== undefined ? { description } : {}), + ...(section !== undefined ? { section } : {}), + ...(selection ? { modelSelection: selection, requireAvailableModel: true } : {}), + }), + }); + if (!isRecord(created?.bot) || typeof created.bot.id !== "string") { + throw new Error("OpenMausBot did not return the created bot"); + } + return { success: true, bot: projectBot(created.bot) }; + }, + async update_bot_profile(args: Json, ctx: ToolContext): Promise { + const botId = idArg(args, "bot_id"); + const patch: Record = {}; + if (args.name !== undefined) patch.name = stringArg(args, "name", { max: 100 }); + if (args.title !== undefined) patch.title = stringArg(args, "title", { trim: false, allowEmpty: true, max: 200 }); + if (args.description !== undefined) patch.description = stringArg(args, "description", { trim: false, allowEmpty: true, max: 4_000 }); + if ("section" in args) patch.section = args.section === null ? null : stringArg(args, "section", { max: 60 }); + if (!Object.keys(patch).length) throw new ToolInputError("provide at least one profile field to update"); + const result = await ctx.fetch(`/api/bots/${encodeURIComponent(botId)}`, { + method: "PATCH", + body: JSON.stringify(patch), + }); + if (!isRecord(result?.bot)) { + throw new Error("OpenMausBot did not return the updated bot"); + } + return { success: true, bot: projectBot(result.bot) }; + }, + async edit_bot_message(args: Json, ctx: ToolContext): Promise { + const botId = idArg(args, "bot_id"); + const messageId = idArg(args, "message_id"); + const text = String(args.text ?? "").trim(); + if (!text) throw new Error("text is required"); + const current = await fleet(ctx.fetch); + const bot = records(current.bots).find((candidate) => candidate.id === botId); + if (!bot) throw new Error(`Bot not found: ${botId}`); + let threadId: string | undefined; + if (args.task_id !== undefined) { + threadId = idArg(args, "task_id"); + if (!taskBelongsTo(bot, threadId)) throw new Error(`Task '${threadId}' does not belong to bot '${botId}'`); + if (botTaskState(bot, threadId).busy) throw new Error("Interrupt the task or let it finish before editing a message"); + } else if (bot.busy) { + // the server refuses a rewind under a live turn — branching beneath + // a dying turn is how a thread ends up with two tails + throw new Error("Interrupt the bot or let it finish before editing a message"); + } + const res = await ctx.fetch( + `/api/bots/${encodeURIComponent(botId)}/messages/${encodeURIComponent(messageId)}/edit`, + { method: "POST", body: JSON.stringify({ text, ...(threadId ? { threadId } : {}) }) }, + ); + return { success: true, botId, message: res?.message ?? null }; + }, +} satisfies Record; diff --git a/scripts/mcp-server/channels.ts b/scripts/mcp-server/channels.ts new file mode 100644 index 0000000000..3e54aafe25 --- /dev/null +++ b/scripts/mcp-server/channels.ts @@ -0,0 +1,103 @@ +// Channel tools: multi-agent groups, their members, and message surface. + +import { idArg, isRecord, optionalStringArg, parsePositiveLimit, stringArg, stringArrayArg, ToolInputError, type Json, type ToolContext, type ToolHandler } from "./context.ts"; +import { fleet, projectChannel, projectMessage, records, taskBelongsTo } from "./shared.ts"; + +function normalizeResponder(value: unknown): Record | undefined { + if (value === undefined) return undefined; + if (!isRecord(value)) throw new ToolInputError("default_responder must be an object"); + if (value.kind === "everyone" || value.kind === "mentions") return { kind: value.kind }; + if (value.kind === "member" && typeof value.bot_id === "string" && value.bot_id.trim()) { + return { kind: "member", botId: value.bot_id.trim() }; + } + throw new ToolInputError("default_responder is invalid"); +} + +export const handlers = { + async list_channels(_args: Json, ctx: ToolContext): Promise { + const res = await fleet(ctx.fetch); + return { channels: records(res.groups).map(projectChannel) }; + }, + async get_channel_messages(args: Json, ctx: ToolContext): Promise { + const channelId = idArg(args, "channel_id"); + const res = await fleet(ctx.fetch); + const channel = records(res.groups).find((candidate) => candidate.id === channelId); + if (!channel) throw new Error(`Channel not found: ${channelId}`); + const taskId = args.task_id === undefined ? String(channel.threadId) : idArg(args, "task_id"); + if (!taskBelongsTo(channel, taskId)) throw new Error(`Task '${taskId}' does not belong to channel '${channelId}'`); + const limit = parsePositiveLimit(args.limit, 30, 200); + const page = await ctx.fetch(`/api/threads/${encodeURIComponent(taskId)}/messages?limit=${limit}`); + return { + channel: projectChannel(channel), + taskId, + messages: records(page.messages).map(projectMessage), + hasMore: Boolean(page.hasMore), + }; + }, + async send_channel_message(args: Json, ctx: ToolContext): Promise { + const channelId = idArg(args, "channel_id"); + const text = stringArg(args, "text", { trim: true, max: 100_000 }); + const state = await fleet(ctx.fetch); + const channel = records(state.groups).find((candidate) => candidate.id === channelId); + if (!channel) throw new Error(`Channel not found: ${channelId}`); + const taskId = args.task_id === undefined ? String(channel.threadId) : idArg(args, "task_id"); + if (!taskBelongsTo(channel, taskId)) { + throw new Error(`Task '${taskId}' does not belong to channel '${channelId}'`); + } + if (channel.threadId !== taskId) { + throw new Error(`Task '${taskId}' is not active for channel '${channelId}'; switch to it before sending`); + } + await ctx.fetch(`/api/groups/${encodeURIComponent(channelId)}/messages`, { + method: "POST", + body: JSON.stringify({ text, threadId: taskId }), + }); + return { success: true, channelId, taskId }; + }, + async create_channel(args: Json, ctx: ToolContext): Promise { + const name = stringArg(args, "name", { max: 100 }); + const memberIds = stringArrayArg(args, "member_ids"); + const section = optionalStringArg(args, "section", { max: 60 }); + const bulletin = optionalStringArg(args, "bulletin", { trim: false, allowEmpty: true, max: 12_000 }) ?? ""; + const requestedResponder = normalizeResponder(args.default_responder); + if (requestedResponder?.kind === "member" && !memberIds.includes(requestedResponder.botId)) { + throw new ToolInputError("default_responder bot must be a channel member"); + } + const responder = requestedResponder ?? { kind: "member", botId: memberIds[0] }; + const created = await ctx.fetch("/api/groups", { + method: "POST", + body: JSON.stringify({ + name, + memberIds, + ...(section ? { section } : {}), + setup: { bulletin, defaultResponder: responder }, + }), + }); + if (!isRecord(created?.group) || typeof created.group.id !== "string") { + throw new Error("OpenMausBot did not return the created channel"); + } + return { success: true, channel: projectChannel(created.group) }; + }, + async update_channel(args: Json, ctx: ToolContext): Promise { + const channelId = idArg(args, "channel_id"); + const patch: Record = {}; + if (args.name !== undefined) patch.name = stringArg(args, "name", { max: 100 }); + if (args.member_ids !== undefined) patch.memberIds = stringArrayArg(args, "member_ids"); + if (args.section !== undefined) patch.section = args.section === null ? null : stringArg(args, "section", { max: 60 }); + if (args.bulletin !== undefined) patch.bulletin = stringArg(args, "bulletin", { trim: false, allowEmpty: true, max: 12_000 }); + if (args.default_responder !== undefined) patch.defaultResponder = normalizeResponder(args.default_responder); + if (Object.keys(patch).length === 0) throw new ToolInputError("provide at least one channel field to update"); + const memberIds = patch.memberIds as string[] | undefined; + const responder = patch.defaultResponder as Record | undefined; + if (memberIds && responder?.kind === "member" && !memberIds.includes(responder.botId)) { + throw new ToolInputError("default_responder bot must be a channel member"); + } + const result = await ctx.fetch(`/api/groups/${encodeURIComponent(channelId)}`, { + method: "PATCH", + body: JSON.stringify(patch), + }); + if (!isRecord(result?.group)) { + throw new Error("OpenMausBot did not return the updated channel"); + } + return { success: true, channel: projectChannel(result.group) }; + }, +} satisfies Record; diff --git a/scripts/mcp-server/context.ts b/scripts/mcp-server/context.ts new file mode 100644 index 0000000000..a15678fcb2 --- /dev/null +++ b/scripts/mcp-server/context.ts @@ -0,0 +1,65 @@ +// The contract between the MCP entry (scripts/mcp-server.ts) and its handler +// modules. Every tool is a plain (args, ctx) function returning the raw +// result payload; process-level state (endpoint discovery) and the +// per-request abort wiring arrive through ToolContext explicitly instead of +// module globals. Argument validation keeps the same ToolInputError the +// JSON-RPC layer maps to invalid-params responses. + +/** Tool arguments as they arrive from the JSON-RPC client. */ +export type Json = Record; + +/** A handler's answer: the raw result payload, stringified by the caller. */ +export type ToolHandler = (args: Json, ctx: ToolContext) => Promise; + +export interface ToolContext { + /** The request helper with the call's abort signal already bound in. */ + fetch(path: string, options?: RequestInit): Promise; + /** The call's abort signal, when the client provided one. */ + readonly signal?: AbortSignal; + /** The endpoint the health check reports: the discovered URL, else the configured one. */ + endpoint(): string; +} + +/** Invalid tool input: the JSON-RPC layer reports these as invalid params. */ +export class ToolInputError extends Error {} + +export function parsePositiveLimit(raw: unknown, fallback = 30, maximum = 200): number { + const parsed = Math.floor(Number(raw)); + return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback; +} + +export function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function stringArg(args: Record, key: string, options: { trim?: boolean; allowEmpty?: boolean; max?: number } = {}): string { + const raw = args[key]; + if (typeof raw !== "string") throw new ToolInputError(`${key} must be a string`); + const value = options.trim === false ? raw : raw.trim(); + if (!options.allowEmpty && !value) throw new ToolInputError(`${key} must not be empty`); + if (options.max && value.length > options.max) throw new ToolInputError(`${key} must be at most ${options.max} characters`); + return value; +} + +export function optionalStringArg( + args: Record, + key: string, + options: { trim?: boolean; allowEmpty?: boolean; max?: number } = {}, +): string | undefined { + if (!(key in args)) return undefined; + return stringArg(args, key, options); +} + +export function idArg(args: Record, key: string): string { + const value = stringArg(args, key); + if (!/^[\w-]+$/.test(value)) throw new ToolInputError(`${key} is not a valid OpenMausBot ID`); + return value; +} + +export function stringArrayArg(args: Record, key: string): string[] { + const value = args[key]; + if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || !item.trim())) { + throw new ToolInputError(`${key} must be a non-empty list of IDs`); + } + return [...new Set(value.map((item) => item.trim()))]; +} diff --git a/scripts/mcp-server/conversations.ts b/scripts/mcp-server/conversations.ts new file mode 100644 index 0000000000..cfca351654 --- /dev/null +++ b/scripts/mcp-server/conversations.ts @@ -0,0 +1,181 @@ +// Conversation tools: bounded waits and interruption across bots and channels. + +import { idArg, isRecord, parsePositiveLimit, ToolInputError, type Json, type ToolContext, type ToolHandler } from "./context.ts"; +import { botTaskState, fleet, projectBot, projectChannel, projectMessage, records, taskBelongsTo } from "./shared.ts"; + +function messageNeedsInput(message: Record): boolean { + const card = isRecord(message.card) && message.card.requestId && !message.card.answered && !message.card.dismissed; + const connector = isRecord(message.connector) && + !message.connector.dismissed && + !message.connector.resumed && + message.connector.status !== "connected"; + const secret = isRecord(message.secret) && !message.secret.provided && !message.secret.dismissed; + return Boolean(card || connector || secret); +} + +function dispatchFailedAfterLatestUser(messages: Array>): boolean { + const lastUser = messages.findLastIndex((message) => message.role === "user"); + const turnMessages = messages.slice(lastUser + 1); + // Only an explicit terminal receipt overrides prose. Existing providers + // also emit diagnostics on intentional cancellation, which remain settled. + if (turnMessages.some((message) => message.tool?.terminal === true && message.tool.ok === false)) return true; + if (turnMessages.some((message) => message.role === "bot" && message.kind === "text" && message.text?.trim())) { + return false; + } + return turnMessages.some( + (message) => + message.kind === "activity" && + message.tool?.ok === false && + typeof message.tool?.name === "string" && + /^error:/i.test(message.tool.name.trim()), + ); +} + +async function conversationTail( + fetcher: (path: string, options?: RequestInit) => Promise, + taskId: string, + limit = 10, +) { + const page = await fetcher(`/api/threads/${encodeURIComponent(taskId)}/messages?limit=${limit}`); + const raw = records(page.messages); + return { + raw, + messages: raw.map(projectMessage), + hasMore: Boolean(page.hasMore), + }; +} + +function sleep(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason ?? new Error("Request cancelled")); + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + reject(signal?.reason ?? new Error("Request cancelled")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export const handlers = { + async wait_for_conversation(args: Json, ctx: ToolContext): Promise { + const targetType = args.target_type; + if (targetType !== "bot" && targetType !== "channel") { + throw new ToolInputError("target_type must be bot or channel"); + } + const targetId = idArg(args, "target_id"); + const timeoutSeconds = parsePositiveLimit(args.timeout_seconds, 30, 120); + const deadline = Date.now() + timeoutSeconds * 1_000; + const startupGraceDeadline = Math.min(deadline, Date.now() + 750); + let state = await fleet(ctx.fetch); + const collection = targetType === "bot" ? records(state.bots) : records(state.groups); + let target = collection.find((candidate) => candidate.id === targetId); + if (!target) throw new Error(`${targetType === "bot" ? "Bot" : "Channel"} not found: ${targetId}`); + const taskId = args.task_id === undefined ? String(target.threadId) : idArg(args, "task_id"); + if (!taskBelongsTo(target, taskId)) { + throw new Error(`Task '${taskId}' does not belong to ${targetType} '${targetId}'`); + } + let sawBusy = false; + while (true) { + const liveCollection = targetType === "bot" ? records(state.bots) : records(state.groups); + target = liveCollection.find((candidate) => candidate.id === targetId); + if (!target) throw new Error(`${targetType === "bot" ? "Bot" : "Channel"} not found: ${targetId}`); + if (!taskBelongsTo(target, taskId)) { + throw new Error(`Task '${taskId}' no longer belongs to ${targetType} '${targetId}'`); + } + const projectedTarget = targetType === "bot" ? projectBot(target) : projectChannel(target); + const terminal = async (status: string, existingTail?: Awaited>) => { + const tail = existingTail ?? await conversationTail(ctx.fetch, taskId); + const needsInput = tail.raw.some(messageNeedsInput); + const terminalStatus = status === "settled" && dispatchFailedAfterLatestUser(tail.raw) + ? "failed" + : status; + return { + status: needsInput ? "needs-user" : terminalStatus, + targetType, + targetId, + taskId, + target: projectedTarget, + messages: tail.messages, + hasMore: tail.hasMore, + }; + }; + + if (targetType === "bot") { + const task = botTaskState(target, taskId); + const busyChannel = task === target && records(state.groups).find((channel) => channel.busyBotId === targetId); + if (busyChannel) { + throw new Error(`Bot '${targetId}' is working in channel '${busyChannel.id}'; wait on that channel instead`); + } + if (task.activity === "waiting-on-you") return terminal("needs-user"); + if (task.activity === "dead") return terminal("failed"); + if (task.activity === "no-signal") return terminal("stalled"); + if (!task.busy) return terminal("settled"); + sawBusy = true; + } else { + if (target.threadId !== taskId) return terminal("settled"); + const tail = await conversationTail(ctx.fetch, taskId); + if (tail.raw.some(messageNeedsInput)) { + return terminal("needs-user", tail); + } + const channelWorking = target.working === true || Boolean(target.busyBotId); + if (channelWorking) { + sawBusy = true; + const busyBotId = target.busyBotId; + if (busyBotId) { + const speaker = records(state.bots).find((bot) => bot.id === busyBotId); + if (!speaker) return terminal("stalled"); + if (speaker.activity === "waiting-on-you") return terminal("needs-user"); + if (speaker.activity === "dead") return terminal("failed"); + if (speaker.activity === "no-signal") return terminal("stalled"); + } + } else { + const latest = tail.raw.at(-1); + // New servers expose `working` synchronously before returning a + // channel send. The short grace remains only for older servers + // that have no operation-level field and report a user message + // just before their first speaker becomes busy. + if (sawBusy || target.working === false || latest?.role !== "user") { + return terminal("settled", tail); + } + if (Date.now() >= startupGraceDeadline) { + return terminal("settled", tail); + } + } + } + + if (Date.now() >= deadline) return terminal("timed-out"); + await sleep(Math.min(500, Math.max(0, deadline - Date.now())), ctx.signal); + state = await fleet(ctx.fetch); + } + }, + async interrupt_conversation(args: Json, ctx: ToolContext): Promise { + const targetType = args.target_type; + if (targetType !== "bot" && targetType !== "channel") { + throw new ToolInputError("target_type must be bot or channel"); + } + const targetId = idArg(args, "target_id"); + const current = await fleet(ctx.fetch); + const target = (targetType === "bot" ? records(current.bots) : records(current.groups)) + .find((candidate) => candidate.id === targetId); + if (!target) throw new Error(`${targetType === "bot" ? "Bot" : "Channel"} not found: ${targetId}`); + const taskId = args.task_id === undefined ? String(target.threadId) : idArg(args, "task_id"); + if (!taskBelongsTo(target, taskId)) throw new Error(`Task '${taskId}' does not belong to ${targetType} '${targetId}'`); + if (targetType === "bot") { + const busyChannel = botTaskState(target, taskId) === target && records(current.groups).find((channel) => channel.busyBotId === targetId); + if (busyChannel) { + throw new Error(`Bot '${targetId}' is working in channel '${busyChannel.id}'; interrupt that channel instead`); + } + } + const route = targetType === "bot" ? "bots" : "groups"; + await ctx.fetch(`/api/${route}/${encodeURIComponent(targetId)}/interrupt`, { + method: "POST", + body: JSON.stringify({ threadId: taskId }), + }); + return { success: true, targetType, targetId, taskId }; + }, +} satisfies Record; diff --git a/scripts/mcp-server/models.ts b/scripts/mcp-server/models.ts new file mode 100644 index 0000000000..eaf4608036 --- /dev/null +++ b/scripts/mcp-server/models.ts @@ -0,0 +1,46 @@ +// Model tools: listing instances and switching an idle bot or task selection. + +import { idArg, isRecord, type Json, type ToolContext, type ToolHandler } from "./context.ts"; +import { botTaskState, checkedModelSelection, fleet, projectBot, projectTask, records, taskBelongsTo, taskRoute } from "./shared.ts"; + +export const handlers = { + async list_available_models(_args: Json, ctx: ToolContext): Promise { + const res = await ctx.fetch("/api/instances"); + return { + instances: records(res.instances).map((instance) => ({ + instanceId: instance.instanceId, + driverKind: instance.driverKind, + displayName: instance.displayName, + snapshot: { state: instance.snapshot?.state }, + models: instance.models, + capabilities: instance.capabilities, + access: instance.access, + })), + }; + }, + async set_bot_model(args: Json, ctx: ToolContext): Promise { + const botId = idArg(args, "bot_id"); + const current = await fleet(ctx.fetch); + const bot = records(current.bots).find((candidate) => candidate.id === botId); + if (!bot) throw new Error(`Bot not found: ${botId}`); + if (args.task_id !== undefined) { + const taskId = idArg(args, "task_id"); + if (!taskBelongsTo(bot, taskId)) throw new Error(`Task '${taskId}' does not belong to bot '${botId}'`); + if (botTaskState(bot, taskId).busy) throw new Error("Interrupt the task or let it finish before changing its model"); + const selection = await checkedModelSelection(args, ctx.fetch); + const res = await ctx.fetch(`${taskRoute("bot", botId)}/${encodeURIComponent(taskId)}`, { + method: "PATCH", + body: JSON.stringify({ modelSelection: selection, requireAvailableModel: true }), + }); + if (!isRecord(res?.task)) throw new Error("OpenMausBot did not return the updated task"); + return { success: true, botId, task: projectTask(res.task, bot.threadId) }; + } + if (bot.busy) throw new Error("Interrupt the bot or let it finish before changing its model"); + const selection = await checkedModelSelection(args, ctx.fetch); + const res = await ctx.fetch(`/api/bots/${encodeURIComponent(botId)}`, { + method: "PATCH", + body: JSON.stringify({ modelSelection: selection, requireAvailableModel: true }), + }); + return { success: true, bot: projectBot(res.bot) }; + }, +} satisfies Record; diff --git a/scripts/mcp-server/registry.ts b/scripts/mcp-server/registry.ts new file mode 100644 index 0000000000..f163edef16 --- /dev/null +++ b/scripts/mcp-server/registry.ts @@ -0,0 +1,26 @@ +// One handler per tool name, assembled from the domain modules beside this +// file. mcp-server.ts keeps the matching TOOLS advertisement list; the +// ToolName type below keys both records, so a list entry without a handler +// fails to typecheck there, and a handler without a list entry fails the +// exhaustiveness check declared beside the list. +import type { ToolHandler } from "./context.ts"; +import * as bots from "./bots.ts"; +import * as channels from "./channels.ts"; +import * as conversations from "./conversations.ts"; +import * as models from "./models.ts"; +import * as search from "./search.ts"; +import * as system from "./system.ts"; +import * as tasks from "./tasks.ts"; + +export const TOOL_HANDLERS = { + ...system.handlers, + ...bots.handlers, + ...channels.handlers, + ...tasks.handlers, + ...conversations.handlers, + ...models.handlers, + ...search.handlers, +} satisfies Record; + +/** Every tool name the MCP server can dispatch. */ +export type ToolName = keyof typeof TOOL_HANDLERS; diff --git a/scripts/mcp-server/search.ts b/scripts/mcp-server/search.ts new file mode 100644 index 0000000000..11837c2a75 --- /dev/null +++ b/scripts/mcp-server/search.ts @@ -0,0 +1,15 @@ +// Search tools: bounded transcript search across or within tasks. + +import { idArg, parsePositiveLimit, stringArg, type Json, type ToolContext, type ToolHandler } from "./context.ts"; +import { records } from "./shared.ts"; + +export const handlers = { + async search_messages(args: Json, ctx: ToolContext): Promise { + const query = stringArg(args, "query", { max: 500 }); + const limit = parsePositiveLimit(args.limit, 40, 100); + const params = new URLSearchParams({ q: query, limit: String(limit) }); + if (args.task_id !== undefined) params.set("threadId", idArg(args, "task_id")); + const result = await ctx.fetch(`/api/search?${params.toString()}`); + return { hits: records(result.hits) }; + }, +} satisfies Record; diff --git a/scripts/mcp-server/shared.ts b/scripts/mcp-server/shared.ts new file mode 100644 index 0000000000..92aa673ae4 --- /dev/null +++ b/scripts/mcp-server/shared.ts @@ -0,0 +1,154 @@ +// Helpers shared by several domain handlers: the API projections and the +// owner/task checks every tool performs the same way. Bodies are unchanged +// from the old dispatch switch in mcp-server.ts. +import { isRecord, optionalStringArg, stringArg, ToolInputError } from "./context.ts"; + +export function records(value: unknown): Array> { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +export async function fleet(fetcher: (path: string, options?: RequestInit) => Promise) { + return fetcher("/api/bots?messages=0"); +} + +export function projectTask(task: Record, activeThreadId: unknown) { + return { + taskId: task.threadId, + title: task.title, + createdAt: task.createdAt, + ...(typeof task.busy === "boolean" ? { busy: task.busy } : {}), + ...(task.activity ? { activity: task.activity } : {}), + ...(task.modelSelection ? { modelSelection: task.modelSelection } : {}), + ...(typeof activeThreadId === "string" ? { active: task.threadId === activeThreadId } : {}), + ...(task.usage ? { usage: task.usage } : {}), + }; +} + +export function botTaskState(bot: Record, taskId: string) { + const task = records(bot.tasks).find((candidate) => candidate.threadId === taskId); + if (task && (typeof task.busy === "boolean" || typeof task.activity === "string")) return task; + // Older servers cannot run non-selected tasks and expose only bot activity. + return bot.threadId === taskId ? bot : { busy: false, activity: "idle" }; +} + +export function projectBot(bot: Record) { + return { + id: bot.id, + name: bot.name, + title: bot.title, + description: bot.description, + section: bot.section ?? null, + chiefOfStaff: Boolean(bot.chiefOfStaff), + modelSelection: bot.modelSelection, + busy: Boolean(bot.busy), + activity: bot.activity, + unread: Boolean(bot.unread), + activeTaskId: bot.threadId, + tasks: records(bot.tasks).map((task) => projectTask(task, bot.threadId)), + }; +} + +export function projectChannel(channel: Record) { + return { + id: channel.id, + name: channel.name, + memberIds: channel.memberIds, + bulletin: channel.bulletin, + defaultResponder: channel.defaultResponder, + section: channel.section ?? null, + directMessage: Boolean(channel.dm), + working: Boolean(channel.working), + busyBotId: channel.busyBotId ?? null, + activeTaskId: channel.threadId, + tasks: records(channel.tasks).map((task) => projectTask(task, channel.threadId)), + }; +} + +export function projectMessage(message: Record) { + const card = isRecord(message.card) + ? { + title: message.card.title, + subtitle: message.card.subtitle, + options: message.card.options, + answered: message.card.answered, + dismissed: message.card.dismissed, + } + : undefined; + const tool = isRecord(message.tool) + ? { name: message.tool.name, ok: message.tool.ok, spoken: message.tool.spoken, setup: message.tool.setup, + ...(message.tool.terminal === true ? { terminal: true } : {}), + } + : undefined; + const connector = isRecord(message.connector) + ? { + slug: message.connector.slug, + label: message.connector.label, + description: message.connector.description, + status: message.connector.status, + dismissed: message.connector.dismissed, + resumed: message.connector.resumed, + } + : undefined; + const secret = isRecord(message.secret) + ? { + target: message.secret.target, + label: message.secret.label, + description: message.secret.description, + placeholder: message.secret.placeholder, + helpUrl: message.secret.helpUrl, + provided: message.secret.provided, + dismissed: message.secret.dismissed, + resumed: message.secret.resumed, + } + : undefined; + return { + id: message.id, + at: message.at, + role: message.role, + kind: message.kind, + text: message.text, + from: message.from, + replyToId: message.replyToId, + reactions: message.reactions, + steered: message.steered, + queued: message.queued, + ...(tool ? { tool } : {}), + ...(card ? { card } : {}), + ...(connector ? { connector } : {}), + ...(secret ? { secret } : {}), + ...(message.kind === "screen" ? { hasImage: Boolean(message.hasImage || message.png) } : {}), + }; +} + +export function taskBelongsTo(owner: Record, taskId: string): boolean { + return owner.threadId === taskId || records(owner.tasks).some((task) => task.threadId === taskId); +} + +export function taskRoute(targetType: unknown, targetId: string): string { + if (targetType === "bot") return `/api/bots/${encodeURIComponent(targetId)}/tasks`; + if (targetType === "channel") return `/api/groups/${encodeURIComponent(targetId)}/tasks`; + throw new ToolInputError("target_type must be bot or channel"); +} + +export async function checkedModelSelection( + args: Record, + fetcher: (path: string, options?: RequestInit) => Promise, +) { + const instanceId = stringArg(args, "instance_id"); + const model = stringArg(args, "model"); + const effort = optionalStringArg(args, "effort"); + const described = await fetcher("/api/instances"); + const instance = records(described.instances).find((candidate) => candidate.instanceId === instanceId); + if (!instance) throw new ToolInputError(`model instance not found: ${instanceId}`); + if (instance.snapshot?.state !== "available") throw new ToolInputError(`model instance is unavailable: ${instanceId}`); + const models = isRecord(instance.models) ? instance.models : {}; + const offered = records(models.options).map((option) => option.id).filter((id) => typeof id === "string"); + if (models.default !== model && !offered.includes(model)) { + throw new ToolInputError(`model '${model}' is not offered by instance '${instanceId}'`); + } + const efforts = Array.isArray(instance.capabilities?.effortLevels) ? instance.capabilities.effortLevels : []; + if (effort && !efforts.includes(effort)) { + throw new ToolInputError(`effort '${effort}' is not offered by instance '${instanceId}'`); + } + return { instanceId, model, ...(effort ? { effort } : {}) }; +} diff --git a/scripts/mcp-server/system.ts b/scripts/mcp-server/system.ts new file mode 100644 index 0000000000..22e26a53c7 --- /dev/null +++ b/scripts/mcp-server/system.ts @@ -0,0 +1,16 @@ +// System tools: connectivity to the configured or discovered server. + +import type { Json, ToolContext, ToolHandler } from "./context.ts"; + +export const handlers = { + async get_system_health(_args: Json, ctx: ToolContext): Promise { + const res = await ctx.fetch("/api/health"); + if (res?.app !== "openmausbot") throw new Error("The configured endpoint is not an OpenMausBot server"); + return { + status: "connected", + endpoint: ctx.endpoint(), + app: "openmausbot", + packaged: Boolean(res.static), + }; + }, +} satisfies Record; diff --git a/scripts/mcp-server/tasks.ts b/scripts/mcp-server/tasks.ts new file mode 100644 index 0000000000..44c66c052d --- /dev/null +++ b/scripts/mcp-server/tasks.ts @@ -0,0 +1,58 @@ +// Task tools: creating, selecting, and renaming bot/channel tasks. + +import { idArg, isRecord, optionalStringArg, stringArg, type Json, type ToolContext, type ToolHandler } from "./context.ts"; +import { projectBot, projectChannel, projectTask, taskRoute } from "./shared.ts"; + +export const handlers = { + async create_task(args: Json, ctx: ToolContext): Promise { + const targetId = idArg(args, "target_id"); + const title = optionalStringArg(args, "title", { max: 80 }); + const route = taskRoute(args.target_type, targetId); + const result = await ctx.fetch(route, { method: "POST", body: JSON.stringify(title ? { title } : {}) }); + if (!isRecord(result?.task) || typeof result.task.threadId !== "string") { + throw new Error("OpenMausBot did not return the created task"); + } + const activeTaskId = result.bot?.threadId ?? result.group?.threadId ?? result.task?.threadId; + return { + success: true, + targetType: args.target_type, + targetId, + task: projectTask(result.task, activeTaskId), + }; + }, + async switch_task(args: Json, ctx: ToolContext): Promise { + const targetId = idArg(args, "target_id"); + const taskId = idArg(args, "task_id"); + const route = taskRoute(args.target_type, targetId); + const result = await ctx.fetch(`${route}/${encodeURIComponent(taskId)}?messages=0`, { method: "POST", body: "{}" }); + const target = args.target_type === "bot" ? result.bot : result.group; + return { + success: true, + targetType: args.target_type, + targetId, + taskId, + ...(isRecord(target) + ? { target: args.target_type === "bot" ? projectBot(target) : projectChannel(target) } + : {}), + }; + }, + async rename_task(args: Json, ctx: ToolContext): Promise { + const targetId = idArg(args, "target_id"); + const taskId = idArg(args, "task_id"); + const title = stringArg(args, "title", { max: 80 }); + const route = taskRoute(args.target_type, targetId); + const result = await ctx.fetch(`${route}/${encodeURIComponent(taskId)}`, { + method: "PATCH", + body: JSON.stringify({ title }), + }); + if (!isRecord(result?.task)) { + throw new Error("OpenMausBot did not return the renamed task"); + } + return { + success: true, + targetType: args.target_type, + targetId, + task: projectTask(result.task, undefined), + }; + }, +} satisfies Record; diff --git a/scripts/testing/bot-tools-ui.e2e.test.ts b/scripts/testing/bot-tools-ui.e2e.test.ts index 800f3d2389..fb49aee8f3 100644 --- a/scripts/testing/bot-tools-ui.e2e.test.ts +++ b/scripts/testing/bot-tools-ui.e2e.test.ts @@ -119,15 +119,21 @@ describe("bot setup and tools in the real renderer", () => { await expect.poll(snapshot, { timeout: 10_000 }).toContain("No MCP servers added yet."); const usageExpanded = () => evaluate("[...document.querySelectorAll('[role=dialog] button')].find(b => b.textContent.trim() === 'Usage')?.getAttribute('aria-expanded')"); const openHeaderUsage = async () => { - const state = await ui("snapshot", "--interactive"); - const cost = Object.entries(state.refs as Record) - .filter(([, entry]) => entry.role === "button" && entry.name.includes("$0.01")); - expect(cost).toHaveLength(1); - await ui("click", "--ref", `@${cost[0][0]}`); - await expect.poll(usageExpanded).toBe("true"); + // Usage figures stream in after the turn settles, re-rendering the + // chat header; a ref captured one snapshot ago can go stale before the + // click lands. Resolve a fresh ref per attempt and keep going until + // the section has actually opened. + await expect.poll(async () => { + const state = await ui("snapshot", "--interactive"); + const cost = Object.entries(state.refs as Record) + .filter(([, entry]) => entry.role === "button" && entry.name.includes("$0.01")); + if (cost.length !== 1) return "no usage chip"; + await ui("click", "--ref", `@${cost[0][0]}`); + return usageExpanded(); + }, { timeout: 10_000 }).toBe("true"); expect(await snapshot()).toContain("All bots"); // Allow subpixel rounding at the bottom edge of the scroll viewport. - await expect.poll(() => evaluate("(() => { const row = document.querySelector('[data-bot-settings-section=usage]'); const rect = row?.getBoundingClientRect(); return rect ? Math.max(-rect.top, rect.bottom - innerHeight) : 9999; })()")).toBeLessThanOrEqual(1); + await expect.poll(() => evaluate("(() => { const row = document.querySelector('[data-bot-settings-section=usage]'); const rect = row?.getBoundingClientRect(); return rect ? Math.max(-rect.top, rect.bottom - innerHeight) : 9999; })()"), { timeout: 10_000 }).toBeLessThanOrEqual(1); }; await openHeaderUsage(); await click("Usage"); diff --git a/scripts/testing/cloud-preview.tsx b/scripts/testing/cloud-preview.tsx index bc21810036..58c52dc979 100644 --- a/scripts/testing/cloud-preview.tsx +++ b/scripts/testing/cloud-preview.tsx @@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client"; import { ComputerPanel } from "../../src/components/ComputerPanel"; import { BotSettingsDialog } from "../../src/components/BotSettingsDialog"; import { RemoteDesktopPanel } from "../../src/components/remote-desktop-panel"; -import { StoreProvider, useStore, type Bot } from "../../src/state/store"; +import { StoreProvider, useStore, overlayOpen, type Bot } from "../../src/state/store"; import { applySkin, readSkin } from "../../src/lib/skins"; import { CLOUD_COMPUTER_BUSY_ERROR } from "../../shared/computer-contention"; import "../../src/styles.css"; @@ -147,7 +147,7 @@ function Fixture() { if (bot) { dispatch({ type: "screenFrame", botId: bot.id, png: blank, mime: "image/png" }); dispatch({ type: "updateBot", botId: bot.id, patch: { computer: "cloud", cloudBackend: "box" } }); - dispatch({ type: "toggleComputer", open: true }); + dispatch({ type: "openOverlay", kind: "computer", open: true }); } }, [bot?.id, dispatch]); useEffect(() => { @@ -194,11 +194,11 @@ function Fixture() { - {state.settingsOpen && bot && } - {state.computerOpen && fixtureBot ? panel === "computer" + {overlayOpen(state, "settings") && bot && } + {overlayOpen(state, "computer") && fixtureBot ? panel === "computer" ? : - : !state.settingsOpen && } + : !overlayOpen(state, "settings") && } ; } applySkin(readSkin()); diff --git a/scripts/testing/cron-routines-ui.e2e.test.ts b/scripts/testing/cron-routines-ui.e2e.test.ts index c59d4a3633..4883351475 100644 --- a/scripts/testing/cron-routines-ui.e2e.test.ts +++ b/scripts/testing/cron-routines-ui.e2e.test.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { afterAll, expect, it } from "vitest"; import { resolveAgentBrowserBinary } from "../../server/browser-engine.ts"; import { waitForExit } from "../../server/testing/cleanup.ts"; -import type { Routine } from "../../src/lib/routines.ts"; +import type { Routine } from "../../shared/routines.ts"; import { runControlOmb } from "../control-omb.ts"; import { UI_TOOLS_DIR } from "./control-omb-ui.ts"; diff --git a/scripts/testing/engines-preview.tsx b/scripts/testing/engines-preview.tsx index 20f19d7258..3f4dcb5d75 100644 --- a/scripts/testing/engines-preview.tsx +++ b/scripts/testing/engines-preview.tsx @@ -3,20 +3,20 @@ import { createRoot } from "react-dom/client"; import { WelcomeFlow } from "../../src/components/onboarding/WelcomeFlow"; import { SettingsModal } from "../../src/components/SettingsModal"; import { DesktopCapabilitiesProvider } from "../../src/components/DesktopCapabilities"; -import { StoreProvider, useStore } from "../../src/state/store"; +import { StoreProvider, useStore, overlayOpen } from "../../src/state/store"; import { applySkin } from "../../src/lib/skins"; import "../../src/styles.css"; function Preview() { const { state, dispatch, refreshInstances } = useStore(); const [onboarding, setOnboarding] = useState(false); - useEffect(() => { dispatch({ type: "toggleAppSettings", open: true, section: "engines" }); }, [dispatch]); + useEffect(() => { dispatch({ type: "openOverlay", kind: "appSettings", open: true, section: "engines" }); }, [dispatch]); return <>
Isolated preview · sample engines, no real accounts
- {onboarding ? setOnboarding(false)} /> : state.appSettingsOpen && } + {onboarding ? setOnboarding(false)} /> : overlayOpen(state, "appSettings") && }