Rhys Sullivanharden local auth (#1012)19c26575 months agoHistory
632 lines · 22.9 KB · TypeScript
RawDownload
import { existsSync } from "node:fs";import { join } from "node:path";import { fileURLToPath } from "node:url";import {  app,  BrowserWindow,  dialog,  ipcMain,  Menu,  type MenuItemConstructorOptions,  nativeImage,  session,  shell,} from "electron";import windowStateKeeper from "electron-window-state";import log from "electron-log/main.js";import updater from "electron-updater";const { autoUpdater } = updater;type UpdateInfo = { readonly version: string };import {  startSidecar,  stopSidecar,  onUnexpectedSidecarExit,  SidecarPortInUseError,  type SidecarConnection,} from "./sidecar";import {  errorReportingEnabled,  exportDiagnostics,  exportDiagnosticsInteractive,  getCrashReportingConfig,  initErrorReporting,  reportAProblem,} from "./diagnostics";import { sidecarCrashHtml } from "./crash-screen";import { announceBackup, confirmResetState, resetExecutorState } from "./reset-state";import {  getServerProfiles,  getServerSettings,  rotateServerToken,  setServerProfiles,  updateServerSettings,} from "./settings";import {  type DesktopServerConnection,  type DesktopServerSettings,} from "../shared/server-settings"; // Pin userData to a friendly app-name-scoped dir BEFORE app.ready so every// Electron-side consumer (electron-store, electron-log, window-state) lands// at a predictable spot. User-mutable executor state (data.db and the optional// executor.jsonc plugin manifest) is pinned separately to ~/.executor in// main/sidecar.ts — that path matches the CLI's default.app.setName("Executor");app.setPath("userData", join(app.getPath("appData"), "Executor")); log.initialize({ preload: true });log.transports.file.level = "info"; // Crash reporting must attach before app.whenReady() so Crashpad covers// every child process (renderer, GPU). Sentry-backed only when a DSN was// baked in at build time; otherwise dumps stay local for the diagnostics// export.initErrorReporting(); let mainWindow: BrowserWindow | null = null;let connection: SidecarConnection | null = null;let authHeaderUnsubscribe: (() => void) | null = null; const PRELOAD_PATH = fileURLToPath(new URL("../preload/index.js", import.meta.url)); const liveMainWindow = (): BrowserWindow | null => {  const window = mainWindow;  if (!window) return null;  if (window.isDestroyed()) {    mainWindow = null;    return null;  }  return window;}; const focusMainWindow = () => {  const window = liveMainWindow();  if (!window) {    if (connection) void createWindow(connection);    return;  }  if (window.isMinimized()) window.restore();  if (!window.isVisible()) window.show();  window.focus();}; const ensureSingleInstance = () => {  if (!app.requestSingleInstanceLock()) {    app.quit();    return false;  }  app.on("second-instance", focusMainWindow);  return true;}; const installBearerAuthHeader = (origin: string, token: string | null) => {  authHeaderUnsubscribe?.();  authHeaderUnsubscribe = null;  if (!token) return;  const headerValue = `Bearer ${token}`;  session.defaultSession.webRequest.onBeforeSendHeaders(    { urls: [`${origin}/*`] },    (details, callback) => {      // Scope the bearer to the app's OWN renderer. OAuth popups run in this same      // session but load third-party provider pages; auto-attaching the bearer to      // any request they make to the sidecar would make it an ambient credential      // (a CSRF vector) for untrusted content — the very thing the bearer model      // exists to avoid. The popup only ever needs the bearer-exempt      // /oauth/callback and hands its result back via same-origin browser      // channels (localStorage/postMessage), so withholding the bearer from any      // non-app webContents is safe. Requests with no webContentsId (main      // process / network service) still get it.      const fromOtherWebContents =        details.webContentsId !== undefined &&        (mainWindow === null || details.webContentsId !== mainWindow.webContents.id);      if (fromOtherWebContents) {        callback({ requestHeaders: details.requestHeaders });        return;      }      callback({        requestHeaders: {          ...details.requestHeaders,          Authorization: headerValue,        },      });    },  );  authHeaderUnsubscribe = () => {    session.defaultSession.webRequest.onBeforeSendHeaders({ urls: [`${origin}/*`] }, null);  };}; /** * Resolve the on-disk path to the Executor app icon. Packaged builds get * the .icns/.ico baked in by electron-builder, but in dev mode Electron * launches via the bare runtime binary with no bundled icon — so we set * it programmatically (dock on mac, BrowserWindow on linux/win). * * In dev `app.getAppPath()` returns the directory of the app's * `package.json` (i.e. `apps/desktop`), which is more robust than * relative path math from the compiled main bundle. */const resolveSourceIconPath = (): string =>  app.isPackaged    ? join(process.resourcesPath, "icon.png")    : join(app.getAppPath(), "build", "icon.png"); const resolveLinuxIcon = (): string | undefined => {  if (process.platform !== "linux") return undefined;  return resolveSourceIconPath();}; /** * Set the macOS dock icon at runtime. Packaged builds already use the * bundle's .icns; this matters only in dev where the bare electron binary * defaults to the Electron diamond. * * `setIcon` accepts a file path but silently no-ops if the underlying * image fails to decode — we route through `nativeImage.createFromPath` * + an `isEmpty()` check so we can surface the bad-path case in the log. */const installDockIcon = () => {  if (process.platform !== "darwin") return;  if (app.isPackaged) return;  if (!app.dock) return;  const iconPath = resolveSourceIconPath();  if (!existsSync(iconPath)) {    log.warn(`[dock-icon] file missing at ${iconPath}; skipping`);    return;  }  const image = nativeImage.createFromPath(iconPath);  if (image.isEmpty()) {    log.warn(`[dock-icon] failed to decode ${iconPath}; skipping`);    return;  }  app.dock.setIcon(image);  log.info(`[dock-icon] set to ${iconPath} (${image.getSize().width}×${image.getSize().height})`);}; const createWindow = async (conn: SidecarConnection) => {  const windowState = windowStateKeeper({    defaultWidth: 1280,    defaultHeight: 800,  });   installBearerAuthHeader(conn.baseUrl, conn.authToken);   const linuxIcon = resolveLinuxIcon();   const window = new BrowserWindow({    x: windowState.x,    y: windowState.y,    width: windowState.width,    height: windowState.height,    minWidth: 720,    minHeight: 480,    show: false,    backgroundColor: "#0a0a0a",    autoHideMenuBar: true,    ...(linuxIcon ? { icon: linuxIcon } : {}),    webPreferences: {      preload: PRELOAD_PATH,      contextIsolation: true,      nodeIntegration: false,      sandbox: true,    },  });  mainWindow = window;   windowState.manage(window);   window.once("closed", () => {    if (mainWindow === window) mainWindow = null;  });   window.once("ready-to-show", () => {    if (!window.isDestroyed()) window.show();  });   window.webContents.setWindowOpenHandler(({ url, disposition }) => {    // JS-initiated `window.open(url, name, "popup=1,...")` calls (OAuth    // sign-in flow in packages/react/src/api/oauth-popup.ts:73) come in    // with disposition "new-window" — allow them as Electron child    // windows so the renderer's popup tracking (closed polling +    // BroadcastChannel handoff) works. Plain `<a target="_blank">`    // link clicks come in as "foreground-tab" / "background-tab" /    // "other" and route to the user's default browser.    if (disposition === "new-window") {      return {        action: "allow",        overrideBrowserWindowOptions: {          autoHideMenuBar: true,          webPreferences: {            // No preload, no nodeIntegration — popup loads third-party            // OAuth provider pages, then a final navigation back to            // 127.0.0.1:<port>/oauth/callback which the session-level            // bearer header injection (installBearerAuthHeader)            // catches automatically. The popup never needs the            // executor IPC bridge.            contextIsolation: true,            nodeIntegration: false,            sandbox: true,          },        },      };    }    void shell.openExternal(url);    return { action: "deny" };  });   await window.loadURL(conn.baseUrl);}; const showPortInUseDialog = async (port: number) => {  await dialog.showMessageBox({    type: "error",    title: "Executor port in use",    message: `Port ${port} is already taken.`,    detail:      "Another process is listening on that port. Quit it (or change the desktop server's port in Settings) and relaunch Executor.",    buttons: ["OK"],  });}; // Last non-port-conflict sidecar startup failure, surfaced by boot() in a// user-facing dialog instead of letting the app vanish without a window.let lastSidecarStartError: unknown = null; const startWithCurrentSettings = async (): Promise<SidecarConnection | null> => {  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: bind failures surface as a user-facing dialog  try {    return await startSidecar();  } catch (error) {    // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: SidecarPortInUseError is a plain Node Error subclass, not an Effect tagged error    if (error instanceof SidecarPortInUseError) {      await showPortInUseDialog(error.port);      return null;    }    lastSidecarStartError = error;    log.error("Failed to start executor sidecar", error);    return null;  }}; const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => {  if (connection) {    await stopSidecar(connection.child);    connection = null;  }  const next = await startWithCurrentSettings();  if (!next) {    // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: surfaces to renderer as a rejected IPC call    throw new Error("Sidecar failed to restart — see Settings");  }  connection = next;  installBearerAuthHeader(next.baseUrl, next.authToken);  const window = liveMainWindow();  if (window) await window.loadURL(next.baseUrl);  return toDesktopServerConnection(next);}; // The renderer's connection carries NO auth: the main process injects the// bearer header at the session layer (installBearerAuthHeader), so the token// never crosses the IPC boundary.const toDesktopServerConnection = (conn: SidecarConnection): DesktopServerConnection => ({  kind: "desktop-sidecar",  key: "desktop-sidecar",  origin: conn.baseUrl,  apiBaseUrl: `${conn.baseUrl.replace(/\/+$/, "")}/api`,  displayName: "Desktop sidecar",}); const registerIpcHandlers = () => {  ipcMain.handle("executor:server:connection", (): DesktopServerConnection | null =>    connection ? toDesktopServerConnection(connection) : null,  );  // The bearer token, exposed only for the "Connect an agent" install command  // (an external agent needs it in plaintext). The renderer's own requests  // never use it — the header is injected at the session layer.  ipcMain.handle("executor:server:auth-token", (): string | null => connection?.authToken ?? null);  ipcMain.handle("executor:settings:get", (): DesktopServerSettings => getServerSettings());  ipcMain.handle(    "executor:settings:update",    (_evt, patch: Partial<DesktopServerSettings>): DesktopServerSettings =>      updateServerSettings(patch),  );  // Rotate the bearer token (auth.json) and restart the sidecar so it loads the  // new token; the webview header is re-injected by restartSidecarAndReload.  ipcMain.handle("executor:server:rotate-token", (): Promise<DesktopServerConnection> => {    rotateServerToken();    return restartSidecarAndReload();  });  ipcMain.handle("executor:server-profiles:get", (): string | null => getServerProfiles());  ipcMain.handle("executor:server-profiles:set", (_evt, value: unknown): void => {    if (typeof value !== "string") return;    setServerProfiles(value);  });  ipcMain.handle("executor:server:restart", () => restartSidecarAndReload());  ipcMain.handle("executor:diagnostics:export", () => exportDiagnostics());  ipcMain.handle("executor:crash-reporting:get", () => getCrashReportingConfig());  // Crash-screen escape hatch: a recurring sidecar crash may already be  // fixed upstream. Reuses the menu flow — staged updates prompt to install,  // "no updates" / failures surface in their own dialogs.  ipcMain.handle("executor:updates:check", () => runUpdateCheck({ alertOnFail: true }));  // Crash-screen last resort for damaged state: confirm, move the data dir  // aside (never delete), then restart the sidecar against the fresh dir.  // Returns false when the user cancelled.  ipcMain.handle("executor:state:reset", async (): Promise<boolean> => {    if (!(await confirmResetState())) return false;    if (connection) {      await stopSidecar(connection.child);      connection = null;    }    const { backupDir } = resetExecutorState();    await restartSidecarAndReload();    await announceBackup(backupDir);    return true;  });  ipcMain.handle("executor:shell:open-external", async (_evt, rawUrl: unknown) => {    if (typeof rawUrl !== "string") return;    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: untrusted renderer string, URL ctor throws on malformed input    try {      const parsed = new URL(rawUrl);      if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;      await shell.openExternal(parsed.toString());    } catch {      // Reject malformed URLs silently — renderer falls back to popup flow.    }  });}; // ──────────────────────────────────────────────────────────────────────// Auto-updates — opencode-style dialog flow.//// electron-updater's `checkForUpdatesAndNotify()` default swaps the app// silently on quit, so users have no signal an update is ready until// they relaunch and notice the version changed. We replace it with:////   - autoDownload: true   — pull the next version in the background//   - autoInstallOnAppQuit: false — never swap silently//   - on 'update-downloaded' → native dialog with Restart now / Later//   - "Check for Updates…" app menu item runs the same flow manually//// Auto-checks at boot stay quiet on failure / no-op. The manual menu// invocation surfaces both outcomes explicitly via `alertOnFail`.// ────────────────────────────────────────────────────────────────────── let downloadedUpdateVersion: string | null = null;let updateDialogOpen = false; const promptInstallUpdate = async (version: string) => {  if (updateDialogOpen) return;  updateDialogOpen = true;  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: dialog wrapper guarantees the open flag clears  try {    const response = await dialog.showMessageBox({      type: "info",      title: "Update ready",      message: `Executor ${version} is ready to install.`,      detail: "Restart now to apply the update, or keep working — we'll prompt again later.",      buttons: ["Restart now", "Later"],      defaultId: 0,      cancelId: 1,    });    if (response.response === 0) {      // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle.      if (connection) {        await stopSidecar(connection.child);        connection = null;      }      autoUpdater.quitAndInstall(false, true);    }  } finally {    updateDialogOpen = false;  }}; // Re-check periodically so a long-running session picks up releases// without requiring a quit + relaunch. The boot-time check still runs;// this interval is purely a self-heal for idle apps.const UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours const setupAutoUpdater = () => {  if (!app.isPackaged) return;  autoUpdater.logger = log;  autoUpdater.autoDownload = true;  autoUpdater.autoInstallOnAppQuit = false;   autoUpdater.on("update-downloaded", (info: UpdateInfo) => {    downloadedUpdateVersion = info.version;    void promptInstallUpdate(info.version);  });  autoUpdater.on("error", (err: Error) => {    log.warn("[updater] error", err);  });   setInterval(() => {    if (downloadedUpdateVersion) return; // already staged; waiting on the user    void runUpdateCheck({ alertOnFail: false });  }, UPDATE_POLL_INTERVAL_MS);}; interface UpdateCheckOptions {  readonly alertOnFail: boolean;} const runUpdateCheck = async ({ alertOnFail }: UpdateCheckOptions) => {  if (!app.isPackaged) {    if (alertOnFail) {      await dialog.showMessageBox({        type: "info",        title: "Updates unavailable",        message: "Auto-update is only enabled in packaged builds.",      });    }    return;  }  if (downloadedUpdateVersion) {    await promptInstallUpdate(downloadedUpdateVersion);    return;  }  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: surface network/update failures only when the user asked  try {    const result = await autoUpdater.checkForUpdates();    const newer = result?.isUpdateAvailable === true;    if (!alertOnFail) return;    if (newer) return; // 'update-downloaded' handler will fire the install dialog    await dialog.showMessageBox({      type: "info",      title: "No updates",      message: `You're on the latest version (${app.getVersion()}).`,    });  } catch (error) {    log.warn("[updater] check failed", error);    if (!alertOnFail) return;    await dialog.showMessageBox({      type: "error",      title: "Update check failed",      message: "Couldn't reach the update server.",      detail: "Check your network and try again from the menu.",    });  }}; // A sidecar that can't boot usually means a broken build or an incompatible// data dir — both states the user can't see from a dock icon that bounces// once and disappears. Surface the real error, and stage any available update// so a dead-on-arrival release can heal itself: without the explicit check// here, the boot-time update check never runs (it sits after a successful// sidecar start), so a broken app could never self-update its way out.const handleFatalSidecarFailure = async (error: unknown) => {  if (app.isPackaged) {    // Install whatever finishes downloading by the time the user quits the    // failure dialog; if it downloads while the dialog is open, the regular

Lines 1–500 of 632.