diff --git a/config/runtime-electron-baseline.txt b/config/runtime-electron-baseline.txt index 7d1341c7e5b..d3af9f74c23 100644 --- a/config/runtime-electron-baseline.txt +++ b/config/runtime-electron-baseline.txt @@ -3,39 +3,5 @@ # This list may only SHRINK. Adding an entry means the runtime got less # portable; migrate the module behind a host port instead (src/main/host/). -src/main/agent-hooks/wsl-hook-relay-launch.ts -src/main/ai-vault/session-scanner-service-entry-path.ts -src/main/browser/browser-cookie-clear-store.ts -src/main/browser/browser-cookie-import.ts -src/main/browser/browser-download-destination.ts -src/main/browser/browser-guest-context-menu.ts -src/main/browser/browser-guest-renderer-target.ts -src/main/browser/browser-manager.ts -src/main/browser/browser-media-access.ts -src/main/browser/browser-session-cookie-staging.ts -src/main/browser/browser-session-partition-policies.ts -src/main/browser/browser-session-registry.ts -src/main/browser/browser-webauthn-account-picker.ts -src/main/browser/cdp-bridge.ts -src/main/browser/popup-origin-bar-window.ts -src/main/computer/sidecar-client.ts -src/main/ipc/browser-tab-registration-wait.ts src/main/ipc/filesystem-watcher.ts -src/main/ipc/parcel-watcher-entry-path.ts -src/main/ipc/plugin-marketplaces.ts -src/main/ipc/plugins.ts -src/main/ipc/preflight.ts -src/main/ipc/pty.ts -src/main/ipc/ssh-browse.ts -src/main/ipc/ssh-passphrase.ts -src/main/ipc/ssh.ts -src/main/jira/authenticated-request.ts -src/main/network/proxy-settings.ts -src/main/persistence/loading-store/user-data-path.ts -src/main/ports/port-scan-command-client.ts -src/main/runtime/orca-runtime-browser.ts -src/main/runtime/orca-runtime.ts src/main/speech/model-manager.ts -src/main/speech/stt-service.ts -src/main/ssh/ssh-relay-deploy.ts -src/main/ssh/ssh-remote-cli-host-passthrough.ts diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs new file mode 100644 index 00000000000..c679e2351ba --- /dev/null +++ b/config/scripts/build-orcad.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * Bundle `orcad` — the Orca runtime served from plain Node, no Electron. + * + * Variant B (see docs/design/node-only-runtime-backend.html): the browser-pane and + * speech clusters are excluded. That is not a size optimisation — those modules are + * the only ones that statically import `node:sqlite`, so dropping them is what keeps + * the host Node floor at 18 instead of 22.5+. + */ +import { build } from 'esbuild' +import { mkdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import process from 'node:process' + +const ROOT = join(import.meta.dirname, '..', '..') +const OUT_DIR = join(ROOT, 'out', 'orcad') +const ENTRY = join(ROOT, 'src', 'main', 'orcad', 'orcad-entry.ts') + +// Native addons must exist on the host; they cannot be bundled. +// `electron` is external so a residual import fails loudly at require() time rather +// than silently bundling the npm package's installer shim, which is what happened the +// first time and made the bundle look clean while it was not. +const EXTERNAL = [ + 'electron', + 'node-pty', + '@parcel/watcher', + 'better-sqlite3', + 'keytar', + 'fsevents', + 'cpu-features' +] + +/** Why: the UMD build's relative dynamic requires do not bundle. Same fix build-relay.mjs uses. */ +const jsoncParserEsm = { + name: 'jsonc-parser-esm', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^jsonc-parser$/ }, () => ({ + path: join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js') + })) + } +} + +/** Why: optional native deps reference prebuilt .node files that may not exist here. */ +const externalNativeAddons = { + name: 'external-native-addons', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true })) + } +} + +rmSync(OUT_DIR, { recursive: true, force: true }) +mkdirSync(OUT_DIR, { recursive: true }) + +const result = await build({ + entryPoints: [ENTRY], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: join(OUT_DIR, 'orcad.js'), + external: EXTERNAL, + plugins: [jsoncParserEsm, externalNativeAddons], + metafile: true, + minify: true, + sourcemap: false, + define: { 'process.env.NODE_ENV': '"production"' }, + logLevel: 'error' +}) + +const output = Object.values(result.metafile.outputs).find((o) => o.entryPoint) +// Why check `original` and not just `path`: when electron is bundleable, esbuild +// rewrites `path` to the resolved file under node_modules and the naive check passes +// while the package is very much in the bundle. +const electronImporters = new Set() +for (const [file, info] of Object.entries(result.metafile.inputs)) { + for (const imported of info.imports ?? []) { + const specifier = imported.original ?? imported.path + if (specifier === 'electron' || specifier.startsWith('electron/')) { + electronImporters.add(file) + } + } +} + +if (electronImporters.size > 0) { + console.error( + `[build-orcad] ${electronImporters.size} module(s) in the bundle import electron: +${[...electronImporters].map((f) => ` - ${f}`).join('\n')}` + ) + // Why this can exceed the ratchet baseline: the ratchet measures the graph reachable + // from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to + // install the PTY controller. Once orcad ships, it should become a ratchet entry + // point so the two numbers cannot drift. + process.exitCode = 1 +} else { + console.log( + `[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron imports.` + ) +} diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs new file mode 100644 index 00000000000..a9e0dca7ced --- /dev/null +++ b/config/scripts/runtime-serve-terminal-smoke.mjs @@ -0,0 +1,216 @@ +/** + * Boots the BUILT headless runtime server (`out/main/index.js --serve`), pairs a real + * client to it over the advertised endpoint, creates a terminal, runs a command in it, + * and asserts the output comes back — then shuts down. + * + * Why this exists: "the server started" proves almost nothing. The runtime dispatches + * terminal creation into OrcaRuntimeService, and without an installed headless PTY + * controller that path falls through to a renderer reply that never arrives and times + * out after ten seconds. A boot probe, a port bind, and a `host.platform` call all pass + * against a server whose terminals are dead. Only a PTY round trip catches it. + * + * This is also the acceptance gate for a future Node-only backend + * (docs/design/node-only-runtime-backend.html): the same script should pass against + * `orcad` unchanged, because it drives nothing but the public pairing + RPC surface. + * + * Hard assertions (fail the job): + * - the server emits its ready payload with a pairing offer, + * - a paired client can list worktrees and create a terminal, + * - a command run in that terminal produces its output, + * - the server exits when asked. + */ +import { spawn, spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { randomBytes } from 'node:crypto' +import process from 'node:process' + +const projectDir = resolve(import.meta.dirname, '../..') +const serveEntry = join(projectDir, 'out', 'main', 'index.js') +const READY_TIMEOUT_MS = 120_000 +const OUTPUT_TIMEOUT_MS = 30_000 +const SHUTDOWN_TIMEOUT_MS = 15_000 +// Why a random high port: a fixed one collides with a developer's own `orca serve`. +const PORT = 6800 + Math.floor(Number(process.env.ORCA_SMOKE_PORT_OFFSET ?? '0')) + +function log(message) { + process.stdout.write(`[serve-terminal-smoke] ${message}\n`) +} + +function fail(message) { + process.stderr.write(`[serve-terminal-smoke] FAIL: ${message}\n`) + process.exitCode = 1 +} + +/** The `orca` CLI, driven with an explicit pairing code so it targets this server only. */ +function orca(pairingCode, args) { + const result = spawnSync('orca', [...args, '--pairing-code', pairingCode, '--json'], { + encoding: 'utf8', + // Why not shell:true — argument encoding is handled by spawnSync; a shell would + // re-split the pairing code, which is base64url and can contain '='. + shell: false + }) + if (result.error) { + throw new Error(`orca ${args[0]} failed to spawn: ${result.error.message}`) + } + const line = (result.stdout ?? '').trim() + if (!line.startsWith('{')) { + throw new Error(`orca ${args.join(' ')} produced no JSON:\n${result.stdout}\n${result.stderr}`) + } + const parsed = JSON.parse(line) + if (parsed.ok === false) { + throw new Error( + `orca ${args.join(' ')} returned ${parsed.error?.code}: ${parsed.error?.message}` + ) + } + return parsed.result +} + +function waitForReady(child) { + return new Promise((resolvePromise, rejectPromise) => { + let buffered = '' + const timer = setTimeout( + () => rejectPromise(new Error(`no ready payload within ${READY_TIMEOUT_MS}ms`)), + READY_TIMEOUT_MS + ) + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + buffered += chunk + for (const line of buffered.split('\n')) { + if (!line.startsWith('{')) { + continue + } + try { + const payload = JSON.parse(line) + if (payload.type === 'orca_server_ready') { + clearTimeout(timer) + resolvePromise(payload) + return + } + } catch { + // Partial line; wait for the rest. + } + } + }) + child.on('exit', (code) => { + clearTimeout(timer) + rejectPromise(new Error(`server exited with ${code} before signalling ready`)) + }) + }) +} + +function pairingCodeFrom(payload) { + const url = payload?.pairing?.url + if (!url) { + throw new Error('ready payload carried no pairing offer') + } + const code = new URL(url).searchParams.get('code') + if (!code) { + throw new Error(`pairing url had no code: ${url}`) + } + return code +} + +async function waitForNonce(pairingCode, terminalHandle, nonce) { + const deadline = Date.now() + OUTPUT_TIMEOUT_MS + while (Date.now() < deadline) { + const read = orca(pairingCode, ['terminal', 'read', '--terminal', terminalHandle]) + const tail = (read?.terminal?.tail ?? []).map((entry) => String(entry)).join('\n') + if (tail.includes(nonce)) { + return true + } + await new Promise((r) => setTimeout(r, 1_000)) + } + return false +} + +async function main() { + const userDataDir = mkdtempSync(join(tmpdir(), 'orca-serve-smoke-')) + log(`booting ${serveEntry} on port ${PORT} with userData ${userDataDir}`) + + const child = spawn( + process.env.ORCA_SMOKE_ELECTRON ?? 'npx', + process.env.ORCA_SMOKE_ELECTRON + ? [ + serveEntry, + '--serve', + '--serve-port', + String(PORT), + '--serve-json', + `--user-data-dir=${userDataDir}` + ] + : [ + 'electron', + serveEntry, + '--serve', + '--serve-port', + String(PORT), + '--serve-json', + `--user-data-dir=${userDataDir}` + ], + { stdio: ['ignore', 'pipe', 'pipe'] } + ) + + try { + const ready = await waitForReady(child) + log(`ready: ${ready.advertisedEndpoint}`) + const pairingCode = pairingCodeFrom(ready) + + const worktrees = orca(pairingCode, ['worktree', 'list'])?.worktrees ?? [] + if (worktrees.length === 0) { + throw new Error('paired client saw no worktrees; cannot create a terminal') + } + log(`paired client sees ${worktrees.length} worktree(s)`) + + const terminal = orca(pairingCode, [ + 'terminal', + 'create', + '--worktree', + worktrees[0].id + ])?.terminal + if (!terminal?.handle) { + throw new Error('terminal.create returned no handle') + } + log(`created ${terminal.handle}`) + + // Why invoke node rather than `echo`: the shell differs per platform, node does not. + const nonce = `ORCA_SMOKE_${randomBytes(8).toString('hex')}` + orca(pairingCode, [ + 'terminal', + 'send', + '--terminal', + terminal.handle, + '--text', + `"${process.execPath}" -e "console.log('${nonce}')"`, + '--enter' + ]) + + if (!(await waitForNonce(pairingCode, terminal.handle, nonce))) { + throw new Error( + `terminal produced no output containing ${nonce} within ${OUTPUT_TIMEOUT_MS}ms — ` + + `the server started and answered RPC, but its PTY path is dead` + ) + } + log('terminal round trip OK') + } catch (error) { + fail(error instanceof Error ? error.message : String(error)) + } finally { + child.kill('SIGTERM') + const exited = await Promise.race([ + new Promise((r) => child.on('exit', () => r(true))), + new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS)) + ]) + if (!exited) { + child.kill('SIGKILL') + fail(`server did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`) + } + rmSync(userDataDir, { recursive: true, force: true }) + } + + if (!process.exitCode) { + log('PASS') + } +} + +await main() diff --git a/package.json b/package.json index e89a56481f4..5c20829e8b0 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", + "smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write", "verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check", diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index ba91d7432e8..770258b972a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -6,7 +6,7 @@ import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' import { @@ -43,7 +43,7 @@ export function resolveWslHookRelayBundle(): WslHookRelayBundle | null { candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', 'wsl')) } try { - const appPath = app.getAppPath() + const appPath = getAppEnvironment().getAppPath() candidates.push(join(appPath, 'resources', 'relay', 'wsl')) candidates.push(join(appPath, 'out', 'relay', 'wsl')) } catch { diff --git a/src/main/ai-vault/session-scanner-service-entry-path.ts b/src/main/ai-vault/session-scanner-service-entry-path.ts index 5f334aa7c44..a64676d66b3 100644 --- a/src/main/ai-vault/session-scanner-service-entry-path.ts +++ b/src/main/ai-vault/session-scanner-service-entry-path.ts @@ -1,14 +1,14 @@ import { existsSync } from 'node:fs' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' -type ElectronAppPath = { getAppPath(): string; isPackaged: boolean } +type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean } +// Why the port and not require('electron'): this module is reachable from plain-Node +// fork entries, where the literal text require("electron") fails the build guard even +// inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer. function loadElectronApp(): ElectronAppPath | null { - try { - return require('electron').app ?? null - } catch { - return null - } + return hasAppEnvironment() ? getAppEnvironment() : null } export function resolveAiVaultServiceEntryPath( @@ -47,6 +47,6 @@ export function resolveAiVaultServiceEntryPathWithoutApp( export function getAiVaultServiceEntryPath(): string { const app = loadElectronApp() return app - ? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged) + ? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged()) : resolveAiVaultServiceEntryPathWithoutApp(process.cwd(), process.resourcesPath) } diff --git a/src/main/automations/external-manager.test.ts b/src/main/automations/external-manager.test.ts index dc96b9b964a..994d5cab560 100644 --- a/src/main/automations/external-manager.test.ts +++ b/src/main/automations/external-manager.test.ts @@ -7,7 +7,7 @@ import { updateExternalAutomation } from './external-manager' import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers' -import { getActiveMultiplexer } from '../ipc/ssh' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' import type { Store } from '../persistence' import type * as Fs from 'node:fs' @@ -41,7 +41,7 @@ vi.mock('fs', async () => { } }) -vi.mock('../ipc/ssh', () => ({ +vi.mock('../ssh/ssh-target-registry', () => ({ getActiveMultiplexer: vi.fn() })) diff --git a/src/main/automations/external-manager.ts b/src/main/automations/external-manager.ts index 8c4d0f0d53e..eb2f340322b 100644 --- a/src/main/automations/external-manager.ts +++ b/src/main/automations/external-manager.ts @@ -17,7 +17,7 @@ import type { } from '../../shared/automations-types' import type { SshTarget } from '../../shared/ssh-types' import type { Store } from '../persistence' -import { getActiveMultiplexer } from '../ipc/ssh' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store' import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers' import { diff --git a/src/main/browser/browser-error.ts b/src/main/browser/browser-error.ts new file mode 100644 index 00000000000..21f7e7300a6 --- /dev/null +++ b/src/main/browser/browser-error.ts @@ -0,0 +1,16 @@ +/** + * The error every browser command rejects with. + * + * Why its own module: this is seven lines with no dependencies, but it lived in + * `cdp-bridge.ts`, which imports `webContents` and drags the whole Chromium cluster + * along. The runtime catches this type on paths that have nothing to do with CDP, so + * that one import kept a Node host from loading the runtime at all. + */ +export class BrowserError extends Error { + constructor( + readonly code: string, + message: string + ) { + super(message) + } +} diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index b3b836d07b0..5a2cd3a8d23 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -1,4 +1,6 @@ import { mkdtempSync } from 'node:fs' +import { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser' +import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createConnection } from 'node:net' @@ -280,6 +282,10 @@ describe('Browser automation pipeline (integration)', () => { const RENDERER_WC_ID = 1 beforeEach(async () => { + // Why: constructing the browser commands is what pulls the Chromium cluster in, so + // production installs this at the Electron entry. Suites that exercise browser + // automation install it too; a Node host installs none and the RPCs reject. + setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host)) activeGuestHarness = createMockGuest(GUEST_WC_ID, 'https://example.com', 'Example Domain') const { guest } = activeGuestHarness activeGuest = guest diff --git a/src/main/browser/cdp-bridge.ts b/src/main/browser/cdp-bridge.ts index 47d263ac371..fc5991924a9 100644 --- a/src/main/browser/cdp-bridge.ts +++ b/src/main/browser/cdp-bridge.ts @@ -48,17 +48,13 @@ import { import { insertTextThroughCdp } from './browser-text-insertion' import type { BrowserManager } from './browser-manager' import { ANTI_DETECTION_SCRIPT } from './anti-detection' +import { BrowserError } from './browser-error' const CAPTURE_LOG_LIMIT = 1000 -export class BrowserError extends Error { - constructor( - readonly code: string, - message: string - ) { - super(message) - } -} +// Why re-exported: moved to ./browser-error so the runtime can catch it without +// pulling Chromium in. Existing importers of this path keep working. +export { BrowserError } from './browser-error' type TabState = { navigationId: string | null diff --git a/src/main/computer/sidecar-client.ts b/src/main/computer/sidecar-client.ts index d0cfa9dfb2c..489c463dd93 100644 --- a/src/main/computer/sidecar-client.ts +++ b/src/main/computer/sidecar-client.ts @@ -1,4 +1,5 @@ import { fork, type ChildProcess } from 'node:child_process' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import type { ComputerActionResult, @@ -107,12 +108,15 @@ function getComputerSidecarEntryPath(): string { return join(basePath, 'out', 'main', 'computer-sidecar.js') } +// Why the port and not require('electron'): the literal text fails the plain-Node +// entry guard even inside a try/catch, and hasAppEnvironment() gives the same +// "no app root here" answer without it. function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null { - try { - return require('electron').app - } catch { + if (!hasAppEnvironment()) { return null } + const environment = getAppEnvironment() + return { getAppPath: () => environment.getAppPath(), isPackaged: environment.isPackaged() } } class ComputerSidecarProcess { diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 094c1b3a50e..cd6d3c67ca9 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -27,6 +27,11 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/runtime/relay/relay-region-preference.ts', 3], ['main/source-control/hosted-review-api-request.ts', 1], ['main/speech/openai-transcription-client.ts', 1], + // Main HTTP port: one type declaration plus the Node fallback call. The fallback + // returns the Response to its caller without inspecting it, so the consume/cancel + // obligation stays with the caller — unchanged from when those callers used + // Electron's net directly. + ['main/network/http-client.ts', 2], // fetch appears only inside injected-page script source strings, not as a // call this process makes ['main/amp/agent-status-plugin-source.ts', 1], diff --git a/src/main/host/electron-browser-commands.ts b/src/main/host/electron-browser-commands.ts new file mode 100644 index 00000000000..24d53f00eb3 --- /dev/null +++ b/src/main/host/electron-browser-commands.ts @@ -0,0 +1,6 @@ +import { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser' +import type { RuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' + +/** The desktop factory. Importing this file is what pulls in the Chromium browser cluster. */ +export const electronRuntimeBrowserCommandsFactory: RuntimeBrowserCommandsFactory = (host) => + new RuntimeBrowserCommands(host) diff --git a/src/main/host/electron-http-client.ts b/src/main/host/electron-http-client.ts new file mode 100644 index 00000000000..daa538757bc --- /dev/null +++ b/src/main/host/electron-http-client.ts @@ -0,0 +1,14 @@ +import { net, session } from 'electron' +import type { MainHttpClient } from '../network/http-client' + +/** + * The desktop HTTP client: Chromium's network stack, which follows session and proxy + * state and sends a Chrome user agent. + * + * `session.defaultSession` throws before the app is ready, so it is read per call + * rather than captured at install time. + */ +export const electronHttpClient: MainHttpClient = { + fetch: (url, init) => net.fetch(url, init), + proxySession: () => session.defaultSession +} diff --git a/src/main/host/electron-runtime-desktop-surface.ts b/src/main/host/electron-runtime-desktop-surface.ts new file mode 100644 index 00000000000..f709804955c --- /dev/null +++ b/src/main/host/electron-runtime-desktop-surface.ts @@ -0,0 +1,20 @@ +import { BrowserWindow, ipcMain, Notification } from 'electron' +import type { RuntimeDesktopSurface } from '../runtime/runtime-desktop-surface' + +/** The desktop implementation of the runtime's optional desktop facilities. */ +export const electronRuntimeDesktopSurface: RuntimeDesktopSurface = { + showNotification: ({ title, body }) => { + if (!Notification.isSupported()) { + return false + } + new Notification({ title, body }).show() + return true + }, + findWindowById: (id) => BrowserWindow.fromId(id), + onIpc: (channel, listener) => { + ipcMain.on(channel, listener as Parameters[1]) + }, + removeIpcListener: (channel, listener) => { + ipcMain.removeListener(channel, listener as Parameters[1]) + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 23ff503ab4a..fc141e98988 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,7 +2,16 @@ import { existsSync, statSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import os from 'node:os' -import { app, BrowserWindow, dialog, ipcMain, nativeTheme, powerMonitor, type Tray } from 'electron' +import { + app, + BrowserWindow, + dialog, + ipcMain, + nativeTheme, + powerMonitor, + type Tray, + session +} from 'electron' import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice' import { electronApp, is } from '@electron-toolkit/utils' import { @@ -13,6 +22,13 @@ import { } from './persistence' import { setAppEnvironment } from '../shared/app-environment' import { ElectronAppEnvironment } from './host/electron-app-environment' +import { setPtyHostBindings } from './ipc/pty-host-bindings' +import { electronRuntimeDesktopSurface } from './host/electron-runtime-desktop-surface' +import { setRuntimeDesktopSurface } from './runtime/runtime-desktop-surface' +import { electronRuntimeBrowserCommandsFactory } from './host/electron-browser-commands' +import { setRuntimeBrowserCommandsFactory } from './runtime/runtime-browser-commands-factory' +import { electronHttpClient } from './host/electron-http-client' +import { setMainHttpClient } from './network/http-client' import { setSecretStore } from '../shared/secret-store' import { ElectronSecretStore } from './host/electron-secret-store' import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' @@ -354,7 +370,10 @@ import { } from '../shared/runtime-types' import { LocalPtyProvider } from './providers/local-pty-provider' import { KeybindingService } from './keybindings/keybinding-service' -import { applyElectronProxySettings } from './network/proxy-settings' +import { + applyElectronProxySettings, + setDefaultProxySessionResolver +} from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' import { CliInstaller } from './cli/cli-installer' import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher' @@ -861,6 +880,28 @@ if (hasSingleInstanceLock) { // the app.setName ordering the userData captures below depend on. setAppEnvironment(new ElectronAppEnvironment()) setSecretStore(new ElectronSecretStore()) + // Why at process level, not per-window: pty.ts registers against injected surfaces so + // it can load without electron, and an Electron main process always has ipcMain — + // whether a window exists is irrelevant. Installing this in attachMainWindowServices + // meant `orca serve` registered its PTY handlers against no-ops before any window + // attached, so a paired desktop owner never received them. + setPtyHostBindings({ ipc: ipcMain, power: powerMonitor }) + // Why also at process level: the runtime's notification, window-lookup and + // tab-create-reply channel are desktop-only. A Node host installs none and the + // runtime routes notifications to paired clients instead. + setRuntimeDesktopSurface(electronRuntimeDesktopSurface) + // Why here: constructing RuntimeBrowserCommands is what pulls the Chromium browser + // cluster into the graph. The desktop installs it; a Node host installs none and every + // browser RPC rejects, which capability filtering already tells clients about. + setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory) + // Why here: proxy-settings only needed electron for `session.defaultSession`. The + // desktop supplies it; a Node host has no Chromium proxy config to consult, so the + // environment variables are the whole answer there. + setDefaultProxySessionResolver(() => session.defaultSession) + // Why here: integrations use Chromium's network stack on the desktop. A Node host + // falls back to the platform default, which is a real behavioural difference (proxy + // read from the environment, Node's user agent) rather than a transparent swap. + setMainHttpClient(electronHttpClient) // Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime. const shouldCoupleToDevParent = is.dev && !isServeMode installDevParentDisconnectQuit(shouldCoupleToDevParent) diff --git a/src/main/ipc/parcel-watcher-entry-path.ts b/src/main/ipc/parcel-watcher-entry-path.ts index 7eba4c205da..a61d240f2bb 100644 --- a/src/main/ipc/parcel-watcher-entry-path.ts +++ b/src/main/ipc/parcel-watcher-entry-path.ts @@ -1,14 +1,14 @@ import { existsSync } from 'node:fs' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' -type ElectronAppPath = { getAppPath(): string; isPackaged: boolean } +type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean } +// Why the port and not require('electron'): this module is reachable from plain-Node +// fork entries, where the literal text require("electron") fails the build guard even +// inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer. function loadElectronApp(): ElectronAppPath | null { - try { - return require('electron').app ?? null - } catch { - return null - } + return hasAppEnvironment() ? getAppEnvironment() : null } export function resolveWatcherProcessEntryPath( @@ -53,7 +53,7 @@ export function resolveWatcherProcessEntryPathWithoutApp( export function getWatcherProcessEntryPath(): string { const app = loadElectronApp() if (app) { - return resolveWatcherProcessEntryPath(app.getAppPath(), app.isPackaged) + return resolveWatcherProcessEntryPath(app.getAppPath(), app.isPackaged()) } return resolveWatcherProcessEntryPathWithoutApp(process.cwd(), process.resourcesPath) } diff --git a/src/main/ipc/plugins.ts b/src/main/ipc/plugins.ts index 29a78812353..a6bc7b6f767 100644 --- a/src/main/ipc/plugins.ts +++ b/src/main/ipc/plugins.ts @@ -14,7 +14,6 @@ import { removeInstalledPlugin } from '../plugins/plugin-install' import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement' -import { buildPluginList, type PluginListEntry } from '../plugins/plugin-list-projection' import type { PluginService } from '../plugins/plugin-service' import { bindPluginPanelOwnerLifecycle } from '../plugins/plugin-panel-owner-lifecycle' import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' @@ -68,13 +67,10 @@ const removeArgsSchema = z.object({ }) const logsArgsSchema = z.object({ pluginKey: z.string().min(1) }) -export async function listPluginsForClients( - pluginService: PluginService -): Promise { - await pluginService.whenReady() - const lock = await readPluginLockfile(getUserPluginsDir(pluginService.options.userDataPath)) - return buildPluginList(pluginService, lock) -} +// Why re-exported: moved to ../plugins/plugin-client-list so the runtime RPC can reach +// it without ipcMain. Existing importers of this path keep working. +export { listPluginsForClients } from '../plugins/plugin-client-list' +import { listPluginsForClients } from '../plugins/plugin-client-list' export function canRemoveInstalledPlugin( pluginService: PluginService, diff --git a/src/main/ipc/preflight-remote-ssh.test.ts b/src/main/ipc/preflight-remote-ssh.test.ts index b94f9c297a9..14d79e953e8 100644 --- a/src/main/ipc/preflight-remote-ssh.test.ts +++ b/src/main/ipc/preflight-remote-ssh.test.ts @@ -70,6 +70,9 @@ vi.mock('../pty/windows-environment-path', () => ({ vi.mock('./ssh', () => ({ getActiveMultiplexer: getActiveMultiplexerMock })) +vi.mock('../ssh/ssh-target-registry', () => ({ + getActiveMultiplexer: getActiveMultiplexerMock +})) vi.mock('../bitbucket/client', () => ({ getBitbucketAuthStatus: getBitbucketAuthStatusMock diff --git a/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts b/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts index f28886cdaec..6ac63d39c5a 100644 --- a/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts +++ b/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts @@ -1,4 +1,4 @@ -import { getActiveMultiplexer } from './ssh' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' export type RemoteWindowsTerminalCapabilities = { wslAvailable: boolean diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 1f0ab007971..e8a678b40cd 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -1,293 +1,21 @@ -import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment' import { ipcMain } from 'electron' -import type { - PathSource, - ShellHydrationFailureReason -} from '../../shared/shell-path-hydration-types' -import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' -import { getAzureDevOpsAuthStatus } from '../azure-devops/client' -import { getBitbucketAuthStatus } from '../bitbucket/client' -import { getGiteaAuthStatus } from '../gitea/client' -import { _resetKnownHostsCache } from '../gitlab/gl-utils' -import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' -import { getActiveMultiplexer } from './ssh' -import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-wsl-agent-detection' -import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection' -import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target' -import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path' -import { - execCommandInWsl, - execLocalPreflightCommand, - isCommandAvailable, - isCommandOnPath, - shellQuote -} from './preflight-command-exec' import { + detectInstalledAgentsWithShellPathHydration, + detectRemoteAgents, detectRemoteWindowsTerminalCapabilities, - type RemoteWindowsTerminalCapabilities -} from './preflight-remote-windows-terminal-capabilities' -import { - getTuiAgentDetectionProbeCommands, - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - resolveDetectedTuiAgentIds -} from './tui-agent-detection-commands' + refreshShellPathAndDetectAgents, + runPreflightCheck +} from '../preflight/agent-detection' +import type { + PreflightRuntimeContext, + PreflightStatus, + RemoteWindowsTerminalCapabilities +} from '../preflight/agent-detection' -export type PreflightStatus = { - git: { installed: boolean } - gh: { installed: boolean; authenticated: boolean } - // Why: optional so existing renderer call sites that only render git/gh - // status keep typechecking. Consumers that surface GitLab-specific - // affordances (the GitLab tab in the source picker, MR list, etc.) - // gate on `glab?.authenticated`. - glab?: { installed: boolean; authenticated: boolean } - bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } - azureDevOps?: { - configured: boolean - authenticated: boolean - account: string | null - baseUrl: string | null - tokenConfigured: boolean - } - gitea?: { - configured: boolean - authenticated: boolean - account: string | null - baseUrl: string | null - tokenConfigured: boolean - } -} - -export { detectRemoteWindowsTerminalCapabilities } -export type { RemoteWindowsTerminalCapabilities } - -// Why: cache the result so repeated Landing mounts don't re-spawn processes. -// The check only runs once per app session — relaunch to re-check. -let cached: PreflightStatus | null = null - -/** @internal - tests need a clean preflight cache between cases. */ -export function _resetPreflightCache(): void { - cached = null -} - -function uniqueAgentIds(ids: Iterable): string[] { - return [...new Set(ids)] -} - -async function detectCommandRuntime( - command: string, - context?: PreflightRuntimeContext -): Promise<{ installed: boolean; wslTarget?: WslPreflightTarget }> { - const wslTarget = getPreflightWslTarget(context) - if (wslTarget) { - return (await isCommandAvailable(command, wslTarget)) - ? { installed: true, wslTarget } - : { installed: false } - } - if (await isCommandAvailable(command)) { - return { installed: true } - } - return { installed: false } -} - -export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { - const wslTarget = getPreflightWslTarget(context) - if (wslTarget) { - const foundCommands = await detectWslCommandsOnPath( - wslTarget, - getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, 'wsl') - ) - return resolveDetectedTuiAgentIds(KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, 'wsl') - } - - const probeCommands = getTuiAgentDetectionProbeCommands( - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - process.platform - ) - const pathChecks = await Promise.all( - probeCommands.map(async (cmd) => ({ - cmd, - installedOnPath: await isCommandOnPath(cmd) - })) - ) - const missedCommands = pathChecks.filter((check) => !check.installedOnPath).map(({ cmd }) => cmd) - // Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution - // computes user install dirs once instead of blocking once per missed CLI. - const installDirCommands = detectCommandsInInstallDirs(missedCommands) - const foundCommands = new Set( - pathChecks - .filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd)) - .map(({ cmd }) => cmd) - ) - return resolveDetectedTuiAgentIds( - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - foundCommands, - process.platform - ) -} - -export async function detectInstalledAgentsWithShellPathHydration( - context?: PreflightRuntimeContext -): Promise { - await hydrateShellPathForAgentDetection(context) - return detectInstalledAgents(context) -} - -export type RefreshAgentsResult = { - /** Agents detected after hydrating PATH from the user's login shell. */ - agents: string[] - /** PATH segments that were added this refresh (empty if nothing new). */ - addedPathSegments: string[] - /** True when the shell spawn succeeded. False = relied on existing PATH. */ - shellHydrationOk: boolean - /** Whether `detectInstalledAgents` ran against shell-hydrated PATH or only - * the seed list from `patchPackagedProcessPath`. Drives the on_path:false - * triage in tile A on dashboard 1562016. */ - pathSource: PathSource - /** Why hydration failed (or `'none'` on success). Typed against the shared - * alias so the IPC boundary stays in lockstep with the renderer-visible - * enum on `onboardingAgentPickedSchema`. */ - pathFailureReason: ShellHydrationFailureReason -} - -/** - * Re-spawn the user's login shell to refresh process.env.PATH, then re-run - * agent detection. Called by the Agents settings pane when the user clicks - * Refresh — handles the "installed a new CLI, Orca doesn't see it yet" case - * without requiring an app restart. - */ -export async function refreshShellPathAndDetectAgents( - context?: PreflightRuntimeContext -): Promise { - const wslTarget = getPreflightWslTarget(context) - if (wslTarget) { - // Why invalidate first: the guest PATH is cached per distro for the process - // lifetime, so Refresh would otherwise re-read the pre-install PATH and - // keep reporting a just-installed CLI as absent -- the exact case this - // function exists to handle. - invalidateWslGuestEnvironment(wslTarget.distro) - const agents = await detectInstalledAgents(context) - return { - agents, - addedPathSegments: [], - shellHydrationOk: true, - pathSource: 'sync_seed_only', - pathFailureReason: 'none' - } - } - - const hydration = await hydrateShellPath({ force: true }) - const added = hydration.ok ? mergePathSegments(hydration.segments) : [] - const agents = await detectInstalledAgents(context) - return { - agents, - addedPathSegments: added, - shellHydrationOk: hydration.ok, - pathSource: hydration.ok ? 'shell_hydrate' : 'sync_seed_only', - pathFailureReason: hydration.failureReason - } -} - -export async function detectRemoteAgents(args: { connectionId: string }): Promise { - const mux = getActiveMultiplexer(args.connectionId) - if (!mux || mux.isDisposed()) { - // Why: remote agent detection is passive UI polling. A disconnected host has - // no detectable agents until reconnect, but should not spam IPC errors. - return [] - } - const result = (await mux.request('preflight.detectAgents', { - commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS - })) as { agents: string[] } - return uniqueAgentIds(result.agents) -} - -async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise { - try { - await (wslTarget - ? execCommandInWsl(wslTarget, `${shellQuote('gh')} auth status`) - : execLocalPreflightCommand('gh', ['auth', 'status'])) - // Why: for plain-text `gh auth status`, exit 0 means gh did not detect any - // authentication issues for the checked hosts/accounts. - return true - } catch (error) { - // Why: some environments may surface partial command output on the thrown - // error object. Keep a compatibility fallback so we avoid a false auth - // warning if success markers are present despite a non-zero result. - const stdout = (error as { stdout?: string }).stdout ?? '' - const stderr = (error as { stderr?: string }).stderr ?? '' - const output = `${stdout}\n${stderr}` - return output.includes('Logged in') || output.includes('Active account: true') - } -} - -// Why: parallel to isGhAuthenticated for the glab CLI. glab writes auth -// status to stderr in some versions and stdout in others; check both. -async function isGlabAuthenticated(wslTarget?: WslPreflightTarget): Promise { - try { - await (wslTarget - ? execCommandInWsl(wslTarget, `${shellQuote('glab')} auth status`) - : execLocalPreflightCommand('glab', ['auth', 'status'])) - return true - } catch (error) { - const stdout = (error as { stdout?: string }).stdout ?? '' - const stderr = (error as { stderr?: string }).stderr ?? '' - const output = `${stdout}\n${stderr}` - return output.includes('Logged in') - } -} - -export async function runPreflightCheck( - force = false, - context?: PreflightRuntimeContext -): Promise { - const wslTarget = getPreflightWslTarget(context) - const cacheable = !wslTarget - if (cacheable && cached && !force) { - return cached - } - - if (process.platform === 'win32' && !wslTarget) { - await mergePersistedWindowsPathAsync(process.env, { forceRefresh: force }) - } - - if (force) { - // Why: the GitLab known-hosts cache (gl-utils) is populated lazily on the - // first GitLab request and never invalidated within a session. A user who - // runs `glab auth login` for a self-hosted host after Orca starts would - // otherwise see "No GitLab project found" until app relaunch. The Re-check - // path in IntegrationsPane forces preflight, so piggyback on that signal - // to refresh the host list too. - _resetKnownHostsCache() - } - - const [gitProbe, ghProbe, glabProbe] = await Promise.all([ - detectCommandRuntime('git', context), - detectCommandRuntime('gh', context), - detectCommandRuntime('glab', context) - ]) - - const [ghAuthenticated, glabAuthenticated, bitbucket, azureDevOps, gitea] = await Promise.all([ - ghProbe.installed ? isGhAuthenticated(ghProbe.wslTarget) : Promise.resolve(false), - glabProbe.installed ? isGlabAuthenticated(glabProbe.wslTarget) : Promise.resolve(false), - getBitbucketAuthStatus(), - getAzureDevOpsAuthStatus(), - getGiteaAuthStatus() - ]) - - const result = { - git: { installed: gitProbe.installed }, - gh: { installed: ghProbe.installed, authenticated: ghAuthenticated }, - glab: { installed: glabProbe.installed, authenticated: glabAuthenticated }, - bitbucket, - azureDevOps, - gitea - } - - if (cacheable) { - cached = result - } - - return result -} +// Why this file is thin: everything above the handler layer moved to +// ../preflight/agent-detection so the runtime can call it without ipcMain. +// Re-exported here so existing importers of `ipc/preflight` keep working. +export * from '../preflight/agent-detection' export function registerPreflightHandlers(): void { ipcMain.handle( diff --git a/src/main/ipc/pty-host-bindings.ts b/src/main/ipc/pty-host-bindings.ts new file mode 100644 index 00000000000..e1eb0dde552 --- /dev/null +++ b/src/main/ipc/pty-host-bindings.ts @@ -0,0 +1,68 @@ +import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron' + +/** + * The host facilities the PTY handlers register against. + * + * Why injected rather than imported: `registerPtyHandlers` owns the PTY controller + * that `terminal.create` actually spawns through, and a Node-only backend needs that + * controller. Everything else in the module is already host-agnostic — the only thing + * pinning it to Electron was a static `ipcMain` / `powerMonitor` import used purely to + * register renderer handlers that no headless host will ever receive. + * + * The desktop passes the real Electron objects. A headless host passes nothing and + * gets no-ops, which is honest: there is no renderer to answer, so registering is a + * no-op rather than a lie about having registered. + */ + +/** + * Deliberately `any[]` on the rest args, matching Electron's own `IpcMain` signature: + * a narrower type here would not accept the real object, and widening at the call site + * would need a cast that hides genuine mismatches. + */ +export type PtyIpcSurface = { + handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: any[]) => unknown): void + on(channel: string, listener: (event: IpcMainEvent, ...args: any[]) => void): void + removeHandler(channel: string): void + removeAllListeners(channel: string): void +} + +export type PtyPowerSurface = { + on(event: 'suspend' | 'resume', listener: () => void): void +} + +/** Why not optional-chaining at 75 call sites: one object keeps the call sites unchanged. */ +export const noopPtyIpcSurface: PtyIpcSurface = { + handle: () => {}, + on: () => {}, + removeHandler: () => {}, + removeAllListeners: () => {} +} + +export const noopPtyPowerSurface: PtyPowerSurface = { + on: () => {} +} + +let currentIpc: PtyIpcSurface = noopPtyIpcSurface +let currentPower: PtyPowerSurface = noopPtyPowerSurface + +/** + * Install the host surfaces once at startup. Defaults are no-ops rather than a throw, + * unlike AppEnvironment/SecretStore: a host with no renderer legitimately has nothing to + * register against, and silently not registering handlers nobody can call is correct + * rather than a hidden downgrade. + */ +export function setPtyHostBindings(bindings: { + ipc?: PtyIpcSurface + power?: PtyPowerSurface +}): void { + currentIpc = bindings.ipc ?? noopPtyIpcSurface + currentPower = bindings.power ?? noopPtyPowerSurface +} + +export function getPtyIpc(): PtyIpcSurface { + return currentIpc +} + +export function getPtyPower(): PtyPowerSurface { + return currentPower +} diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index f20e0c3885f..5e5a9c819f7 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -1,4 +1,8 @@ import { afterEach, beforeEach, vi } from 'vitest' +import * as electron from 'electron' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { setPtyHostBindings } from './pty-host-bindings' +import { testPtyIpcSurface } from './pty-ipc-test-surface' import type { Mock } from 'vitest' import { handleMock, @@ -89,6 +93,25 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { const envScope = createPtyIpcProcessEnvScope() beforeEach(() => { + // Why here: pty.ts registers against injected surfaces now, so the mocked ipcMain + // must be installed for the shared `handlers` map to keep capturing registrations. + setPtyHostBindings({ ipc: testPtyIpcSurface() }) + // Why here: pty.ts reads app paths and the packaged flag through the AppEnvironment + // port now, so the shared vi.mock('electron') app object alone is inert. Back the + // port with the same mocks so every suite's existing expectations still hold. + // Why read through the electron mock instead of hardcoding: suites toggle + // `app.isPackaged` mid-test to exercise dev-mode spawn paths, so the port must + // observe the same mutable field rather than freeze a value at install time. + const electronAppMock = ( + vi.mocked(electron) as unknown as { + app: { isPackaged: boolean; getPath: (name: string) => string; getVersion: () => string } + } + ).app + installFakeAppEnvironment({ + getPath: (name) => electronAppMock.getPath(name), + isPackaged: () => electronAppMock.isPackaged, + getVersion: () => electronAppMock.getVersion() + }) envScope.applyTestEnvDefaults() handlers.clear() handleMock.mockReset() diff --git a/src/main/ipc/pty-ipc-test-surface.ts b/src/main/ipc/pty-ipc-test-surface.ts new file mode 100644 index 00000000000..12f21e8fb37 --- /dev/null +++ b/src/main/ipc/pty-ipc-test-surface.ts @@ -0,0 +1,21 @@ +import type { PtyIpcSurface } from './pty-host-bindings' +import { + handleMock, + onMock, + removeAllListenersMock, + removeHandlerMock +} from './pty-ipc-mock-registry' + +/** + * The registration surface pty suites drive. Production injects Electron's `ipcMain`; + * suites inject this so the existing `handlers` map keeps capturing registrations + * exactly as it did when the module imported `ipcMain` directly. + */ +export function testPtyIpcSurface(): PtyIpcSurface { + return { + handle: handleMock as unknown as PtyIpcSurface['handle'], + on: onMock as unknown as PtyIpcSurface['on'], + removeHandler: removeHandlerMock, + removeAllListeners: removeAllListenersMock + } +} diff --git a/src/main/ipc/pty-renderer-surface.ts b/src/main/ipc/pty-renderer-surface.ts new file mode 100644 index 00000000000..09fff75e681 --- /dev/null +++ b/src/main/ipc/pty-renderer-surface.ts @@ -0,0 +1,37 @@ +import type { BrowserWindow, WebContents } from 'electron' + +/** + * The renderer surface the PTY handlers talk to, which may not exist. + * + * Why: `orca serve` — and a future Node-only backend — run the same PTY handlers + * with no window. That used to be faked: `registerHeadlessPtyRuntime` built a + * `BrowserWindow` whose `isDestroyed()` returned true and whose `webContents.send` + * was a no-op, purely to satisfy the type — the "looks fine, silently lies" shape + * this codebase rejects elsewhere, and what forced an `electron` value import into a + * path that needs none. It now passes `null`. + * + * An absent renderer is semantically identical to a destroyed one — every call site + * already guards on `isDestroyed()` and skips — so model it as `null` and say so. + */ + +/** True when there is no renderer, or it is gone. Callers already treat these the same. */ +export function isRendererGone(window: BrowserWindow | null): boolean { + return window === null || window.isDestroyed() +} + +/** Send to the renderer if one is listening. Absent renderer drops the message, as a destroyed one does. */ +export function sendToRenderer( + window: BrowserWindow | null, + channel: string, + payload?: unknown +): void { + if (isRendererGone(window)) { + return + } + window!.webContents.send(channel, payload) +} + +/** The renderer's WebContents, or null. Used for identity checks and listener registration. */ +export function rendererWebContents(window: BrowserWindow | null): WebContents | null { + return isRendererGone(window) ? null : window!.webContents +} diff --git a/src/main/ipc/pty-startup-barrier-ordering.test.ts b/src/main/ipc/pty-startup-barrier-ordering.test.ts index 78f7477f468..95dafaaab8a 100644 --- a/src/main/ipc/pty-startup-barrier-ordering.test.ts +++ b/src/main/ipc/pty-startup-barrier-ordering.test.ts @@ -11,8 +11,10 @@ describe('PTY startup barrier ordering', () => { const runtimeSpawnStart = source.indexOf('spawn: async (args) => {') const runtimeSpawnEnd = source.indexOf(' write:', runtimeSpawnStart) const runtimeSpawn = source.slice(runtimeSpawnStart, runtimeSpawnEnd) - const rendererSpawnStart = source.indexOf("ipcMain.handle(\n 'pty:spawn'") - const rendererSpawnEnd = source.indexOf("ipcMain.handle(\n 'pty:kill'", rendererSpawnStart) + // Why `ipc.` and not `ipcMain.`: the module registers against an injected surface now + // (pty-host-bindings) so it can run without electron. The ordering this asserts is unchanged. + const rendererSpawnStart = source.indexOf("ipc.handle(\n 'pty:spawn'") + const rendererSpawnEnd = source.indexOf("ipc.handle(\n 'pty:kill'", rendererSpawnStart) const rendererSpawn = source.slice(rendererSpawnStart, rendererSpawnEnd) for (const spawnBlock of [runtimeSpawn, rendererSpawn]) { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 6b436f186b7..cac4538be37 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1,16 +1,11 @@ /* eslint-disable max-lines -- Why: PTY IPC is centralized in one main-process module so spawn env scoping, lifecycle cleanup, process inspection, and renderer IPC stay behind one audited boundary. */ import { join, delimiter } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import { getPtyIpc, getPtyPower, type PtyPowerSurface } from './pty-host-bindings' +import { isRendererGone, rendererWebContents, sendToRenderer } from './pty-renderer-surface' import { randomUUID } from 'node:crypto' import { statSync } from 'node:fs' -import { - type BrowserWindow, - type IpcMainEvent, - type IpcMainInvokeEvent, - type WebContents, - ipcMain, - app, - powerMonitor -} from 'electron' +import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { PtyBindingSourceExpectation, Store } from '../persistence' @@ -2280,16 +2275,16 @@ let lastPowerResumeAtMs: number | null = null let powerSignalBreadcrumbsInstalled = false // Why: both field freeze variants correlate with display sleep; suspend/resume timestamps let breadcrumbs line up against the wake. -function installPowerSignalBreadcrumbs(): void { +function installPowerSignalBreadcrumbs(power: PtyPowerSurface): void { if (powerSignalBreadcrumbsInstalled) { return } powerSignalBreadcrumbsInstalled = true - powerMonitor.on('suspend', () => { + power.on('suspend', () => { lastPowerSuspendAtMs = Date.now() mainDeliveryBreadcrumbs.record('power-suspend') }) - powerMonitor.on('resume', () => { + power.on('resume', () => { lastPowerResumeAtMs = Date.now() mainDeliveryBreadcrumbs.record('power-resume') }) @@ -2432,7 +2427,7 @@ export function unbindLocalProviderListeners(): void { // ─── IPC Registration ─────────────────────────────────────────────── export function registerPtyHandlers( - mainWindow: BrowserWindow, + mainWindow: BrowserWindow | null, runtime?: OrcaRuntimeService, getSelectedCodexHomePath?: GetSelectedCodexHomePath, getSettings?: () => GlobalSettings, @@ -2448,12 +2443,16 @@ export function registerPtyHandlers( onPtyExit?: (id: string, exitSequence: number) => void } ): void { + const ipc = getPtyIpc() // Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure. clearRendererDispatcherReadyWatchdog() resetRendererDeliveryAccountingForLifecycleReset = () => {} invalidatePendingPtyDrainPriority = () => {} invalidatePendingPtyDrainPolicy = () => {} - registerRendererLifecycleResetHandlers(mainWindow.webContents) + const rendererContents = rendererWebContents(mainWindow) + if (rendererContents) { + registerRendererLifecycleResetHandlers(rendererContents) + } const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { if (connectionId) { @@ -2473,32 +2472,32 @@ export function registerPtyHandlers( } // Remove prior handlers so re-registration (e.g. macOS re-activate creating a new window) doesn't double-register. - ipcMain.removeHandler('pty:spawn') - ipcMain.removeHandler('pty:kill') - ipcMain.removeHandler('pty:listSessions') - ipcMain.removeHandler('pty:hasPty') - ipcMain.removeHandler('pty:hasChildProcesses') - ipcMain.removeHandler('pty:getForegroundProcess') - ipcMain.removeHandler('pty:inspectProcess') - ipcMain.removeHandler('pty:confirmForegroundProcess') - ipcMain.removeHandler('pty:getCwd') - ipcMain.removeHandler('pty:getSize') - ipcMain.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') - ipcMain.removeHandler('pty:declarePendingPaneSerializer') - ipcMain.removeHandler('pty:settlePaneSerializer') - ipcMain.removeHandler('pty:clearPendingPaneSerializer') - ipcMain.removeHandler('pty:reportRendererSerializerReady') - ipcMain.removeHandler('pty:getMainBufferSnapshot') - ipcMain.removeHandler('pty:sideEffectSnapshot') - ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') - ipcMain.removeHandler('pty:resetRendererDeliveryDebug') - ipcMain.removeHandler('pty:reportRendererDeliveryState') - ipcMain.removeHandler('pty:writeAccepted') - ipcMain.removeAllListeners('pty:write') - ipcMain.removeAllListeners('pty:ackColdRestore') - ipcMain.removeAllListeners('pty:ackData') - ipcMain.removeAllListeners('pty:deliveryResyncResponse') - ipcMain.removeAllListeners('pty:serializeBuffer:response') + ipc.removeHandler('pty:spawn') + ipc.removeHandler('pty:kill') + ipc.removeHandler('pty:listSessions') + ipc.removeHandler('pty:hasPty') + ipc.removeHandler('pty:hasChildProcesses') + ipc.removeHandler('pty:getForegroundProcess') + ipc.removeHandler('pty:inspectProcess') + ipc.removeHandler('pty:confirmForegroundProcess') + ipc.removeHandler('pty:getCwd') + ipc.removeHandler('pty:getSize') + ipc.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') + ipc.removeHandler('pty:declarePendingPaneSerializer') + ipc.removeHandler('pty:settlePaneSerializer') + ipc.removeHandler('pty:clearPendingPaneSerializer') + ipc.removeHandler('pty:reportRendererSerializerReady') + ipc.removeHandler('pty:getMainBufferSnapshot') + ipc.removeHandler('pty:sideEffectSnapshot') + ipc.removeHandler('pty:getRendererDeliveryDebugSnapshot') + ipc.removeHandler('pty:resetRendererDeliveryDebug') + ipc.removeHandler('pty:reportRendererDeliveryState') + ipc.removeHandler('pty:writeAccepted') + ipc.removeAllListeners('pty:write') + ipc.removeAllListeners('pty:ackColdRestore') + ipc.removeAllListeners('pty:ackData') + ipc.removeAllListeners('pty:deliveryResyncResponse') + ipc.removeAllListeners('pty:serializeBuffer:response') // Why: only LocalPtyProvider needs main-process hook injection; daemon-backed providers spawn subprocesses internally. if (localProvider instanceof LocalPtyProvider) { @@ -2527,9 +2526,9 @@ export function registerPtyHandlers( const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath const ptySettings = getSettings?.() const env = buildPtyHostEnv(id, baseEnv, { - isPackaged: app.isPackaged, + isPackaged: getAppEnvironment().isPackaged(), resourcesPath: process.resourcesPath, - userDataPath: app.getPath('userData'), + userDataPath: getAppEnvironment().getPath('userData'), selectedCodexHomePath, skipCodexHomeEnv, stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ @@ -2879,13 +2878,13 @@ export function registerPtyHandlers( }) } perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars)) - const windowAlive = !mainWindow.isDestroyed() + const windowAlive = !isRendererGone(mainWindow) return { - appVersion: app.getVersion(), + appVersion: getAppEnvironment().getVersion(), mainUptimeMs: Math.round(process.uptime() * 1000), - windowFocused: windowAlive ? mainWindow.isFocused() : null, - windowVisible: windowAlive ? mainWindow.isVisible() : null, - windowMinimized: windowAlive ? mainWindow.isMinimized() : null, + windowFocused: windowAlive ? mainWindow!.isFocused() : null, + windowVisible: windowAlive ? mainWindow!.isVisible() : null, + windowMinimized: windowAlive ? mainWindow!.isMinimized() : null, msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs, msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs, perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS), @@ -3082,7 +3081,7 @@ export function registerPtyHandlers( // Why: data for a fully gated PTY signals delivery may be stuck on lost ACKs (e.g. dropped across suspend); ask the renderer for authoritative totals instead of a wall-clock guess. function requestDeliveryResyncForGatedPty(): void { - if (deliveryResyncOutstandingRequestId !== null || mainWindow.isDestroyed()) { + if (deliveryResyncOutstandingRequestId !== null || isRendererGone(mainWindow)) { return } deliveryResyncRequestSerial += 1 @@ -3104,7 +3103,7 @@ export function registerPtyHandlers( }) }, PTY_DELIVERY_RESYNC_TIMEOUT_MS) deliveryResyncTimer.unref?.() - mainWindow.webContents.send('pty:requestDeliveryResync', { requestId }) + sendToRenderer(mainWindow, 'pty:requestDeliveryResync', { requestId }) } // Why write off: bytes sent but never received after a confirmed wedge are gone (no ACK can repay them); hand back restore markers so panes repaint from the snapshot. @@ -3188,7 +3187,7 @@ export function registerPtyHandlers( rendererInFlightTotalChars += charCount recordPtyRendererDeliveryPressure(id) try { - mainWindow.webContents.send('pty:data', payload) + sendToRenderer(mainWindow, 'pty:data', payload) } catch (error) { const current = rendererDeliveryAccountingByPty.get(id) if (current) { @@ -3275,10 +3274,10 @@ export function registerPtyHandlers( reason: PtyModelRestoreReason, markerSeq: number | undefined ): void { - if (mainWindow.isDestroyed()) { + if (isRendererGone(mainWindow)) { return } - mainWindow.webContents.send('pty:modelRestoreNeeded', { + sendToRenderer(mainWindow, 'pty:modelRestoreNeeded', { id, reason, ...(typeof markerSeq === 'number' ? { markerSeq } : {}) @@ -3475,13 +3474,13 @@ export function registerPtyHandlers( function armDispatcherReadyWatchdog(): void { clearDispatcherReadyWatchdog() - if (mainWindow.isDestroyed()) { + if (isRendererGone(mainWindow)) { return } // Why: one-shot self-heal — force the gate open if the reloaded page never signals ready, so a dropped handshake can't hold it forever. Unref'd so it can't keep the process alive. dispatcherReadyWatchdogTimer = setTimeout(() => { dispatcherReadyWatchdogTimer = null - if (rendererPtyDispatcherReady || mainWindow.isDestroyed()) { + if (rendererPtyDispatcherReady || isRendererGone(mainWindow)) { return } rendererPtyDispatcherReady = true @@ -3494,7 +3493,7 @@ export function registerPtyHandlers( function flushPendingData(): void { flushTimer = null - if (mainWindow.isDestroyed()) { + if (isRendererGone(mainWindow)) { // Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge. producerFlowControl.releaseAll() clearDeliveryResyncProbe() @@ -3688,7 +3687,7 @@ export function registerPtyHandlers( } function preparePtyExitForRenderer(payload: { id: string; code: number }): (() => void) | null { - if (mainWindow.isDestroyed()) { + if (isRendererGone(mainWindow)) { sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed') return () => {} } @@ -3749,7 +3748,7 @@ export function registerPtyHandlers( } function finalizePtyExitForRenderer(payload: { id: string; code: number }): void { - if (mainWindow.isDestroyed()) { + if (isRendererGone(mainWindow)) { rendererCreditBeforeExitByPty.delete(payload.id) return } @@ -3777,7 +3776,7 @@ export function registerPtyHandlers( schedulePendingDataAfterCreditReport(true) } } - mainWindow.webContents.send('pty:exit', { + sendToRenderer(mainWindow, 'pty:exit', { ...payload, ...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {}) }) @@ -3798,8 +3797,8 @@ export function registerPtyHandlers( } function sendPtySpawnedToRenderer(id: string): void { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:spawned', { id }) + if (!isRendererGone(mainWindow)) { + sendToRenderer(mainWindow, 'pty:spawned', { id }) } } @@ -3817,7 +3816,7 @@ export function registerPtyHandlers( const preservesSeq = !payload.transformed && rawLength === payload.data.length const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined const projectionId = projection?.identity.projectionSemanticsId - if (mainWindow.isDestroyed()) { + if (isRendererGone(mainWindow)) { if (projectionId) { sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed') } @@ -4065,14 +4064,10 @@ export function registerPtyHandlers( // just the pane whose write happened to detect the dead endpoint (STA-2373). localWriteUnavailableUnsub = localProvider.onWriteUnavailable?.((payload) => { - if ( - mainWindow.isDestroyed() || - (typeof mainWindow.webContents.isDestroyed === 'function' && - mainWindow.webContents.isDestroyed()) - ) { + if (isRendererGone(mainWindow)) { return } - mainWindow.webContents.send('pty:writeUnavailable', { id: payload.id }) + sendToRenderer(mainWindow, 'pty:writeUnavailable', { id: payload.id }) }) ?? null // Daemon keep-tail thinning facts, in byte order with onData: markers flip transient-fact scan authority; a gap forces renderer restore from the snapshot. @@ -4165,7 +4160,7 @@ export function registerPtyHandlers( pending.resolve(result) } - ipcMain.on( + ipc.on( 'pty:serializeBuffer:response', ( _event, @@ -4226,7 +4221,7 @@ export function registerPtyHandlers( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } ): Promise { - if (mainWindow.isDestroyed()) { + if (isRendererGone(mainWindow)) { return Promise.resolve(null) } @@ -4244,7 +4239,7 @@ export function registerPtyHandlers( if (opts) { payload.opts = opts } - mainWindow.webContents.send('pty:serializeBuffer:request', payload) + sendToRenderer(mainWindow, 'pty:serializeBuffer:request', payload) }) } @@ -4261,9 +4256,13 @@ export function registerPtyHandlers( } rendererGateResetLoadHandler = resetRendererPtyDeliveryGateState rendererGateResetGoneHandler = resetRendererPtyDeliveryGateState - rendererGateResetWebContents = mainWindow.webContents - mainWindow.webContents.on('did-finish-load', rendererGateResetLoadHandler) - mainWindow.webContents.on('render-process-gone', rendererGateResetGoneHandler) + // Why guarded: with no renderer there is nothing to reset a gate for, and nothing + // that will ever emit these. Registering against a fake window was the old workaround. + if (rendererContents) { + rendererGateResetWebContents = rendererContents + rendererContents.on('did-finish-load', rendererGateResetLoadHandler) + rendererContents.on('render-process-gone', rendererGateResetGoneHandler) + } // Why: only LocalPtyProvider PTYs (main-process) can be orphaned on reload; daemon sessions survive by design and cleanup would kill them. clearDidFinishLoadHandler() @@ -4272,14 +4271,16 @@ export function registerPtyHandlers( didFinishLoadHandler = () => { // Why: always advance to keep the generation monotonic, but skip the sweep on crash/freeze-recovery reload — it would kill live local PTYs before session restore (#5787). const generation = lp.advanceGeneration() - if (options?.isRecoveryReloadInFlight?.(mainWindow.webContents.id)) { + if (rendererContents && options?.isRecoveryReloadInFlight?.(rendererContents.id)) { return } // Why: the retained provider onExit callback is the only physical-exit proof; it clears ownership after the OS reaps it. lp.killOrphanedPtys(generation - 1) } - didFinishLoadWebContents = mainWindow.webContents - mainWindow.webContents.on('did-finish-load', didFinishLoadHandler) + if (rendererContents) { + didFinishLoadWebContents = rendererContents + rendererContents.on('did-finish-load', didFinishLoadHandler) + } } const assertFolderWorkspacePtyPathUsable = async ( @@ -4801,13 +4802,13 @@ export function registerPtyHandlers( settings: ptySettings }) if (isDaemonHostSpawn && sessionId && !preAdoptedStablePane) { - if (!isSafePtySessionId(sessionId, app.getPath('userData'))) { + if (!isSafePtySessionId(sessionId, getAppEnvironment().getPath('userData'))) { throw new Error('Invalid PTY session id') } env = buildPtyHostEnv(sessionId, env ?? {}, { - isPackaged: app.isPackaged, + isPackaged: getAppEnvironment().isPackaged(), resourcesPath: process.resourcesPath, - userDataPath: app.getPath('userData'), + userDataPath: getAppEnvironment().getPath('userData'), selectedCodexHomePath, skipCodexHomeEnv, stripInheritedOrcaCodexHome, @@ -5868,7 +5869,7 @@ export function registerPtyHandlers( }, clearBuffer: async (ptyId) => { // Why: desktop xterm and daemon/SSH providers hold separate buffers; clear both so mobile resubscribe can't resurrect cleared history. - mainWindow.webContents.send('pty:clearBuffer:request', { ptyId }) + sendToRenderer(mainWindow, 'pty:clearBuffer:request', { ptyId }) try { await getProviderForPty(ptyId).clearBuffer(ptyId) } catch { @@ -5942,7 +5943,7 @@ export function registerPtyHandlers( return Math.max(0, Math.min(50_000, Math.floor(value))) } - ipcMain.handle( + ipc.handle( 'pty:getMainBufferSnapshot', async ( _event, @@ -6006,22 +6007,22 @@ export function registerPtyHandlers( ) // Why: main owns side effects, so this replay restores title state only — never historical bells/completions (no-attention-replay rule, terminal-side-effect-authority.md). - ipcMain.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { + ipc.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { return null } return runtime.getTerminalSideEffectSnapshot(args.id) }) - installPowerSignalBreadcrumbs() - ipcMain.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { + installPowerSignalBreadcrumbs(getPtyPower()) + ipc.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { return getPtyRendererDeliveryDebugSnapshot() }) - ipcMain.handle('pty:resetRendererDeliveryDebug', (): void => { + ipc.handle('pty:resetRendererDeliveryDebug', (): void => { resetPtyRendererDeliveryDebug() }) - ipcMain.handle( + ipc.handle( 'pty:spawn', async ( _event, @@ -6544,16 +6545,16 @@ export function registerPtyHandlers( } const sessionIdForEnv = effectiveSessionId // Why: this id reaches filesystem paths; reject traversal/separators so a crafted IPC payload can't escape the expected roots. - if (!isSafePtySessionId(sessionIdForEnv, app.getPath('userData'))) { + if (!isSafePtySessionId(sessionIdForEnv, getAppEnvironment().getPath('userData'))) { throw new Error('Invalid PTY session id') } // Why: clone before mutating so injections don't leak back into args.env (renderer may reuse it). env = { ...baseEnv } try { buildPtyHostEnv(sessionIdForEnv, env, { - isPackaged: app.isPackaged, + isPackaged: getAppEnvironment().isPackaged(), resourcesPath: process.resourcesPath, - userDataPath: app.getPath('userData'), + userDataPath: getAppEnvironment().getPath('userData'), selectedCodexHomePath, skipCodexHomeEnv, stripInheritedOrcaCodexHome, @@ -7232,15 +7233,10 @@ export function registerPtyHandlers( ) const reportUnavailablePtyWrite = (id: string, error: unknown): void => { - if ( - !isPtyWriteUnavailableError(error) || - mainWindow.isDestroyed() || - (typeof mainWindow.webContents.isDestroyed === 'function' && - mainWindow.webContents.isDestroyed()) - ) { + if (!isPtyWriteUnavailableError(error) || isRendererGone(mainWindow)) { return } - mainWindow.webContents.send('pty:writeUnavailable', { id }) + sendToRenderer(mainWindow, 'pty:writeUnavailable', { id }) } const writePtyProviderInputWithinLimit = ( @@ -7331,12 +7327,16 @@ export function registerPtyHandlers( (value as { cols: number }).cols > 0 && (value as { rows: number }).rows > 0 + // Why null-tolerant: with no renderer there is no sender that can legitimately match, + // so every write is rejected. These handlers cannot fire headless anyway — ipcMain has + // nothing to deliver from — but failing closed is the right answer if that ever changes. const isPtyWriteEventFromMainWindow = ( event: IpcMainEvent | IpcMainInvokeEvent, - mainWebContents: WebContents + mainWebContents: WebContents | null ): boolean => + mainWebContents !== null && event.sender === mainWebContents && - !mainWindow.isDestroyed() && + !isRendererGone(mainWindow) && !(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed()) const writePtyInput = (args: PtyWritePayload): boolean | Promise => { @@ -7388,8 +7388,11 @@ export function registerPtyHandlers( const hostViewportClaimTails = new Map>() - ipcMain.on('pty:write', (event, args: unknown) => { - if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + ipc.on('pty:write', (event, args: unknown) => { + if ( + !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || + !isPtyWritePayload(args) + ) { return } const claimTail = hostViewportClaimTails.get(args.id) @@ -7399,8 +7402,11 @@ export function registerPtyHandlers( } writePtyInput(args) }) - ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { - if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + ipc.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { + if ( + !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || + !isPtyWritePayload(args) + ) { return false } const claimTail = hostViewportClaimTails.get(args.id) @@ -7409,10 +7415,10 @@ export function registerPtyHandlers( : writePtyInputAccepted(args) }) - ipcMain.removeAllListeners('pty:claimViewport') - ipcMain.on('pty:claimViewport', (event, args: unknown) => { + ipc.removeAllListeners('pty:claimViewport') + ipc.on('pty:claimViewport', (event, args: unknown) => { if ( - !isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || + !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || !runtime || !isPtyViewportClaimPayload(args) ) { @@ -7436,9 +7442,9 @@ export function registerPtyHandlers( }) }) - // Why: resize is fire-and-forget — ipcMain.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply. - ipcMain.removeAllListeners('pty:resize') - ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { + // Why: resize is fire-and-forget — ipc.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply. + ipc.removeAllListeners('pty:resize') + ipc.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { // Why: after a desktop-fit override change the renderer's safeFit cascade re-measures ALL panes (background ones at full width), so suppress every pty:resize in this window to avoid corrupting PTY dimensions. if (runtime?.isResizeSuppressed()) { return @@ -7478,13 +7484,13 @@ export function registerPtyHandlers( }) // Why: pty:reportGeometry is a measurement-only sibling of pty:resize — it refreshes the restore-target cache (never resizes) so mobile-fit hold learns real desktop dims even while resize is blocked. See docs/mobile-fit-hold.md. - ipcMain.removeAllListeners('pty:reportGeometry') - ipcMain.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => { + ipc.removeAllListeners('pty:reportGeometry') + ipc.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => { runtime?.recordRendererGeometry(args.id, args.cols, args.rows) }) // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold-restore cache after the renderer consumed it; no-op for non-daemon providers. - ipcMain.on('pty:ackColdRestore', (_event, args: { id: string }) => { + ipc.on('pty:ackColdRestore', (_event, args: { id: string }) => { const provider = tryGetProviderForPty(args.id) if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') { provider.ackColdRestore(args.id) @@ -7492,7 +7498,7 @@ export function registerPtyHandlers( }) // Why: renderer ACKs bound main→renderer delivery without stopping PTY ingestion — agent/status consumers still see every chunk via the provider/runtime path. - ipcMain.on( + ipc.on( 'pty:ackData', (_event, args: { id: string; charCount?: number; processedChars?: number }) => { lastAckReceivedAtMs = Date.now() @@ -7512,7 +7518,7 @@ export function registerPtyHandlers( } ) - ipcMain.on( + ipc.on( 'pty:deliveryResyncResponse', (_event, args: { requestId: number; processedCharsByPty: Record }) => { if ( @@ -7540,7 +7546,7 @@ export function registerPtyHandlers( ) // Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0) kills every main→renderer push channel while invoke survives, so the resync rides here plus a write-off lane. - ipcMain.handle( + ipc.handle( 'pty:reportRendererDeliveryState', (_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => { // Extra repair lane for the lost-ACK variant: identical max-merge to the resync response, so a heal is only reached when merging cannot drain. @@ -7583,10 +7589,10 @@ export function registerPtyHandlers( ) // Why: renderer signals its pty:data listener is live; until then sends are held so boot-window bytes can't drop into a listener-less page and pin the gate. - ipcMain.removeAllListeners('pty:rendererDispatcherReady') - ipcMain.on('pty:rendererDispatcherReady', (event) => { + ipc.removeAllListeners('pty:rendererDispatcherReady') + ipc.on('pty:rendererDispatcherReady', (event) => { // Why: the reconcile below destructively clears delivery accounting, so a straggler handshake from a dying window must not reset the new window. - if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents)) { + if (!isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow))) { return } // Why: a handshake while the gate is already open means a page load whose lifecycle reset was missed; clear the dead page's stale accounting so it can't permanently gate survivors. @@ -7600,8 +7606,8 @@ export function registerPtyHandlers( schedulePendingDataFlush(0) }) - ipcMain.removeAllListeners('pty:setActiveRendererPty') - ipcMain.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => { + ipc.removeAllListeners('pty:setActiveRendererPty') + ipc.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => { if (typeof args.id !== 'string' || !args.id) { return } @@ -7617,8 +7623,8 @@ export function registerPtyHandlers( invalidatePendingPtyDrainPriority(args.id) }) - ipcMain.removeAllListeners('pty:setRendererPtyVisible') - ipcMain.on('pty:setRendererPtyVisible', (_event, args: { id: string; visible: boolean }) => { + ipc.removeAllListeners('pty:setRendererPtyVisible') + ipc.on('pty:setRendererPtyVisible', (_event, args: { id: string; visible: boolean }) => { if (typeof args.id !== 'string' || !args.id) { return } @@ -7633,8 +7639,8 @@ export function registerPtyHandlers( syncPtyBackgroundedDelivery(args.id, 'visibility-report') }) - ipcMain.removeAllListeners('pty:setHiddenRendererPty') - ipcMain.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => { + ipc.removeAllListeners('pty:setHiddenRendererPty') + ipc.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => { if (typeof args.id !== 'string' || !args.id) { return } @@ -7678,8 +7684,8 @@ export function registerPtyHandlers( } }) - ipcMain.removeAllListeners('pty:terminalViewAttributes') - ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => { + ipc.removeAllListeners('pty:terminalViewAttributes') + ipc.on('pty:terminalViewAttributes', (_event, args: unknown) => { // Why validate-or-drop: a malformed palette gives a wrong color reply that breaks TUI theme detection worse than the silent-until-first-push default. const attributes = validateTerminalViewAttributes(args) if (attributes) { @@ -7687,8 +7693,8 @@ export function registerPtyHandlers( } }) - ipcMain.removeAllListeners('pty:setPtyDeliveryInterest') - ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { + ipc.removeAllListeners('pty:setPtyDeliveryInterest') + ipc.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { if (typeof args.id !== 'string' || !args.id) { return } @@ -7701,15 +7707,15 @@ export function registerPtyHandlers( } }) - ipcMain.removeAllListeners('pty:signal') - ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => { + ipc.removeAllListeners('pty:signal') + ipc.on('pty:signal', (_event, args: { id: string; signal: string }) => { tryGetProviderForPty(args.id) ?.sendSignal(args.id, args.signal) .catch(() => {}) }) - ipcMain.removeAllListeners('pty:clearBuffer') - ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => { + ipc.removeAllListeners('pty:clearBuffer') + ipc.on('pty:clearBuffer', (_event, args: { id: string }) => { // Why: clear PTY-side state (ConPTY/daemon/SSH buffer) so the next prompt repaint doesn't land at a stale cursor row. tryGetProviderForPty(args.id) ?.clearBuffer(args.id) @@ -7717,7 +7723,7 @@ export function registerPtyHandlers( runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) }) - ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { + ipc.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. throw new Error('Invalid PTY provider id') @@ -7767,7 +7773,7 @@ export function registerPtyHandlers( } }) - ipcMain.handle('pty:listSessions', async (): Promise => { + ipc.handle('pty:listSessions', async (): Promise => { const deduped = new Map() const admission = new PtyProcessListAdmission() await visitPtyProcessListingsInBatches( @@ -7799,7 +7805,7 @@ export function registerPtyHandlers( return Array.from(deduped.values()) }) - ipcMain.handle( + ipc.handle( 'pty:getAuthoritativeBufferSnapshotCapabilities', async (_event, args: { ids?: unknown }) => { const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : [] @@ -7847,7 +7853,7 @@ export function registerPtyHandlers( } ) - ipcMain.handle('pty:hasPty', async (_event, args: { id: string }): Promise => { + ipc.handle('pty:hasPty', async (_event, args: { id: string }): Promise => { if (typeof args?.id !== 'string' || args.id.startsWith('remote:')) { // Why: same routing hazard pty:kill guards against — ptyOwnership never holds // a runtime terminal handle and parseAppSshPtyId ignores it, so the lookup @@ -7871,17 +7877,14 @@ export function registerPtyHandlers( } }) - ipcMain.handle( - 'pty:hasChildProcesses', - async (_event, args: { id: string }): Promise => { - if (!hasPtyProviderForInspection(args.id)) { - return false - } - return getProviderForPty(args.id).hasChildProcesses(args.id) + ipc.handle('pty:hasChildProcesses', async (_event, args: { id: string }): Promise => { + if (!hasPtyProviderForInspection(args.id)) { + return false } - ) + return getProviderForPty(args.id).hasChildProcesses(args.id) + }) - ipcMain.handle( + ipc.handle( 'pty:getForegroundProcess', async (_event, args: { id: string }): Promise => { if (!hasPtyProviderForInspection(args.id)) { @@ -7891,11 +7894,11 @@ export function registerPtyHandlers( } ) - ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) => + ipc.handle('pty:inspectProcess', async (_event, args: { id: string }) => inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) ) - ipcMain.handle( + ipc.handle( 'pty:confirmForegroundProcess', async (_event, args: { id: string }): Promise => { if (!hasPtyProviderForInspection(args.id)) { @@ -7908,7 +7911,7 @@ export function registerPtyHandlers( ) // Why: Cmd+D split needs the live shell cwd so the new pane inherits it (not the worktree root); '' means unknown/unresolvable (Windows) → renderer falls through. - ipcMain.handle('pty:getCwd', async (_event, args: { id: string }): Promise => { + ipc.handle('pty:getCwd', async (_event, args: { id: string }): Promise => { try { return await getProviderForPty(args.id).getCwd(args.id) } catch { @@ -7917,7 +7920,7 @@ export function registerPtyHandlers( }) // Why: prefer the provider's APPLIED size over the requested ptySizes so the renderer's resume drift-check can spot a dropped resize; null means "cannot confirm" → re-forward once. - ipcMain.handle( + ipc.handle( 'pty:getSize', async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => { const provider = tryGetProviderForPty(args.id) @@ -7936,7 +7939,7 @@ export function registerPtyHandlers( ) // Pre-signal handshake handlers (declare→spawn→settle/clear); see docs/mobile-prefer-renderer-scrollback.md and `pendingByPaneKey` above. - ipcMain.handle( + ipc.handle( 'pty:declarePendingPaneSerializer', async (event, args: { paneKey?: unknown }): Promise => { if (!isValidPaneKey(args.paneKey)) { @@ -7946,7 +7949,7 @@ export function registerPtyHandlers( } ) - ipcMain.handle( + ipc.handle( 'pty:settlePaneSerializer', async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { @@ -7962,7 +7965,7 @@ export function registerPtyHandlers( } ) - ipcMain.handle( + ipc.handle( 'pty:clearPendingPaneSerializer', async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { @@ -7973,7 +7976,7 @@ export function registerPtyHandlers( } ) - ipcMain.handle( + ipc.handle( 'pty:reportRendererSerializerReady', async (_event, args: { ptyId?: unknown }): Promise => { if ( @@ -8001,17 +8004,13 @@ export function registerHeadlessPtyRuntime( onPtyExit?: (id: string, exitSequence: number) => void } ): void { - // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. - const headlessWindow = { - isDestroyed: () => true, - webContents: { - send: () => {}, - on: () => {}, - removeListener: () => {} - } - } as unknown as BrowserWindow + // Why null and not a stub window: headless `orca serve` has no renderer but needs the + // same PTY handlers so remote clients can drive terminals. This used to pass a fake + // BrowserWindow whose isDestroyed() returned true — a window-shaped object that lied + // about being a window. `null` says the same thing honestly, and keeps `electron` out + // of this path entirely. registerPtyHandlers( - headlessWindow, + null, runtime, getSelectedCodexHomePath, getSettings, diff --git a/src/main/ipc/repos-execution-host-catalog.test.ts b/src/main/ipc/repos-execution-host-catalog.test.ts index 0caa144ba88..6d200522535 100644 --- a/src/main/ipc/repos-execution-host-catalog.test.ts +++ b/src/main/ipc/repos-execution-host-catalog.test.ts @@ -26,6 +26,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-local-add-and-project-setup.test.ts b/src/main/ipc/repos-local-add-and-project-setup.test.ts index bd581042881..aed03724200 100644 --- a/src/main/ipc/repos-local-add-and-project-setup.test.ts +++ b/src/main/ipc/repos-local-add-and-project-setup.test.ts @@ -26,6 +26,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-local-clone-lifecycle.test.ts b/src/main/ipc/repos-local-clone-lifecycle.test.ts index d612c6bde1b..b6f851590ba 100644 --- a/src/main/ipc/repos-local-clone-lifecycle.test.ts +++ b/src/main/ipc/repos-local-clone-lifecycle.test.ts @@ -30,6 +30,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-nested-import.test.ts b/src/main/ipc/repos-nested-import.test.ts index f5731059b1b..010be624a91 100644 --- a/src/main/ipc/repos-nested-import.test.ts +++ b/src/main/ipc/repos-nested-import.test.ts @@ -29,6 +29,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-nested-scan.test.ts b/src/main/ipc/repos-nested-scan.test.ts index 22d4c24be29..26650246a06 100644 --- a/src/main/ipc/repos-nested-scan.test.ts +++ b/src/main/ipc/repos-nested-scan.test.ts @@ -29,6 +29,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-remote-base-ref-queries.test.ts b/src/main/ipc/repos-remote-base-ref-queries.test.ts index 296dcb3c7a6..f96ff9c4da6 100644 --- a/src/main/ipc/repos-remote-base-ref-queries.test.ts +++ b/src/main/ipc/repos-remote-base-ref-queries.test.ts @@ -26,6 +26,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-remote-git-username.test.ts b/src/main/ipc/repos-remote-git-username.test.ts index d1c0cc27403..d3f22254fd7 100644 --- a/src/main/ipc/repos-remote-git-username.test.ts +++ b/src/main/ipc/repos-remote-git-username.test.ts @@ -26,6 +26,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 7145db75db2..e5660eb8d27 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -27,6 +27,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/ssh-worktree-create-root-registration.ts b/src/main/ipc/ssh-worktree-create-root-registration.ts index e364239cdbe..705489992b7 100644 --- a/src/main/ipc/ssh-worktree-create-root-registration.ts +++ b/src/main/ipc/ssh-worktree-create-root-registration.ts @@ -1,4 +1,4 @@ -import { getActiveMultiplexer } from './ssh' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' const SSH_CONNECTION_UNAVAILABLE_MESSAGE = 'SSH connection is not available. Please reconnect and try again.' diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 0c504c5f0a0..e31b852a751 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -9,7 +9,6 @@ import { } from '../ssh/ssh-config-host-picker' import type { SshConnection, SshConnectionCallbacks } from '../ssh/ssh-connection' import { SshConnectionManager } from '../ssh/ssh-connection-manager' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session' import type { SshAiVaultRelayListParams, @@ -30,6 +29,29 @@ import type { import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' import { quitTeardownStartGate } from '../quit-teardown-start-gate' import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' +import { + getSshTargetRegistryStore, + setSshActiveMultiplexerResolver, + setSshTargetRegistryHandlers, + setSshTargetRegistryStore +} from '../ssh/ssh-target-registry' + +// Why at module scope: this resolver is pure state lookup with no handler lifecycle, so +// installing it on import keeps it correct even before registerSshHandlers runs. +setSshActiveMultiplexerResolver( + (connectionId) => activeSessions.get(connectionId)?.getMux() ?? undefined +) + +// Why re-exported: the registry moved to ../ssh/ssh-target-registry so the runtime can +// read it without pulling ipcMain in, but many existing importers reference these from +// here. Re-exporting keeps them working without a repo-wide rename. +export { + connectRegisteredSshTarget, + getActiveMultiplexer, + getRegisteredSshState, + listRegisteredRemovedSshTargetLabels, + listRegisteredSshTargets +} from '../ssh/ssh-target-registry' import { isAuthError } from '../ssh/ssh-connection-utils' import { createCancelledConnectAttemptError } from '../ssh/ssh-connect-attempt-cancellation' import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset' @@ -62,11 +84,8 @@ import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -let sshStore: SshConnectionStore | null = null let connectionManager: SshConnectionManager | null = null let portForwardManager: SshPortForwardManager | null = null -let registeredConnectSshTarget: ((targetId: string) => Promise) | null = null -let registeredGetSshState: ((targetId: string) => SshConnectionState | undefined) | null = null let persistedStore: Store | null = null let advertisedUrlWatcherUnsubscribe: (() => void) | null = null let powerMonitorUnsubscribe: (() => void) | null = null @@ -103,27 +122,6 @@ function getCurrentMainWindow(): BrowserWindow | null { return currentGetMainWindow() } -export async function connectRegisteredSshTarget(targetId: string): Promise { - if (!registeredConnectSshTarget) { - throw new Error('ssh_handlers_not_registered') - } - return registeredConnectSshTarget(targetId) -} - -export function getRegisteredSshState(targetId: string): SshConnectionState | undefined { - return registeredGetSshState?.(targetId) -} - -/** Public targets for runtime RPC clients — same list the desktop renderer gets. */ -export function listRegisteredSshTargets(): SshTarget[] { - return sshStore?.listTargets() ?? [] -} - -/** Removed-target id → last known label, for ghost-host display on paired clients. */ -export function listRegisteredRemovedSshTargetLabels(): Record { - return sshStore?.listRemovedTargetLabels() ?? {} -} - export async function disconnectRegisteredSshTarget(targetId: string): Promise { invalidateConnectAttempt(targetId) await runTargetLifecycle(targetId, () => @@ -132,10 +130,10 @@ export async function disconnectRegisteredSshTarget(targetId: string): Promise { - if (!sshStore) { + const store = getSshTargetRegistryStore() + if (!store) { return } - const store = sshStore invalidateConnectAttempt(targetId) await runTargetLifecycle(targetId, async () => { try { @@ -528,7 +526,9 @@ function persistPortForwards(targetId: string): void { remotePort: f.remotePort, label: f.label })) - sshStore!.updateTarget(targetId, { portForwards: saved.length > 0 ? saved : undefined }) + getSshTargetRegistryStore()!.updateTarget(targetId, { + portForwards: saved.length > 0 ? saved : undefined + }) } // Why: keep forwards that failed to restore in the persisted list so they retry on next reconnect instead of being silently dropped. @@ -536,7 +536,7 @@ function persistPortForwardsWithUnrestored(targetId: string): void { const active = portForwardManager!.listForwards(targetId) const activeKeys = new Set(active.map((f) => `${f.localPort}:${f.remoteHost}:${f.remotePort}`)) - const existing = sshStore!.getTarget(targetId)?.portForwards ?? [] + const existing = getSshTargetRegistryStore()!.getTarget(targetId)?.portForwards ?? [] const unrestored = existing.filter( (pf) => !activeKeys.has(`${pf.localPort}:${pf.remoteHost}:${pf.remotePort}`) ) @@ -550,14 +550,16 @@ function persistPortForwardsWithUnrestored(targetId: string): void { })), ...unrestored ] - sshStore!.updateTarget(targetId, { portForwards: saved.length > 0 ? saved : undefined }) + getSshTargetRegistryStore()!.updateTarget(targetId, { + portForwards: saved.length > 0 ? saved : undefined + }) } async function restorePortForwards( targetId: string, getMainWindow: () => BrowserWindow | null ): Promise { - const target = sshStore!.getTarget(targetId) + const target = getSshTargetRegistryStore()!.getTarget(targetId) if (!target?.portForwards?.length) { return } @@ -765,7 +767,7 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks { } // Why: allow reconnect from both 'ready' and 'reconnecting'; without the latter, a failed relay deploy would permanently brick the session. if (shouldReconnectRelay) { - const target = sshStore?.getTarget(targetId) + const target = getSshTargetRegistryStore()?.getTarget(targetId) const conn = connectionManager?.getConnection(targetId) if (conn) { void session.reconnect(conn, relayGracePeriodForTarget(target)) @@ -804,7 +806,7 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void { if (!c) { return } - const t = sshStore?.getTarget(tid) + const t = getSshTargetRegistryStore()?.getTarget(tid) // Why: bounded exponential backoff — without it, a remote bug that closes every fresh --connect channel becomes an infinite relay-deploy loop. const state = relayLostBackoff.get(tid) ?? { @@ -963,7 +965,7 @@ export function registerSshHandlers( currentGetMainWindow = getMainWindow currentRuntime = runtime - sshStore = new SshConnectionStore(store) + setSshTargetRegistryStore(new SshConnectionStore(store)) persistedStore = store registerAdvertisedUrlRefresh(getCurrentMainWindow) @@ -997,11 +999,12 @@ export function registerSshHandlers( // Why: add/import can re-adopt workspaces orphaned on a removed target id (see ssh-target-readoption); the renderer must refresh its repo list to surface them. function takeRepoReadoptions(): SshRepoReadoption[] { - if (!sshStore || sshStore.lastRepoReadoptions.length === 0) { + const store = getSshTargetRegistryStore() + if (!store || store.lastRepoReadoptions.length === 0) { return [] } - const repoReadoptions = sshStore.lastRepoReadoptions - sshStore.lastRepoReadoptions = [] + const repoReadoptions = store.lastRepoReadoptions + store.lastRepoReadoptions = [] for (const targetId of new Set( repoReadoptions.flatMap(({ oldTargetId, newTargetId }) => [oldTargetId, newTargetId]) )) { @@ -1015,15 +1018,15 @@ export function registerSshHandlers( } ipcMain.handle('ssh:listTargets', () => { - return sshStore!.listTargets() + return getSshTargetRegistryStore()!.listTargets() }) ipcMain.handle('ssh:listRemovedTargetLabels', () => { - return sshStore!.listRemovedTargetLabels() + return getSshTargetRegistryStore()!.listRemovedTargetLabels() }) ipcMain.handle('ssh:addTarget', (_event, args: { target: Omit }) => { - const target = sshStore!.addTarget(args.target) + const target = getSshTargetRegistryStore()!.addTarget(args.target) // Why: re-adding a removed host can re-adopt orphaned workspaces; refresh the renderer's repo list so they move back onto the live host. const repoReadoptions = takeRepoReadoptions() return { target, repoReadoptions } @@ -1032,7 +1035,7 @@ export function registerSshHandlers( ipcMain.handle( 'ssh:updateTarget', (_event, args: { id: string; updates: Partial> }) => { - return sshStore!.updateTarget(args.id, args.updates) + return getSshTargetRegistryStore()!.updateTarget(args.id, args.updates) } ) @@ -1041,7 +1044,7 @@ export function registerSshHandlers( }) ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => { - const targets = sshStore!.importFromSshConfig(args) + const targets = getSshTargetRegistryStore()!.importFromSshConfig(args) const repoReadoptions = takeRepoReadoptions() return { targets, repoReadoptions } }) @@ -1050,9 +1053,9 @@ export function registerSshHandlers( // mutate the target store (bulk sync stays on Settings → Import). ipcMain.handle('ssh:listConfigHosts', (_event, args?: SshConfigHostListArgs) => { return listUserSshConfigHostSummaries( - sshStore!.listTargets(), + getSshTargetRegistryStore()!.listTargets(), args?.query, - sshStore!.listSuppressedSshConfigAliases(), + getSshTargetRegistryStore()!.listSuppressedSshConfigAliases(), { refresh: args?.refresh === true } ) }) @@ -1115,8 +1118,10 @@ export function registerSshHandlers( } } - registeredConnectSshTarget = connectTarget - registeredGetSshState = (targetId: string) => getPublicSshState(targetId) + setSshTargetRegistryHandlers({ + connect: connectTarget, + getState: (targetId: string) => getPublicSshState(targetId) + }) ipcMain.handle('ssh:connect', async (_event, args: { targetId: string }) => { return connectTarget(args.targetId) @@ -1126,7 +1131,7 @@ export function registerSshHandlers( targetId: string, replacePendingTransport = false ): Promise { - const target = sshStore!.getTarget(targetId) + const target = getSshTargetRegistryStore()!.getTarget(targetId) if (!target) { throw new Error(`SSH target "${targetId}" not found`) } @@ -1284,7 +1289,9 @@ export function registerSshHandlers( // Why: persist whether this connect needed a credential so startup can partition targets into eager vs deferred without re-probing keys. const requiredPassphrase = credentialRequestedForTarget.has(targetId) credentialRequestedForTarget.delete(targetId) - sshStore!.updateTarget(targetId, { lastRequiredPassphrase: requiredPassphrase }) + getSshTargetRegistryStore()!.updateTarget(targetId, { + lastRequiredPassphrase: requiredPassphrase + }) return getPublicSshState(targetId)! } @@ -1416,7 +1423,7 @@ export function registerSshHandlers( return existingReset } - const target = sshStore!.getTarget(args.targetId) + const target = getSshTargetRegistryStore()!.getTarget(args.targetId) if (!target) { throw new Error(`SSH target "${args.targetId}" not found`) } @@ -1441,7 +1448,7 @@ export function registerSshHandlers( // Why: auto-connect callers need to know whether connecting will prompt; true when the last connect required a credential and no live conn has it cached. ipcMain.handle('ssh:needsPassphrasePrompt', (_event, args: { targetId: string }) => { - const target = sshStore!.getTarget(args.targetId) + const target = getSshTargetRegistryStore()!.getTarget(args.targetId) if (!target?.lastRequiredPassphrase) { return false } @@ -1450,7 +1457,7 @@ export function registerSshHandlers( }) ipcMain.handle('ssh:testConnection', async (_event, args: { targetId: string }) => { - const target = sshStore!.getTarget(args.targetId) + const target = getSshTargetRegistryStore()!.getTarget(args.targetId) if (!target) { throw new Error(`SSH target "${args.targetId}" not found`) } @@ -1601,7 +1608,7 @@ export function registerSshHandlers( return enrichDetected(args.targetId, ports) }) - return { connectionManager, sshStore } + return { connectionManager, sshStore: getSshTargetRegistryStore() as SshConnectionStore } } export function getSshConnectionManager(): SshConnectionManager | null { @@ -1788,18 +1795,13 @@ export async function resetSshHandlerStateForTests(): Promise { portForwardManager?.dispose() connectionManager = null portForwardManager = null - sshStore = null + setSshTargetRegistryStore(null) persistedStore = null - registeredConnectSshTarget = null - registeredGetSshState = null + setSshTargetRegistryHandlers({ connect: null, getState: null }) currentGetMainWindow = () => null currentRuntime = undefined } export function getSshConnectionStore(): SshConnectionStore | null { - return sshStore -} - -export function getActiveMultiplexer(connectionId: string): SshChannelMultiplexer | undefined { - return activeSessions.get(connectionId)?.getMux() ?? undefined + return getSshTargetRegistryStore() } diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 7946466ba7e..798cc443442 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -47,6 +47,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-delete-pty-teardown.test.ts b/src/main/ipc/worktrees-delete-pty-teardown.test.ts index 721e0a8866d..ea398ddaf63 100644 --- a/src/main/ipc/worktrees-delete-pty-teardown.test.ts +++ b/src/main/ipc/worktrees-delete-pty-teardown.test.ts @@ -49,6 +49,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-detected-scan-cache.test.ts b/src/main/ipc/worktrees-detected-scan-cache.test.ts index c0e9fba684e..6fb798fad3f 100644 --- a/src/main/ipc/worktrees-detected-scan-cache.test.ts +++ b/src/main/ipc/worktrees-detected-scan-cache.test.ts @@ -41,6 +41,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts b/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts index a7d2a9964d8..b1fea8d41a5 100644 --- a/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts +++ b/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts @@ -36,6 +36,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-existing-branch-checkout.test.ts b/src/main/ipc/worktrees-existing-branch-checkout.test.ts index b78018c4dbf..e455e176cf5 100644 --- a/src/main/ipc/worktrees-existing-branch-checkout.test.ts +++ b/src/main/ipc/worktrees-existing-branch-checkout.test.ts @@ -44,6 +44,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-forget-local.test.ts b/src/main/ipc/worktrees-forget-local.test.ts index 9971885d5aa..b1616f6eecc 100644 --- a/src/main/ipc/worktrees-forget-local.test.ts +++ b/src/main/ipc/worktrees-forget-local.test.ts @@ -52,6 +52,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-issue-command-overrides.test.ts b/src/main/ipc/worktrees-issue-command-overrides.test.ts index 00e58e49e31..ae6e89d51e1 100644 --- a/src/main/ipc/worktrees-issue-command-overrides.test.ts +++ b/src/main/ipc/worktrees-issue-command-overrides.test.ts @@ -38,6 +38,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-lineage-hydration.test.ts b/src/main/ipc/worktrees-lineage-hydration.test.ts index 156827e86b7..cb12b0095f9 100644 --- a/src/main/ipc/worktrees-lineage-hydration.test.ts +++ b/src/main/ipc/worktrees-lineage-hydration.test.ts @@ -41,6 +41,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-listing-fallback-rows.test.ts b/src/main/ipc/worktrees-listing-fallback-rows.test.ts index 5d3fa00a9dd..45bea853e67 100644 --- a/src/main/ipc/worktrees-listing-fallback-rows.test.ts +++ b/src/main/ipc/worktrees-listing-fallback-rows.test.ts @@ -36,6 +36,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-local-base-ref-resolution.test.ts b/src/main/ipc/worktrees-local-base-ref-resolution.test.ts index 20f880cdbde..130816d901c 100644 --- a/src/main/ipc/worktrees-local-base-ref-resolution.test.ts +++ b/src/main/ipc/worktrees-local-base-ref-resolution.test.ts @@ -43,6 +43,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index eb463ba6a62..570aa38c09b 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -52,6 +52,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts index 3a844f67179..954a994940d 100644 --- a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts +++ b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts @@ -49,6 +49,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts b/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts index 45f1152c61f..bd3501abbe7 100644 --- a/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts +++ b/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts @@ -42,6 +42,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index 8472309261d..cc10e1929a1 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -61,6 +61,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-remove-archive-hooks.test.ts b/src/main/ipc/worktrees-remove-archive-hooks.test.ts index fa80b5a0d51..cb1b39c8d6e 100644 --- a/src/main/ipc/worktrees-remove-archive-hooks.test.ts +++ b/src/main/ipc/worktrees-remove-archive-hooks.test.ts @@ -49,6 +49,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-remove-host-disambiguation.test.ts b/src/main/ipc/worktrees-remove-host-disambiguation.test.ts index 48af60bd6ca..289609a2b86 100644 --- a/src/main/ipc/worktrees-remove-host-disambiguation.test.ts +++ b/src/main/ipc/worktrees-remove-host-disambiguation.test.ts @@ -42,6 +42,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-remove-preflight.test.ts b/src/main/ipc/worktrees-remove-preflight.test.ts index f113641a5ef..335eeda6e90 100644 --- a/src/main/ipc/worktrees-remove-preflight.test.ts +++ b/src/main/ipc/worktrees-remove-preflight.test.ts @@ -50,6 +50,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts index e94b2ccacf4..6eb250af69a 100644 --- a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts +++ b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts @@ -44,6 +44,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts b/src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts index 0b1f93914e4..599acb1bcc7 100644 --- a/src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts @@ -40,6 +40,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts b/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts index 5d080170990..73605e2332e 100644 --- a/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts +++ b/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts @@ -39,6 +39,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts b/src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts index 84a8f5028c4..a14d7572f03 100644 --- a/src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts +++ b/src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts @@ -35,6 +35,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts index 68651186f4d..632aba29d0c 100644 --- a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts +++ b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts @@ -36,6 +36,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts b/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts index 0c4a00a4746..b739b482884 100644 --- a/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts +++ b/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts @@ -36,6 +36,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts index 96a827a3b3c..109dca653d3 100644 --- a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts +++ b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts @@ -43,6 +43,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-provider-authority.test.ts b/src/main/ipc/worktrees-ssh-provider-authority.test.ts index 25f6df7329f..c801d0498f3 100644 --- a/src/main/ipc/worktrees-ssh-provider-authority.test.ts +++ b/src/main/ipc/worktrees-ssh-provider-authority.test.ts @@ -41,6 +41,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts index 1f016f89b22..6a398e9b02d 100644 --- a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts @@ -45,6 +45,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-setup-launch.test.ts b/src/main/ipc/worktrees-ssh-setup-launch.test.ts index 8d2e39bbb0e..c43d4d7cb7c 100644 --- a/src/main/ipc/worktrees-ssh-setup-launch.test.ts +++ b/src/main/ipc/worktrees-ssh-setup-launch.test.ts @@ -43,6 +43,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-test-module-mocks.ts b/src/main/ipc/worktrees-test-module-mocks.ts index bcf19650877..50bdc6a21d7 100644 --- a/src/main/ipc/worktrees-test-module-mocks.ts +++ b/src/main/ipc/worktrees-test-module-mocks.ts @@ -183,6 +183,13 @@ export const sshModuleMock = () => ({ getActiveMultiplexer: getActiveMultiplexerMock }) +// Why a second builder: getActiveMultiplexer moved to ../ssh/ssh-target-registry so the +// runtime could reach it without ipcMain. Production imports it from there now, so a +// vi.mock('./ssh') factory alone is inert. +export const sshTargetRegistryModuleMock = () => ({ + getActiveMultiplexer: getActiveMultiplexerMock +}) + export const hooksModuleMock = () => ({ getEffectiveHooks: getEffectiveHooksMock, loadHooks: loadHooksMock, diff --git a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index e47527bc6e8..23758ae5d77 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -55,6 +55,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/jira/authenticated-request.ts b/src/main/jira/authenticated-request.ts index 6da47706476..feb54167c9c 100644 --- a/src/main/jira/authenticated-request.ts +++ b/src/main/jira/authenticated-request.ts @@ -1,5 +1,5 @@ -import { net, session } from 'electron' import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' +import { getMainHttpClient } from '../network/http-client' import { withSpan } from '../observability/tracer' import type { JiraAuthType, JiraSite } from '../../shared/jira-types' @@ -58,8 +58,10 @@ async function jiraFetch(url: string, init: RequestInit): Promise { 'jira.request', async (span) => { span.setAttribute('jira.siteUrl', new URL(url).origin) + const httpClient = getMainHttpClient() + const proxySession = httpClient.proxySession() await ensureElectronProxyFromEnvironment({ - proxySession: session.defaultSession, + ...(proxySession ? { proxySession } : {}), probeUrl: url }).catch((error) => { span.addEvent('jira.proxySetupFailed', { @@ -68,9 +70,10 @@ async function jiraFetch(url: string, init: RequestInit): Promise { }) }) try { - // Why: Electron's network stack follows Chromium proxy/session state, - // avoiding undici's stale keep-alive sockets after VPN path changes. - return await net.fetch(url, init) + // Why the port: on the desktop this is Electron's net.fetch, which follows + // Chromium proxy/session state and avoids undici's stale keep-alive sockets + // after VPN path changes. A host without Chromium gets Node's fetch instead. + return await httpClient.fetch(url, init) } catch (error) { span.setAttribute( 'jira.transportErrorName', diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index 4e77150e72b..7480a98db03 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -101,6 +101,14 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) { } } })) + // Why here and not in beforeEach: vi.resetModules() above gives the http-client module + // a fresh singleton, so the port must be installed on that instance. The electron net + // mock alone is inert now that Jira fetches through the port. + const { setMainHttpClient } = await import('../network/http-client') + setMainHttpClient({ + fetch: (url, init) => netFetchMock(url, init), + proxySession: () => ({ resolveProxy: resolveProxyMock, setProxy: setProxyMock }) as never + }) const { setSecretStore } = await import('../../shared/secret-store') setSecretStore({ isEncryptionAvailable: () => options.encryptionAvailable ?? false, diff --git a/src/main/network/http-client.ts b/src/main/network/http-client.ts new file mode 100644 index 00000000000..03dfbc18a3f --- /dev/null +++ b/src/main/network/http-client.ts @@ -0,0 +1,41 @@ +import type { Session } from 'electron' + +/** + * Outbound HTTP for main-process integrations. + * + * Why a port: the desktop uses Electron's Chromium-backed network stack — it follows + * session/proxy state, avoids undici's stale keep-alive sockets after a VPN path change, + * and sends a Chrome user agent that some APIs (Jira's XSRF check) depend on. None of + * that exists on a host with no Chromium. + * + * The Node default is the platform global. That is a real behavioural difference, not a + * transparent swap, which is why this is a named port rather than a silent fallback: + * a Node host reads proxy configuration from the environment instead of from Chromium, + * and sends Node's user agent. + * + * Body safety (orca#8695): the global uses undici, where an unread response body can + * crash the process. This port hands the Response straight to its caller and never + * inspects it, so the consume/cancel obligation stays exactly where it already was — + * with the caller, unchanged from when they called Electron's net directly. + */ + +export type MainHttpClient = { + fetch(url: string, init?: RequestInit): Promise + /** The Chromium session whose proxy state applies, or null on a host without one. */ + proxySession(): Session | null +} + +const nodeHttpClient: MainHttpClient = { + fetch: (url, init) => globalThis.fetch(url, init), + proxySession: () => null +} + +let current: MainHttpClient = nodeHttpClient + +export function setMainHttpClient(client: MainHttpClient | null): void { + current = client ?? nodeHttpClient +} + +export function getMainHttpClient(): MainHttpClient { + return current +} diff --git a/src/main/network/proxy-settings.ts b/src/main/network/proxy-settings.ts index c1f63e0ac99..6ef32f4be5c 100644 --- a/src/main/network/proxy-settings.ts +++ b/src/main/network/proxy-settings.ts @@ -1,4 +1,38 @@ -import { session } from 'electron' +import type { Session } from 'electron' + +/** + * The default proxy session, or null on a host with no Chromium. + * + * Why settable: `session.defaultSession` is the only Electron value this module needs, + * and callers already accept an explicit `options.proxySession`. Making the *default* + * injectable lets the module load under plain Node, where there is no Chromium proxy + * config to consult and the environment variables are the whole answer. + */ +let resolveDefaultProxySession: (() => Session | null) | null = null + +/** + * Why a resolver and not a Session: `session.defaultSession` throws until the Electron + * app is ready, and this is installed during pre-ready bootstrap. Passing a getter + * defers the access to first use, which is always after ready. + */ +export function setDefaultProxySessionResolver(resolve: (() => Session | null) | null): void { + resolveDefaultProxySession = resolve +} + +function defaultProxySession(): Session | null { + return resolveDefaultProxySession?.() ?? null +} + +/** Apply proxy rules only when a Chromium session exists; a Node host has none to configure. */ +async function setSessionProxyIfPresent( + proxySession: ProxySession | Session | null, + config: Parameters[1] +): Promise { + if (!proxySession) { + return + } + await setSessionProxy(proxySession as ProxySession, config) +} import { getProxyBypassRulesFromEnvironment, getProxyUrlFromEnvironment, @@ -50,8 +84,12 @@ export async function ensureElectronProxyFromEnvironment( return lastAppliedProxyConfig } - const proxySession = options.proxySession ?? session.defaultSession - const resolved = await proxySession.resolveProxy(options.probeUrl ?? PROXY_PROBE_URL) + const proxySession = options.proxySession ?? defaultProxySession() + // Why not bail: with no Chromium session there is no system proxy to discover, so the + // environment variables below are the complete answer rather than a fallback. + const resolved = proxySession + ? await proxySession.resolveProxy(options.probeUrl ?? PROXY_PROBE_URL) + : 'DIRECT' if (resolved !== 'DIRECT') { return { source: 'system' } } @@ -65,7 +103,7 @@ export async function ensureElectronProxyFromEnvironment( } const bypassRules = getProxyBypassRulesFromEnvironment(options.env ?? process.env) - await setSessionProxy(proxySession, { + await setSessionProxyIfPresent(proxySession, { mode: 'fixed_servers', proxyRules: proxy.value, ...(bypassRules ? { proxyBypassRules: bypassRules } : {}) @@ -86,20 +124,22 @@ export async function applyElectronProxySettings( probeUrl?: string } = {} ): Promise { - const proxySession = options.proxySession ?? session.defaultSession + const proxySession = options.proxySession ?? defaultProxySession() const proxy = normalizeProxyUrl(settings.httpProxyUrl) if (!proxy.ok) { return ensureElectronProxyFromEnvironment({ - proxySession, + ...(proxySession ? { proxySession } : {}), env: options.env, force: lastAppliedProxyConfig !== null, probeUrl: options.probeUrl }).then((result) => (result.source === 'none' ? { source: 'invalid-settings' } : result)) } + // Why guarded: applying proxy rules to a Chromium session is meaningless with no + // Chromium. The settings are still honoured — outbound requests read the environment. if (proxy.value) { const bypassRules = normalizeProxyBypassRules(settings.httpProxyBypassRules) - await setSessionProxy(proxySession, { + await setSessionProxyIfPresent(proxySession, { mode: 'fixed_servers', proxyRules: proxy.value, ...(bypassRules ? { proxyBypassRules: bypassRules } : {}) @@ -113,11 +153,11 @@ export async function applyElectronProxySettings( } if (lastAppliedProxyConfig !== null) { - await setSessionProxy(proxySession, { mode: 'system' }) + await setSessionProxyIfPresent(proxySession, { mode: 'system' }) lastAppliedProxyConfig = null } return ensureElectronProxyFromEnvironment({ - proxySession, + ...(proxySession ? { proxySession } : {}), env: options.env, force: true, probeUrl: options.probeUrl diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts new file mode 100644 index 00000000000..9080288cd53 --- /dev/null +++ b/src/main/orcad/orcad-entry.ts @@ -0,0 +1,118 @@ +/** + * `orcad` — the Orca runtime served from plain Node, with no Electron. + * + * Installs the Node host adapters, constructs the same `OrcaRuntimeService` the + * desktop uses, installs a PTY controller via `registerPtyHandlers(null, …)`, and + * serves runtime RPC. See docs/design/node-only-runtime-backend.html. + * + * The desktop-only surfaces are deliberately left uninstalled: no notifications, no + * renderer window, no browser panes. Those are declared rather than faked — see + * `runtime-desktop-surface.ts` and `pty-host-bindings.ts`. + */ +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' +import { setSecretStore, type SecretStore } from '../../shared/secret-store' + +/** XDG-ish data root. `$ORCA_USER_DATA` wins so a smoke test can isolate state. */ +function resolveUserDataPath(): string { + const explicit = process.env.ORCA_USER_DATA + if (explicit) { + return explicit + } + const xdg = process.env.XDG_DATA_HOME + return xdg ? join(xdg, 'Orca') : join(homedir(), '.orca') +} + +function createNodeAppEnvironment(): AppEnvironment { + const userData = resolveUserDataPath() + const quitHandlers: (() => void)[] = [] + // Why SIGTERM/SIGINT: this is the Node equivalent of electron's will-quit, and the + // runtime's teardown (daemon disconnect, PTY kill, store flush) hangs off it. + const runQuitHandlers = (): void => { + for (const handler of quitHandlers.splice(0)) { + try { + handler() + } catch (error) { + console.error('[orcad] shutdown handler failed:', error) + } + } + } + process.once('SIGTERM', () => { + runQuitHandlers() + process.exit(0) + }) + process.once('SIGINT', () => { + runQuitHandlers() + process.exit(0) + }) + return { + getPath: (name) => (name === 'home' ? homedir() : name === 'temp' ? tmpdir() : userData), + getAppPath: () => process.cwd(), + getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad', + isPackaged: () => true, + onWillQuit: (handler) => quitHandlers.push(handler), + exit: (code = 0) => process.exit(code), + // Why []: there are no Chromium processes on this host to measure. + getAppMetrics: () => [] + } +} + +/** + * Why not silently plaintext: `isEncryptionAvailable() === false` already makes every + * caller fall back to unsealed storage, which is a security posture, not a detail. + * `describeUnavailable()` gives the reason a client can surface. + */ +function createNodeSecretStore(): SecretStore { + return { + isEncryptionAvailable: () => false, + encryptString: () => { + throw new Error('orcad_secret_sealing_unavailable') + }, + decryptString: () => { + throw new Error('orcad_secret_sealing_unavailable') + }, + describeUnavailable: () => + 'This host has no OS keyring, so credentials are stored unencrypted. Pair from a desktop to manage secrets, or install and unlock a keyring.' + } +} + +export function installOrcadHostAdapters(): void { + setAppEnvironment(createNodeAppEnvironment()) + setSecretStore(createNodeSecretStore()) +} + +/** Boot the runtime and serve RPC. Returns once the transport is listening. */ +export async function startOrcad(options: { port?: number } = {}): Promise { + installOrcadHostAdapters() + + const { OrcaRuntimeService } = await import('../runtime/orca-runtime') + const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') + const { registerPtyHandlers } = await import('../ipc/pty') + const { getAppEnvironment } = await import('../../shared/app-environment') + + const runtime = new OrcaRuntimeService(null, undefined, { + // Why false: this host does not run the terminal daemon, so persistent local PTYs + // cannot be recovered. The constructor defaults this to true, which would claim a + // capability orcad does not have. + canRecoverPersistentLocalPtys: () => false, + // Why 'blocked': `'openable'` means a desktop window can be opened here, which is + // what powers serve→desktop promotion. A Node host can never do that, and the + // constructor's default would advertise it. + getDesktopWindowStatus: () => 'blocked' + }) + + // Why null: no renderer. This installs the RuntimePtyController that terminal.create + // spawns through — the whole reason this module had to stop importing electron. + registerPtyHandlers(null, runtime) + + const rpc = new OrcaRuntimeRpcServer({ + runtime, + userDataPath: getAppEnvironment().getPath('userData'), + enableWebSocket: true, + exposeNetworkByDefault: true, + ...(options.port !== undefined ? { wsPort: options.port, preferPinnedWsPort: true } : {}) + } as never) + await rpc.start() +} diff --git a/src/main/plugins/plugin-client-list.ts b/src/main/plugins/plugin-client-list.ts new file mode 100644 index 00000000000..fe3026c2dec --- /dev/null +++ b/src/main/plugins/plugin-client-list.ts @@ -0,0 +1,17 @@ +import { getUserPluginsDir } from './plugin-discovery' +import { readPluginLockfile } from './plugin-install' +import { buildPluginList, type PluginListEntry } from './plugin-list-projection' +import type { PluginService } from './plugin-service' + +/** + * The plugin list paired clients see. Split out of `ipc/plugins.ts` so the runtime's + * `plugins.list` RPC can reach it without dragging `ipcMain` into its module graph — + * the same reason preflight and the SSH registry moved. + */ +export async function listPluginsForClients( + pluginService: PluginService +): Promise { + await pluginService.whenReady() + const lock = await readPluginLockfile(getUserPluginsDir(pluginService.options.userDataPath)) + return buildPluginList(pluginService, lock) +} diff --git a/src/main/ports/port-scan-command-client.ts b/src/main/ports/port-scan-command-client.ts index 9748a8cfc2b..c2bc80bc883 100644 --- a/src/main/ports/port-scan-command-client.ts +++ b/src/main/ports/port-scan-command-client.ts @@ -1,4 +1,5 @@ import { existsSync } from 'node:fs' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { Worker } from 'node:worker_threads' import { @@ -16,9 +17,9 @@ import { // the duplicated ~150 lines are cheaper than a premature shared abstraction, so // a third adopter should extract one. // -// This module contains the literal text require('electron'), so it must never -// become reachable from a plain-Node fork entry (build-plugins/ -// plain-node-entry-guard.ts fails the build on that text, try/catch or not). +// This module used to contain the literal text require('electron'), which fails the +// plain-Node entry guard even inside a try/catch. It reads the AppEnvironment port +// instead, so it is now safe to reach from a fork entry. // Why: the worker's own loop absorbs the spawn stall, so the client only needs // a backstop for a wedged thread. Kept at 30s because a scan sits on the @@ -324,14 +325,8 @@ export function resolveWorkerEntryPath(layout: WorkerEntryLayout): string { } function currentWorkerEntryLayout(): WorkerEntryLayout { - let app: { isPackaged: boolean } | null = null - try { - app = require('electron').app ?? null - } catch { - app = null - } return { - isPackaged: app?.isPackaged === true, + isPackaged: hasAppEnvironment() && getAppEnvironment().isPackaged(), resourcesPath: process.resourcesPath, moduleDir: __dirname } diff --git a/src/main/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts new file mode 100644 index 00000000000..700941de34f --- /dev/null +++ b/src/main/preflight/agent-detection.ts @@ -0,0 +1,304 @@ +/** + * Agent/tool preflight detection. Split out of `ipc/preflight.ts` so the Orca + * runtime — which calls `detectInstalledAgentsWithShellPathHydration` and + * `detectRemoteAgents` during normal operation — can reach this logic without + * dragging `ipcMain` into its module graph. The Electron handler registration + * stays in `ipc/preflight.ts` and imports from here. + */ +import type { + PathSource, + ShellHydrationFailureReason +} from '../../shared/shell-path-hydration-types' +import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' +import { getAzureDevOpsAuthStatus } from '../azure-devops/client' +import { getBitbucketAuthStatus } from '../bitbucket/client' +import { getGiteaAuthStatus } from '../gitea/client' +import { _resetKnownHostsCache } from '../gitlab/gl-utils' +import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' +import { + detectWslCommandsOnPath, + type WslPreflightTarget +} from '../ipc/preflight-wsl-agent-detection' +import { detectCommandsInInstallDirs } from '../ipc/local-agent-install-dir-detection' +import { + getPreflightWslTarget, + type PreflightRuntimeContext +} from '../ipc/preflight-runtime-target' + +export type { PreflightRuntimeContext } +import { hydrateShellPathForAgentDetection } from '../ipc/agent-detection-shell-path' +import { + execCommandInWsl, + execLocalPreflightCommand, + isCommandAvailable, + isCommandOnPath, + shellQuote +} from '../ipc/preflight-command-exec' +import { + detectRemoteWindowsTerminalCapabilities, + type RemoteWindowsTerminalCapabilities +} from '../ipc/preflight-remote-windows-terminal-capabilities' +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgentIds +} from '../ipc/tui-agent-detection-commands' +import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment' + +export type PreflightStatus = { + git: { installed: boolean } + gh: { installed: boolean; authenticated: boolean } + // Why: optional so existing renderer call sites that only render git/gh + // status keep typechecking. Consumers that surface GitLab-specific + // affordances (the GitLab tab in the source picker, MR list, etc.) + // gate on `glab?.authenticated`. + glab?: { installed: boolean; authenticated: boolean } + bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } + azureDevOps?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } + gitea?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } +} + +export { detectRemoteWindowsTerminalCapabilities } +export type { RemoteWindowsTerminalCapabilities } + +// Why: cache the result so repeated Landing mounts don't re-spawn processes. +// The check only runs once per app session — relaunch to re-check. +let cached: PreflightStatus | null = null + +/** @internal - tests need a clean preflight cache between cases. */ +export function _resetPreflightCache(): void { + cached = null +} + +function uniqueAgentIds(ids: Iterable): string[] { + return [...new Set(ids)] +} + +async function detectCommandRuntime( + command: string, + context?: PreflightRuntimeContext +): Promise<{ installed: boolean; wslTarget?: WslPreflightTarget }> { + const wslTarget = getPreflightWslTarget(context) + if (wslTarget) { + return (await isCommandAvailable(command, wslTarget)) + ? { installed: true, wslTarget } + : { installed: false } + } + if (await isCommandAvailable(command)) { + return { installed: true } + } + return { installed: false } +} + +export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { + const wslTarget = getPreflightWslTarget(context) + if (wslTarget) { + const foundCommands = await detectWslCommandsOnPath( + wslTarget, + getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, 'wsl') + ) + return resolveDetectedTuiAgentIds(KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, 'wsl') + } + + const probeCommands = getTuiAgentDetectionProbeCommands( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + process.platform + ) + const pathChecks = await Promise.all( + probeCommands.map(async (cmd) => ({ + cmd, + installedOnPath: await isCommandOnPath(cmd) + })) + ) + const missedCommands = pathChecks.filter((check) => !check.installedOnPath).map(({ cmd }) => cmd) + // Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution + // computes user install dirs once instead of blocking once per missed CLI. + const installDirCommands = detectCommandsInInstallDirs(missedCommands) + const foundCommands = new Set( + pathChecks + .filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd)) + .map(({ cmd }) => cmd) + ) + return resolveDetectedTuiAgentIds( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + foundCommands, + process.platform + ) +} + +export async function detectInstalledAgentsWithShellPathHydration( + context?: PreflightRuntimeContext +): Promise { + await hydrateShellPathForAgentDetection(context) + return detectInstalledAgents(context) +} + +export type RefreshAgentsResult = { + /** Agents detected after hydrating PATH from the user's login shell. */ + agents: string[] + /** PATH segments that were added this refresh (empty if nothing new). */ + addedPathSegments: string[] + /** True when the shell spawn succeeded. False = relied on existing PATH. */ + shellHydrationOk: boolean + /** Whether `detectInstalledAgents` ran against shell-hydrated PATH or only + * the seed list from `patchPackagedProcessPath`. Drives the on_path:false + * triage in tile A on dashboard 1562016. */ + pathSource: PathSource + /** Why hydration failed (or `'none'` on success). Typed against the shared + * alias so the IPC boundary stays in lockstep with the renderer-visible + * enum on `onboardingAgentPickedSchema`. */ + pathFailureReason: ShellHydrationFailureReason +} + +/** + * Re-spawn the user's login shell to refresh process.env.PATH, then re-run + * agent detection. Called by the Agents settings pane when the user clicks + * Refresh — handles the "installed a new CLI, Orca doesn't see it yet" case + * without requiring an app restart. + */ +export async function refreshShellPathAndDetectAgents( + context?: PreflightRuntimeContext +): Promise { + const wslTarget = getPreflightWslTarget(context) + if (wslTarget) { + // Why invalidate first: the guest PATH is cached per distro for the process + // lifetime, so Refresh would otherwise re-read the pre-install PATH and + // keep reporting a just-installed CLI as absent -- the exact case this + // function exists to handle. + invalidateWslGuestEnvironment(wslTarget.distro) + const agents = await detectInstalledAgents(context) + return { + agents, + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'sync_seed_only', + pathFailureReason: 'none' + } + } + + const hydration = await hydrateShellPath({ force: true }) + const added = hydration.ok ? mergePathSegments(hydration.segments) : [] + const agents = await detectInstalledAgents(context) + return { + agents, + addedPathSegments: added, + shellHydrationOk: hydration.ok, + pathSource: hydration.ok ? 'shell_hydrate' : 'sync_seed_only', + pathFailureReason: hydration.failureReason + } +} + +export async function detectRemoteAgents(args: { connectionId: string }): Promise { + const mux = getActiveMultiplexer(args.connectionId) + if (!mux || mux.isDisposed()) { + // Why: remote agent detection is passive UI polling. A disconnected host has + // no detectable agents until reconnect, but should not spam IPC errors. + return [] + } + const result = (await mux.request('preflight.detectAgents', { + commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS + })) as { agents: string[] } + return uniqueAgentIds(result.agents) +} + +async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise { + try { + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote('gh')} auth status`) + : execLocalPreflightCommand('gh', ['auth', 'status'])) + // Why: for plain-text `gh auth status`, exit 0 means gh did not detect any + // authentication issues for the checked hosts/accounts. + return true + } catch (error) { + // Why: some environments may surface partial command output on the thrown + // error object. Keep a compatibility fallback so we avoid a false auth + // warning if success markers are present despite a non-zero result. + const stdout = (error as { stdout?: string }).stdout ?? '' + const stderr = (error as { stderr?: string }).stderr ?? '' + const output = `${stdout}\n${stderr}` + return output.includes('Logged in') || output.includes('Active account: true') + } +} + +// Why: parallel to isGhAuthenticated for the glab CLI. glab writes auth +// status to stderr in some versions and stdout in others; check both. +async function isGlabAuthenticated(wslTarget?: WslPreflightTarget): Promise { + try { + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote('glab')} auth status`) + : execLocalPreflightCommand('glab', ['auth', 'status'])) + return true + } catch (error) { + const stdout = (error as { stdout?: string }).stdout ?? '' + const stderr = (error as { stderr?: string }).stderr ?? '' + const output = `${stdout}\n${stderr}` + return output.includes('Logged in') + } +} + +export async function runPreflightCheck( + force = false, + context?: PreflightRuntimeContext +): Promise { + const wslTarget = getPreflightWslTarget(context) + const cacheable = !wslTarget + if (cacheable && cached && !force) { + return cached + } + + if (process.platform === 'win32' && !wslTarget) { + await mergePersistedWindowsPathAsync(process.env, { forceRefresh: force }) + } + + if (force) { + // Why: the GitLab known-hosts cache (gl-utils) is populated lazily on the + // first GitLab request and never invalidated within a session. A user who + // runs `glab auth login` for a self-hosted host after Orca starts would + // otherwise see "No GitLab project found" until app relaunch. The Re-check + // path in IntegrationsPane forces preflight, so piggyback on that signal + // to refresh the host list too. + _resetKnownHostsCache() + } + + const [gitProbe, ghProbe, glabProbe] = await Promise.all([ + detectCommandRuntime('git', context), + detectCommandRuntime('gh', context), + detectCommandRuntime('glab', context) + ]) + + const [ghAuthenticated, glabAuthenticated, bitbucket, azureDevOps, gitea] = await Promise.all([ + ghProbe.installed ? isGhAuthenticated(ghProbe.wslTarget) : Promise.resolve(false), + glabProbe.installed ? isGlabAuthenticated(glabProbe.wslTarget) : Promise.resolve(false), + getBitbucketAuthStatus(), + getAzureDevOpsAuthStatus(), + getGiteaAuthStatus() + ]) + + const result = { + git: { installed: gitProbe.installed }, + gh: { installed: ghProbe.installed, authenticated: ghAuthenticated }, + glab: { installed: glabProbe.installed, authenticated: glabAuthenticated }, + bitbucket, + azureDevOps, + gitea + } + + if (cacheable) { + cached = result + } + + return result +} diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 2ba058ffd9c..cf2d9979a88 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { setPtyHostBindings } from '../ipc/pty-host-bindings' const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -82,6 +83,16 @@ describe('PTY provider dispatch', () => { onMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { handlers.set(channel, handler) }) + // Why: pty.ts registers against an injected surface now, so the mocked ipcMain must + // be installed for this suite's own `handlers` map to capture registrations. + setPtyHostBindings({ + ipc: { + handle: handleMock, + on: onMock, + removeHandler: removeHandlerMock, + removeAllListeners: removeAllListenersMock + } + }) registerPtyHandlers(mainWindow as never) } diff --git a/src/main/remote-agent-trust-presets.test.ts b/src/main/remote-agent-trust-presets.test.ts index 226d689a537..d1ff9ee24ca 100644 --- a/src/main/remote-agent-trust-presets.test.ts +++ b/src/main/remote-agent-trust-presets.test.ts @@ -5,7 +5,7 @@ const mocks = vi.hoisted(() => ({ getSshFilesystemProvider: vi.fn() })) -vi.mock('./ipc/ssh', () => ({ +vi.mock('./ssh/ssh-target-registry', () => ({ getActiveMultiplexer: mocks.getActiveMultiplexer })) diff --git a/src/main/remote-agent-trust-presets.ts b/src/main/remote-agent-trust-presets.ts index a88aaceae58..b5eaaf6d2c0 100644 --- a/src/main/remote-agent-trust-presets.ts +++ b/src/main/remote-agent-trust-presets.ts @@ -1,6 +1,6 @@ import type { AgentTrustPreset } from './agent-trust-presets' import { upsertProjectTrustLevelInContent } from './codex/config-toml-trust' -import { getActiveMultiplexer } from './ipc/ssh' +import { getActiveMultiplexer } from './ssh/ssh-target-registry' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import type { IFilesystemProvider } from './providers/types' import { diff --git a/src/main/runtime/orca-runtime-agent-skill-share.test.ts b/src/main/runtime/orca-runtime-agent-skill-share.test.ts index 4d8c3e42cca..e7b643b2019 100644 --- a/src/main/runtime/orca-runtime-agent-skill-share.test.ts +++ b/src/main/runtime/orca-runtime-agent-skill-share.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import type { AgentSkillShareRequest } from '../../shared/agent-skill-sharing-contract' import { getDefaultSettings } from '../../shared/constants' import type { DiscoveredSkill } from '../../shared/skills' @@ -120,6 +121,7 @@ function runtimeWithCloud(options: { } beforeEach(async () => { + installFakeAppEnvironment({ getPath: () => mocks.userDataPath }) testRoot = await mkdtemp(join(tmpdir(), 'orca-agent-skill-share-')) mocks.userDataPath = testRoot }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index fa9041d0ae5..d1eaee981a0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1,5 +1,9 @@ /* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */ import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { RuntimeBrowserCommands } from './orca-runtime-browser' +import { setRuntimeBrowserCommandsFactory } from './runtime-browser-commands-factory' +import { setRuntimeDesktopSurface } from './runtime-desktop-surface' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import type * as GitUsernameModule from '../git/git-username' import { performance } from 'node:perf_hooks' import { EventEmitter } from 'node:events' @@ -456,12 +460,12 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ unregisterSshGitProvider: unregisterSshGitProviderMock })) -vi.mock('../ipc/ssh', () => ({ +vi.mock('../ssh/ssh-target-registry', () => ({ getActiveMultiplexer: getActiveMultiplexerMock, getRegisteredSshState: () => ({ remotePlatform: 'linux' }) })) -vi.mock('../ipc/preflight', () => ({ +vi.mock('../preflight/agent-detection', () => ({ detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock })) @@ -675,8 +679,29 @@ vi.mock('../git/git-username', async () => { }) function resetRuntimeTestMocks(): void { + // Why: constructing the browser commands is what pulls the Chromium cluster in, so + // production installs this at the Electron entry. A Node host installs none and the + // browser RPCs reject rather than silently succeeding. + setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host)) + // Why: the runtime's notification, window lookup and tab-create-reply channel are + // injected now, so the electron mock alone is inert. Back the surface with the same + // mocks so every existing expectation still holds. + setRuntimeDesktopSurface({ + showNotification: () => true, + findWindowById: (id) => electronMocks.BrowserWindow.fromId(id) as never, + onIpc: (channel, listener) => electronMocks.ipcMain.on(channel, listener as never), + removeIpcListener: (channel, listener) => + electronMocks.ipcMain.removeListener(channel, listener as never) + }) resetPlatform() electronMocks.app.isPackaged = false + // Why here and not the electron mock: the runtime reads paths and the packaged flag + // through the AppEnvironment port now, so the electron mock alone is inert. Reading + // electronMocks.app keeps the existing per-test toggles below working unchanged. + installFakeAppEnvironment({ + getPath: () => electronMocks.app.getPath(), + isPackaged: () => electronMocks.app.isPackaged + }) clearConfiguredWorktreeSharedDirectoriesCacheForTests() _resetTerminalViewAttributesForTest() advertisedUrlWatcher.clear() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index acba1a26aca..03ba37f0d98 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -371,7 +371,7 @@ import { type ExecutionHostId } from '../../shared/execution-host' import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' -import { getRegisteredSshState } from '../ipc/ssh' +import { getRegisteredSshState } from '../ssh/ssh-target-registry' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -570,7 +570,10 @@ import { } from '../../shared/tui-agent-config' import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout' import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' -import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight' +import { + detectInstalledAgentsWithShellPathHydration, + detectRemoteAgents +} from '../preflight/agent-detection' import { markCodexProjectTrusted, markCopilotFolderTrusted, @@ -653,7 +656,8 @@ import { } from '../ports/workspace-port-ownership' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import type { AutomationService } from '../automations/service' -import { RuntimeBrowserCommands } from './orca-runtime-browser' +import type { RuntimeBrowserCommands } from './orca-runtime-browser' +import { createRuntimeBrowserCommands } from './runtime-browser-commands-factory' import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency' import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity' import { @@ -727,11 +731,13 @@ import { } from '../../shared/claude-agent-teams-tmux-compat' import { joinWorktreeRelativePath } from './runtime-relative-paths' import { collectMemorySnapshot } from '../memory/collector' -import { app, BrowserWindow, ipcMain, Notification } from 'electron' +import type { BrowserWindow } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' +import { getRuntimeDesktopSurface } from './runtime-desktop-surface' import { RendererPublicationThrottle } from '../window/renderer-publication-throttle' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { BrowserBackend } from '../browser/browser-backend' -import { BrowserError } from '../browser/cdp-bridge' +import { BrowserError } from '../browser/browser-error' import { getPRForBranch, getPRForBranchOutcome, @@ -3921,7 +3927,7 @@ export class OrcaRuntimeService { return } await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, { - shouldHydrateShellPath: app.isPackaged, + shouldHydrateShellPath: getAppEnvironment().isPackaged(), onInstallError: recordManagedHookInstallFailure, shouldContinue: (agent) => { const current = this.store?.getSettings() @@ -4229,13 +4235,12 @@ export class OrcaRuntimeService { return { projectId, workspaceMode: 'new_per_run', workspaceId: null } } - // Why: lazy initialization — the DB path depends on Electron's userData - // which may not be finalized until after app.ready. Also allows unit tests - // to inject an in-memory DB without touching the filesystem. + // Why: lazy initialization — the DB path depends on userData, which on the desktop + // is not finalized until after app.ready. Also allows unit tests to inject an + // in-memory DB without touching the filesystem. getOrchestrationDb(): OrchestrationDb { if (!this._orchestrationDb) { - const { app } = require('electron') - const dbPath = join(app.getPath('userData'), 'orchestration.db') + const dbPath = join(getAppEnvironment().getPath('userData'), 'orchestration.db') this._orchestrationDb = new OrchestrationDb(dbPath) this.ensureOrchestrationFederationRelay() this.scheduleRestoredMessageRepoints() @@ -4996,7 +5001,10 @@ export class OrcaRuntimeService { signal?: AbortSignal ): Promise { const selectedSkills = selectDiscoveredSkills(discoveredSkills, request.skillSelectors) - const operationRoot = join(app.getPath('userData'), 'agent-skill-share-operations') + const operationRoot = join( + getAppEnvironment().getPath('userData'), + 'agent-skill-share-operations' + ) const cloud = this.requireSkillCloudService() const preparations = new SkillSharePreparationService( operationRoot, @@ -5005,7 +5013,7 @@ export class OrcaRuntimeService { createShare: (packageId, input) => cloud.createShare(packageId, input) }, { - installStateDirectory: join(app.getPath('userData'), 'skill-installs') + installStateDirectory: join(getAppEnvironment().getPath('userData'), 'skill-installs') } ) let preparationId: string | null = null @@ -5212,7 +5220,7 @@ export class OrcaRuntimeService { if (sshTarget) { return installSkillBundleOnSshHost({ provider: sshTarget.provider, - userDataPath: app.getPath('userData'), + userDataPath: getAppEnvironment().getPath('userData'), request: { ...request, destination: @@ -5221,14 +5229,14 @@ export class OrcaRuntimeService { : request.destination }, workspace: sshTarget.workspace, - requireHttps: app.isPackaged, + requireHttps: getAppEnvironment().isPackaged(), signal: controller.signal, onProgress: reportProgress }) } await this.skillTransactionRecovery const allowedDownloadOrigins = ['https://storage.googleapis.com'] - if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { + if (!getAppEnvironment().isPackaged() && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { allowedDownloadOrigins.push( ...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',') .map((origin) => origin.trim()) @@ -5237,9 +5245,9 @@ export class OrcaRuntimeService { } return await executeSkillBundleInstallRequest(request, { authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: app.getPath('userData'), + stateDirectory: getAppEnvironment().getPath('userData'), allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)], - requireHttps: app.isPackaged, + requireHttps: getAppEnvironment().isPackaged(), resolveStagedUpload: (uploadId, identity) => this.requireSkillUploadSessions().take(uploadId, identity), detectProviders: detectInstalledAgentsWithShellPathHydration, @@ -5276,7 +5284,7 @@ export class OrcaRuntimeService { if (sshTarget) { return installSkillOnSshHost({ provider: sshTarget.provider, - userDataPath: app.getPath('userData'), + userDataPath: getAppEnvironment().getPath('userData'), request: { ...request, destination: @@ -5285,13 +5293,13 @@ export class OrcaRuntimeService { : request.destination }, workspace: sshTarget.workspace, - requireHttps: app.isPackaged, + requireHttps: getAppEnvironment().isPackaged(), signal }) } await this.skillTransactionRecovery const allowedDownloadOrigins = ['https://storage.googleapis.com'] - if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { + if (!getAppEnvironment().isPackaged() && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { allowedDownloadOrigins.push( ...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',') .map((origin) => origin.trim()) @@ -5300,9 +5308,9 @@ export class OrcaRuntimeService { } return executeSkillInstallRequest(request, { authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: app.getPath('userData'), + stateDirectory: getAppEnvironment().getPath('userData'), allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)], - requireHttps: app.isPackaged, + requireHttps: getAppEnvironment().isPackaged(), resolveStagedUpload: (uploadId, identity) => this.requireSkillUploadSessions().take(uploadId, identity), detectProviders: detectInstalledAgentsWithShellPathHydration, @@ -5333,7 +5341,7 @@ export class OrcaRuntimeService { await this.skillTransactionRecovery return previewSharedSkillInstall(request, { authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: app.getPath('userData'), + stateDirectory: getAppEnvironment().getPath('userData'), detectProviders: detectInstalledAgentsWithShellPathHydration, resolveProviderRootOverrides: (destination) => this.resolveSkillProviderRootOverrides(destination) @@ -5361,7 +5369,7 @@ export class OrcaRuntimeService { const runtimeId = this.getStatus().runtimeId return previewSharedSkillBundleInstall(request, { authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: app.getPath('userData'), + stateDirectory: getAppEnvironment().getPath('userData'), detectProviders: detectInstalledAgentsWithShellPathHydration, resolveProviderRootOverrides: (destination) => this.resolveSkillProviderRootOverrides(destination) @@ -5387,7 +5395,7 @@ export class OrcaRuntimeService { await this.skillTransactionRecovery return removeSharedSkillInstall(request, { authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: app.getPath('userData'), + stateDirectory: getAppEnvironment().getPath('userData'), detectProviders: detectInstalledAgentsWithShellPathHydration, resolveProviderRootOverrides: (destination) => this.resolveSkillProviderRootOverrides(destination) @@ -5406,7 +5414,7 @@ export class OrcaRuntimeService { await this.skillTransactionRecovery const runtimeId = this.getStatus().runtimeId const [installs, worktrees] = await Promise.all([ - listManagedSkillInstalls(join(app.getPath('userData'), 'skill-installs'), { + listManagedSkillInstalls(join(getAppEnvironment().getPath('userData'), 'skill-installs'), { observeReceipt: async (receipt) => { if (!receipt.wslDistro) { return nativeSkillInstallFilesystem.observeSkill( @@ -5702,7 +5710,11 @@ export class OrcaRuntimeService { throw new Error('skill-upload-service-disposed') } this.skillUploadSessions ??= new SkillUploadSessionService( - join(app.getPath('userData'), 'skill-installs', SKILL_UPLOAD_STAGING_ROOT_NAME) + join( + getAppEnvironment().getPath('userData'), + 'skill-installs', + SKILL_UPLOAD_STAGING_ROOT_NAME + ) ) return this.skillUploadSessions } @@ -14352,13 +14364,9 @@ export class OrcaRuntimeService { const body = input.body ?? '' let delivered = false try { - if (Notification.isSupported()) { - new Notification({ title, body }).show() - delivered = true - } + delivered = getRuntimeDesktopSurface().showNotification({ title, body }) } catch { - // Headless serve has no notification display; the mobile relay below - // still runs. + // A host with no notification display still relays to paired clients below. } this.dispatchMobileNotification({ type: 'notification', source: 'plugin', title, body }) return { delivered } @@ -28390,7 +28398,7 @@ export class OrcaRuntimeService { // creates the tab and replies with the tabId so we can resolve the handle. const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { const timer = setTimeout(() => { - ipcMain.removeListener('terminal:tabCreateReply', handler) + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) reject(new Error('Terminal creation timed out')) }, 10_000) @@ -28402,14 +28410,14 @@ export class OrcaRuntimeService { return } clearTimeout(timer) - ipcMain.removeListener('terminal:tabCreateReply', handler) + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) if (r.error) { reject(new Error(r.error)) } else { resolve({ tabId: r.tabId!, title: r.title ?? launchOpts.title ?? '' }) } } - ipcMain.on('terminal:tabCreateReply', handler) + getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler) win.webContents.send('terminal:requestTabCreate', { requestId, worktreeId, @@ -28761,7 +28769,7 @@ export class OrcaRuntimeService { const requestId = randomUUID() const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { const timer = setTimeout(() => { - ipcMain.removeListener('terminal:tabCreateReply', handler) + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) opts.signal?.removeEventListener('abort', onAbort) reject(new Error('Terminal creation timed out')) }, 10_000) @@ -28769,7 +28777,7 @@ export class OrcaRuntimeService { // its shell) stays alive for the host and mirrors on reconnect (#7718). const onAbort = (): void => { clearTimeout(timer) - ipcMain.removeListener('terminal:tabCreateReply', handler) + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) reject(new Error('client_disconnected')) } @@ -28781,7 +28789,7 @@ export class OrcaRuntimeService { return } clearTimeout(timer) - ipcMain.removeListener('terminal:tabCreateReply', handler) + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) opts.signal?.removeEventListener('abort', onAbort) if (r.error) { reject(new Error(r.error)) @@ -28790,7 +28798,7 @@ export class OrcaRuntimeService { } } opts.signal?.addEventListener('abort', onAbort, { once: true }) - ipcMain.on('terminal:tabCreateReply', handler) + getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler) win.webContents.send('terminal:requestTabCreate', { requestId, worktreeId, @@ -38095,7 +38103,7 @@ export class OrcaRuntimeService { // ── Browser automation ── - private readonly browserCommands = new RuntimeBrowserCommands({ + private readonly browserCommands = createRuntimeBrowserCommands({ getAgentBrowserBridge: () => this.agentBrowserBridge, resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), getAuthoritativeWindow: () => this.getAuthoritativeWindow(), @@ -38572,10 +38580,7 @@ export class OrcaRuntimeService { if (this.authoritativeWindowId === null) { return null } - if (!BrowserWindow?.fromId) { - return null - } - const win = BrowserWindow.fromId(this.authoritativeWindowId) + const win = getRuntimeDesktopSurface().findWindowById(this.authoritativeWindowId) return win && !win.isDestroyed() ? win : null } } diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts index 81ee32d1988..0c3a079262f 100644 --- a/src/main/runtime/rpc/methods/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ipc/ssh' +import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ssh/ssh-target-registry' import { getPublicSshState } from '../../public-ssh-state' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' diff --git a/src/main/runtime/rpc/methods/plugins.ts b/src/main/runtime/rpc/methods/plugins.ts index f463feff1cf..bb035b984eb 100644 --- a/src/main/runtime/rpc/methods/plugins.ts +++ b/src/main/runtime/rpc/methods/plugins.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { defineMethod, type RpcContext, type RpcMethod } from '../core' import type { PluginPanelEntry } from '../../../../shared/plugins/plugin-panel-bridge' -import { listPluginsForClients } from '../../../ipc/plugins' +import { listPluginsForClients } from '../../../plugins/plugin-client-list' import type { PluginListEntry } from '../../../plugins/plugin-list-projection' import type { PluginService } from '../../../plugins/plugin-service' import { diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index e8f1ef82930..5b453f9ce40 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -18,7 +18,7 @@ const { runPreflightCheckMock: vi.fn() })) -vi.mock('../../../ipc/preflight', () => ({ +vi.mock('../../../preflight/agent-detection', () => ({ detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock, detectRemoteWindowsTerminalCapabilities: detectRemoteWindowsTerminalCapabilitiesMock, diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index aafbb96319c..f863a1941d1 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -6,7 +6,7 @@ import { detectInstalledAgentsWithShellPathHydration, refreshShellPathAndDetectAgents, runPreflightCheck -} from '../../../ipc/preflight' +} from '../../../preflight/agent-detection' const PreflightCheck = z.object({ force: z.boolean().optional() diff --git a/src/main/runtime/rpc/methods/ssh.test.ts b/src/main/runtime/rpc/methods/ssh.test.ts index d19f5f85a53..450375e48f8 100644 --- a/src/main/runtime/rpc/methods/ssh.test.ts +++ b/src/main/runtime/rpc/methods/ssh.test.ts @@ -16,7 +16,7 @@ const { listRegisteredRemovedSshTargetLabelsMock: vi.fn() })) -vi.mock('../../../ipc/ssh', () => ({ +vi.mock('../../../ssh/ssh-target-registry', () => ({ connectRegisteredSshTarget: connectRegisteredSshTargetMock, getRegisteredSshState: getRegisteredSshStateMock, listRegisteredSshTargets: listRegisteredSshTargetsMock, diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index 0e970af6864..41691dc6ae9 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -4,7 +4,7 @@ import { getRegisteredSshState, listRegisteredRemovedSshTargetLabels, listRegisteredSshTargets -} from '../../../ipc/ssh' +} from '../../../ssh/ssh-target-registry' import { defineMethod, type RpcMethod } from '../core' import { getPublicSshError, getPublicSshState } from '../../public-ssh-state' diff --git a/src/main/runtime/runtime-browser-commands-factory.ts b/src/main/runtime/runtime-browser-commands-factory.ts new file mode 100644 index 00000000000..99e321aa310 --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-factory.ts @@ -0,0 +1,53 @@ +import type { RuntimeBrowserCommandHost, RuntimeBrowserCommands } from './orca-runtime-browser' + +/** + * How `OrcaRuntimeService` obtains its browser-automation commands. + * + * Why a factory rather than a direct import: `orca-runtime-browser.ts` reaches the + * whole Chromium cluster — `BrowserWindow`, `session`, `webContents`, cookie jars — + * 15 modules that a Node host cannot load at all. Importing the class for its *type* + * is free; constructing it is what drags the cluster in. + * + * The desktop installs the real factory. A Node host installs none and every browser + * RPC rejects with `browser_unavailable`, which the runtime already advertises through + * capability filtering — clients do not offer the affordance. + * + * Deliberately NOT a stub object with silently-succeeding methods: that is the + * "looks fine, returns a lie" shape this codebase rejects. Absent means rejected. + */ + +export type RuntimeBrowserCommandsFactory = ( + host: RuntimeBrowserCommandHost +) => RuntimeBrowserCommands + +let currentFactory: RuntimeBrowserCommandsFactory | null = null + +export function setRuntimeBrowserCommandsFactory( + factory: RuntimeBrowserCommandsFactory | null +): void { + currentFactory = factory +} + +/** + * Build the commands, or a rejecting proxy when this host has no browser. The proxy + * throws per call rather than at construction so the runtime still starts — the + * capability is simply not advertised. + */ +export function createRuntimeBrowserCommands( + host: RuntimeBrowserCommandHost +): RuntimeBrowserCommands { + if (currentFactory) { + return currentFactory(host) + } + return new Proxy({} as RuntimeBrowserCommands, { + get: (_target, property) => { + if (property === 'then') { + // Why: an awaited undefined must not look like a thenable. + return undefined + } + return () => { + throw new Error(`browser_unavailable: ${String(property)} needs a desktop host`) + } + } + }) +} diff --git a/src/main/runtime/runtime-desktop-surface.ts b/src/main/runtime/runtime-desktop-surface.ts new file mode 100644 index 00000000000..ac1086e4f35 --- /dev/null +++ b/src/main/runtime/runtime-desktop-surface.ts @@ -0,0 +1,42 @@ +import type { BrowserWindow, IpcMainEvent } from 'electron' + +/** + * The desktop facilities `OrcaRuntimeService` uses, which a Node host does not have. + * + * Three sites, all optional by nature: a native notification toast, a lookup of the + * authoritative renderer window, and one ipcMain channel used only by the + * renderer-backed tab-create fallback. With no renderer that fallback is unreachable — + * `createTerminal` already takes the background spawn branch when there is no + * authoritative window (#10333) — so a Node host needs none of them. + * + * Defaults are inert rather than throwing, for the same reason as the PTY bindings: a + * host with no desktop legitimately has nothing here, and that is not a downgrade. + * Where absence IS user-visible — a notification that would have been shown — the + * runtime already routes to paired clients, which is the better destination anyway. + */ + +export type RuntimeDesktopSurface = { + /** Show a native notification. Returns false when the host cannot, so callers can say so. */ + showNotification(input: { title: string; body: string }): boolean + /** The renderer window with this id, or null when there is no desktop. */ + findWindowById(id: number): BrowserWindow | null + onIpc(channel: string, listener: (event: IpcMainEvent, ...args: never[]) => void): void + removeIpcListener(channel: string, listener: (...args: never[]) => void): void +} + +const inertDesktopSurface: RuntimeDesktopSurface = { + showNotification: () => false, + findWindowById: () => null, + onIpc: () => {}, + removeIpcListener: () => {} +} + +let current: RuntimeDesktopSurface = inertDesktopSurface + +export function setRuntimeDesktopSurface(surface: RuntimeDesktopSurface | null): void { + current = surface ?? inertDesktopSurface +} + +export function getRuntimeDesktopSurface(): RuntimeDesktopSurface { + return current +} diff --git a/src/main/speech/stt-service.ts b/src/main/speech/stt-service.ts index e8b8d5fadb4..7903c663c28 100644 --- a/src/main/speech/stt-service.ts +++ b/src/main/speech/stt-service.ts @@ -3,7 +3,7 @@ timeout teardown must stay co-located so dictation lifecycle state cannot drift. import { Worker } from 'node:worker_threads' import { existsSync } from 'node:fs' import { join } from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { getCatalogModel } from './model-catalog' import type { ModelManager } from './model-manager' import { OpenAiTranscriptionSession } from './openai-transcription-client' @@ -482,7 +482,7 @@ export class SttService { } private getWorkerPath(): string { - if (app.isPackaged) { + if (getAppEnvironment().isPackaged()) { return join(process.resourcesPath, 'app.asar', 'out', 'main', 'stt-worker.js') } return join(__dirname, 'stt-worker.js') @@ -564,7 +564,7 @@ export class SttService { ? 'sherpa-onnx-win-x64' : `sherpa-onnx-${process.platform}-${process.arch}` - if (app.isPackaged) { + if (getAppEnvironment().isPackaged()) { const resourcesNodeModule = join(process.resourcesPath, 'node_modules', nativePkg) if (existsSync(resourcesNodeModule)) { return resourcesNodeModule diff --git a/src/main/ssh/ssh-target-registry.ts b/src/main/ssh/ssh-target-registry.ts new file mode 100644 index 00000000000..af71581af42 --- /dev/null +++ b/src/main/ssh/ssh-target-registry.ts @@ -0,0 +1,89 @@ +import type { SshConnectionStore } from './ssh-connection-store' +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' + +/** + * The SSH target/state registry, split out of `ipc/ssh.ts`. + * + * Why: the Orca runtime reads registered SSH targets and state during normal + * operation, but `ipc/ssh.ts` also owns `ipcMain`, `powerMonitor` and a + * `BrowserWindow` accessor. Importing four thin accessors dragged all of Electron + * into the runtime's module graph. + * + * This holds only the registry: the store plus the two callbacks the handler layer + * installs. `registerSshHandlers` populates it; the runtime reads it. Keeping the + * indirection (rather than the runtime holding a manager directly) is deliberate — + * SSH providers register after construction and may reconnect, so callers must + * resolve the current generation rather than freeze one. + */ + +let sshStore: SshConnectionStore | null = null +let registeredConnectSshTarget: ((targetId: string) => Promise) | null = null +let registeredGetSshState: ((targetId: string) => SshConnectionState | undefined) | null = null + +export function setSshTargetRegistryStore(store: SshConnectionStore | null): void { + sshStore = store +} + +export function getSshTargetRegistryStore(): SshConnectionStore | null { + return sshStore +} + +export function setSshTargetRegistryHandlers(handlers: { + connect: ((targetId: string) => Promise) | null + getState: ((targetId: string) => SshConnectionState | undefined) | null +}): void { + registeredConnectSshTarget = handlers.connect + registeredGetSshState = handlers.getState +} + +export async function connectRegisteredSshTarget(targetId: string): Promise { + if (!registeredConnectSshTarget) { + // Why this still throws: a headless host that never registered handlers must fail + // loudly rather than report a target as unreachable, which would read as `exited`. + throw new Error('ssh_handlers_not_registered') + } + return registeredConnectSshTarget(targetId) +} + +export function getRegisteredSshState(targetId: string): SshConnectionState | undefined { + return registeredGetSshState?.(targetId) +} + +/** Public targets for runtime RPC clients — same list the desktop renderer gets. */ +export function listRegisteredSshTargets(): SshTarget[] { + return sshStore?.listTargets() ?? [] +} + +/** Removed-target id → last known label, for ghost-host display on paired clients. */ +export function listRegisteredRemovedSshTargetLabels(): Record { + return sshStore?.listRemovedTargetLabels() ?? {} +} + +let registeredGetActiveMultiplexer: + | ((connectionId: string) => SshChannelMultiplexer | undefined) + | null = null + +export function setSshActiveMultiplexerResolver( + resolve: ((connectionId: string) => SshChannelMultiplexer | undefined) | null +): void { + registeredGetActiveMultiplexer = resolve +} + +/** + * The live channel multiplexer for a connection, or undefined when the target is not + * connected. Undefined means "not connected", never "the connection died" — callers + * must not read absence here as an `exited` verdict (docs/reference/ssh-execution-boundary.md). + * + * Why it throws when no resolver is installed rather than returning undefined: that + * case is a wiring error, not a connection state, and the two are indistinguishable to + * callers. A host that never loaded the SSH layer would otherwise report every target as + * quietly "not connected" — which is precisely the unverifiable-reported-as-exited + * conflation the execution-boundary doc exists to prevent. + */ +export function getActiveMultiplexer(connectionId: string): SshChannelMultiplexer | undefined { + if (!registeredGetActiveMultiplexer) { + throw new Error('ssh_active_multiplexer_resolver_not_installed') + } + return registeredGetActiveMultiplexer(connectionId) +} diff --git a/src/shared/app-environment.ts b/src/shared/app-environment.ts index 4c2492be678..86aca4f8182 100644 --- a/src/shared/app-environment.ts +++ b/src/shared/app-environment.ts @@ -68,6 +68,14 @@ export function setAppEnvironment(environment: AppEnvironment): void { slot()[SLOT] = environment } +/** + * Whether an environment is installed. For callers that must work in BOTH the desktop + * and a plain-Node fork — those legitimately have no app root and want null, not a throw. + */ +export function hasAppEnvironment(): boolean { + return read() !== null +} + export function getAppEnvironment(): AppEnvironment { const current = read() if (!current) {