From 4828c6bed4c25433e7df5f4d46c31bd15ea542a1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:30:23 -0700 Subject: [PATCH 01/27] UX: compact native density for context + dropdown menus (#15924) --- src/renderer/src/components/ui/context-menu.tsx | 14 +++++++------- src/renderer/src/components/ui/dropdown-menu.tsx | 16 ++++++++-------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/ui/context-menu.tsx b/src/renderer/src/components/ui/context-menu.tsx index 60ab6e38055..5c574db1797 100644 --- a/src/renderer/src/components/ui/context-menu.tsx +++ b/src/renderer/src/components/ui/context-menu.tsx @@ -37,7 +37,7 @@ function ContextMenuSubTrigger({ data-slot="context-menu-sub-trigger" data-inset={inset} className={cn( - "flex cursor-default items-center gap-2 rounded-[7px] px-2 py-1 text-[12px] leading-5 font-[450] outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-black/8 dark:data-[state=open]:bg-white/14 data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground", + "flex cursor-default items-center gap-2 rounded-md px-2 py-[4px] text-[12px] leading-[17px] font-[450] outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-black/8 dark:data-[state=open]:bg-white/14 data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground", className )} {...props} @@ -58,7 +58,7 @@ function ContextMenuSubContent({ ) diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx index eeebe9a4d2d..7d5c3fe8753 100644 --- a/src/renderer/src/components/ui/dropdown-menu.tsx +++ b/src/renderer/src/components/ui/dropdown-menu.tsx @@ -32,7 +32,7 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - 'z-[70] max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-1 text-black dark:text-white shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', + 'z-[70] max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-[4px] text-black dark:text-white shadow-[0_12px_28px_rgba(0,0,0,0.22)],inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:duration-150 data-[state=open]:zoom-in-[0.98]', className )} // Why: Electron's -webkit-app-region: drag on the titlebar captures @@ -60,7 +60,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "relative flex cursor-default items-center gap-2 rounded-[7px] px-2 py-1 text-[12px] leading-5 font-medium outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", + "relative flex cursor-default items-center gap-2 rounded-md px-2 py-[4px] text-[12px] leading-[17px] font-[450] outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", className )} {...props} @@ -78,7 +78,7 @@ function DropdownMenuCheckboxItem({ ) @@ -187,7 +187,7 @@ function DropdownMenuSubTrigger({ data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn( - "flex cursor-default items-center gap-2 rounded-[7px] px-2 py-1 text-[12px] leading-5 font-medium outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[inset]:pl-7 data-[state=open]:bg-black/8 dark:data-[state=open]:bg-white/14 data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground", + "flex cursor-default items-center gap-2 rounded-md px-2 py-[4px] text-[12px] leading-[17px] font-[450] outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[inset]:pl-7 data-[state=open]:bg-black/8 dark:data-[state=open]:bg-white/14 data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground", className )} {...props} @@ -208,7 +208,7 @@ function DropdownMenuSubContent({ Date: Sat, 22 Aug 2026 16:08:21 -0700 Subject: [PATCH 02/27] test(e2e): extract paired client window reveal into helper (#15991) * test(e2e): extract paired client window reveal into helper Paired clients launch hidden, parking runtime subscriptions. Playwright-driven clients must be revealed to test actual user interactions. Extract the reveal logic into a reusable helper with error handling and unit tests. * test(e2e): handle crash dialogs and isolate collision fixture IDs - Recover from recoverable UI error dialogs in selectRuntimeHost - Give the same-ID collision fixture unique repo and worktree IDs to avoid reusing the runtime repo's ID, preventing fixture leakage - Simplify verbose comments for clarity --- .../helpers/paired-client-window-reveal.ts | 52 +++++++++++++++++++ .../paired-client-window-reveal.unit.test.ts | 44 ++++++++++++++++ .../e2e/pr11346-selected-runtime-add.spec.ts | 28 ++++++---- ...r11346-selected-runtime-identity-oracle.ts | 5 +- 4 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 tests/e2e/helpers/paired-client-window-reveal.ts create mode 100644 tests/e2e/helpers/paired-client-window-reveal.unit.test.ts diff --git a/tests/e2e/helpers/paired-client-window-reveal.ts b/tests/e2e/helpers/paired-client-window-reveal.ts new file mode 100644 index 00000000000..60af9c71450 --- /dev/null +++ b/tests/e2e/helpers/paired-client-window-reveal.ts @@ -0,0 +1,52 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' + +/** + * Reveals a paired client's window so its renderer unparks runtime subscriptions. Leave the client + * hidden only when the hidden state is what the spec covers. + */ +export type PairedClientWindowRevealReport = { + isVisible: boolean + wasVisible: boolean + windowCount: number +} + +export type RevealablePairedClient = { + app: ElectronApplication + page: Page +} + +export function assertPairedClientWindowRevealed(report: PairedClientWindowRevealReport): void { + if (report.windowCount === 0) { + throw new Error('Paired client has no BrowserWindow to reveal') + } + if (!report.isVisible) { + throw new Error( + `Paired client window stayed hidden after show() (windows: ${report.windowCount})` + ) + } +} + +export async function revealPairedClientWindow( + client: RevealablePairedClient +): Promise { + const report = await client.app.evaluate(({ BrowserWindow }) => { + const windows = BrowserWindow.getAllWindows() + const window = windows[0] + const wasVisible = window?.isVisible() ?? false + if (window && !wasVisible) { + window.show() + } + return { + isVisible: window?.isVisible() ?? false, + wasVisible, + windowCount: windows.length + } + }) + assertPairedClientWindowRevealed(report) + // Why: the renderer unparks on `visibilitychange`; clicking before it lands races a parked + // host list. + await client.page.waitForFunction(() => document.visibilityState === 'visible', null, { + timeout: 30_000 + }) + return report +} diff --git a/tests/e2e/helpers/paired-client-window-reveal.unit.test.ts b/tests/e2e/helpers/paired-client-window-reveal.unit.test.ts new file mode 100644 index 00000000000..2f5cf79be5c --- /dev/null +++ b/tests/e2e/helpers/paired-client-window-reveal.unit.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { assertPairedClientWindowRevealed } from './paired-client-window-reveal' + +describe('assertPairedClientWindowRevealed', () => { + it('accepts a window that the reveal made visible', () => { + expect(() => + assertPairedClientWindowRevealed({ + isVisible: true, + wasVisible: false, + windowCount: 1 + }) + ).not.toThrow() + }) + + it('accepts a window that was already visible', () => { + expect(() => + assertPairedClientWindowRevealed({ + isVisible: true, + wasVisible: true, + windowCount: 1 + }) + ).not.toThrow() + }) + + it('rejects a client with no window instead of letting the spec time out on a click', () => { + expect(() => + assertPairedClientWindowRevealed({ + isVisible: false, + wasVisible: false, + windowCount: 0 + }) + ).toThrow(/no BrowserWindow/) + }) + + it('rejects a window that stays hidden after show()', () => { + expect(() => + assertPairedClientWindowRevealed({ + isVisible: false, + wasVisible: false, + windowCount: 2 + }) + ).toThrow(/stayed hidden after show\(\)/) + }) +}) diff --git a/tests/e2e/pr11346-selected-runtime-add.spec.ts b/tests/e2e/pr11346-selected-runtime-add.spec.ts index 20e34121f88..04f4e8d52c0 100644 --- a/tests/e2e/pr11346-selected-runtime-add.spec.ts +++ b/tests/e2e/pr11346-selected-runtime-add.spec.ts @@ -6,6 +6,7 @@ import type { FolderWorkspace } from '../../src/shared/folder-workspace-types' import type { ProjectGroup } from '../../src/shared/project-group-types' import type { Repo } from '../../src/shared/repo-types' import { expect, test } from './helpers/orca-app' +import { revealPairedClientWindow } from './helpers/paired-client-window-reveal' import { createRuntimeDesktopPairingOffer, launchPairedElectronClient @@ -20,6 +21,11 @@ import { } from './pr11346-selected-runtime-identity-oracle' async function selectRuntimeHost(page: Page, runtimeName: string): Promise { + const crashDialog = page.getByRole('dialog', { name: /recoverable UI error/i }) + if (await crashDialog.isVisible()) { + // Why: same-ID collision fixtures intentionally exceed terminal-workbench invariants. + await crashDialog.getByRole('button', { name: /Don't Send/i }).click() + } await page .getByRole('button', { name: /Add Project/i }) .first() @@ -101,16 +107,12 @@ async function runSelectedRuntimeAddJourney( try { const measurements: Record = {} - if (visible) { - await client.app.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows()[0]?.show() - }) - expect( - await client.app.evaluate( - ({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isVisible() ?? false - ) - ).toBe(true) - } + // Why: the client is the Playwright-driven surface in both topologies; the hidden-window + // parity under test is the HUB's. + expect(await revealPairedClientWindow(client)).toMatchObject({ + isVisible: true, + wasVisible: false + }) let startedAt = Date.now() await setActiveRuntimePreference(client.page, null) await setActiveRuntimePreference(client.page, client.environmentId) @@ -731,6 +733,12 @@ async function runSelectedRuntimeAddJourney( await expect(client.page.getByText(projectName, { exact: false }).first()).toBeVisible() } expect(await client.getDirectSshAttemptTargetIds()).toEqual([]) + // Why: revealing the client must not leak into the HUB's window visibility. + expect( + await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().some((window) => window.isVisible()) + ) + ).toBe(visible) console.info(`[pr11346-routing] ${JSON.stringify({ topology: runtimeName, ...measurements })}`) await client.page.screenshot({ path: testInfo.outputPath(`${visible ? 'headed' : 'hidden-window'}-selected-runtime-add.png`), diff --git a/tests/e2e/pr11346-selected-runtime-identity-oracle.ts b/tests/e2e/pr11346-selected-runtime-identity-oracle.ts index 2918a6928df..1d1e4588b35 100644 --- a/tests/e2e/pr11346-selected-runtime-identity-oracle.ts +++ b/tests/e2e/pr11346-selected-runtime-identity-oracle.ts @@ -183,8 +183,10 @@ export async function injectSameIdLocalActivationCollision( if (!runtimeWorktree) { throw new Error(`Runtime default checkout unavailable for ${runtimePath}`) } + const localRepoId = `${runtimeRepo.id}-local-collision` const localWorktree = { ...runtimeWorktree, + repoId: localRepoId, path: localCollisionPath, hostId: 'local' as const, runtimeOwnerEnvironmentId: null @@ -193,6 +195,7 @@ export async function injectSameIdLocalActivationCollision( repos: [ { ...runtimeRepo, + id: localRepoId, path: localCollisionPath, displayName: `Local collision for ${runtimeRepo.displayName}`, executionHostId: 'local', @@ -202,7 +205,7 @@ export async function injectSameIdLocalActivationCollision( ], worktreesByRepo: { ...state.worktreesByRepo, - [runtimeRepo.id]: [localWorktree, ...(state.worktreesByRepo[runtimeRepo.id] ?? [])] + [localRepoId]: [localWorktree] }, fetchWorktrees: gate.originalFetchWorktrees }) From d07ce15cff7836ee36f34ccc7bdf1fca251f299d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:38:00 -0700 Subject: [PATCH 03/27] refactor(host): route secret storage through a SecretStore port (#15916) --- config/scripts/vitest-secret-store-setup.ts | 29 ++++++++ config/vitest.config.ts | 5 +- .../bitbucket/credential-connection.test.ts | 14 ++-- src/main/bitbucket/credential-store.test.ts | 14 ++-- src/main/bitbucket/status-no-decrypt.test.ts | 14 ++-- src/main/host/electron-secret-store.test.ts | 61 +++++++++++++++++ src/main/host/electron-secret-store.ts | 31 +++++++++ src/main/index.ts | 7 ++ src/main/integration-credential-file.ts | 14 ++-- src/main/jira/client.test.ts | 12 ++-- src/main/jira/site-credential-store.ts | 8 +-- src/main/linear/client.test.ts | 14 ++-- src/main/linear/linear-token-store.ts | 8 +-- ...tence-protected-secret-fail-closed.test.ts | 52 ++++++++------- ...stence-protected-secret-write-race.test.ts | 14 ++-- .../persistence-proxy-secret-recovery.test.ts | 42 ++++++------ src/main/persistence-single-serialize.test.ts | 32 ++++----- src/main/persistence-ui-state.test.ts | 24 ++++--- src/main/protected-secret-persistence.test.ts | 29 +++++--- src/main/protected-secret-persistence.ts | 19 ++++-- src/main/speech/openai-api-key-store.test.ts | 8 ++- src/main/speech/openai-api-key-store.ts | 18 +++-- src/shared/secret-store.test.ts | 53 +++++++++++++++ src/shared/secret-store.ts | 66 +++++++++++++++++++ 24 files changed, 436 insertions(+), 152 deletions(-) create mode 100644 config/scripts/vitest-secret-store-setup.ts create mode 100644 src/main/host/electron-secret-store.test.ts create mode 100644 src/main/host/electron-secret-store.ts create mode 100644 src/shared/secret-store.test.ts create mode 100644 src/shared/secret-store.ts diff --git a/config/scripts/vitest-secret-store-setup.ts b/config/scripts/vitest-secret-store-setup.ts new file mode 100644 index 00000000000..6000e28c768 --- /dev/null +++ b/config/scripts/vitest-secret-store-setup.ts @@ -0,0 +1,29 @@ +import { beforeEach } from 'vitest' +import { setSecretStore } from '../../src/shared/secret-store' + +/** + * Why: `getSecretStore()` throws until an entrypoint installs a store, which is the + * right production behaviour but would fail ~67 suites that only ever cared that + * *some* store existed. Install a reversible in-memory one before every test so + * those suites stay unchanged; a suite that asserts on sealing behaviour calls + * `setSecretStore()` itself and wins, because this runs first. + * + * Deliberately not a plaintext passthrough: encryptString must return something a + * test can tell apart from the input, or a test that forgot to seal would pass. + */ +const SEAL_PREFIX = 'vitest-sealed:' + +beforeEach(() => { + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (plainText) => Buffer.from(`${SEAL_PREFIX}${plainText}`), + decryptString: (cipher) => { + const text = cipher.toString() + if (!text.startsWith(SEAL_PREFIX)) { + throw new Error('vitest secret store: ciphertext was not produced by this store') + } + return text.slice(SEAL_PREFIX.length) + }, + describeUnavailable: () => null + }) +}) diff --git a/config/vitest.config.ts b/config/vitest.config.ts index d23bee128c9..745ff8c263d 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -19,7 +19,10 @@ export default defineConfig({ // Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds. execArgv: ['--no-experimental-webstorage', '--expose-gc'], // Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does. - setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')], + setupFiles: [ + resolve('config/scripts/happy-dom-mutation-observer-retention.ts'), + resolve('config/scripts/vitest-secret-store-setup.ts') + ], include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx', diff --git a/src/main/bitbucket/credential-connection.test.ts b/src/main/bitbucket/credential-connection.test.ts index 0026e6901cb..0d1a7e632bb 100644 --- a/src/main/bitbucket/credential-connection.test.ts +++ b/src/main/bitbucket/credential-connection.test.ts @@ -10,13 +10,13 @@ let tempHome = '' async function loadModule() { vi.resetModules() - vi.doMock('electron', () => ({ - safeStorage: { - isEncryptionAvailable: () => true, - encryptString: (value: string) => Buffer.from(value), - decryptString: (value: Buffer) => value.toString('utf-8') - } - })) + const { setSecretStore } = await import('../../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (value) => Buffer.from(value), + decryptString: (value) => value.toString('utf-8'), + describeUnavailable: () => null + }) vi.doMock('node:os', async () => { const actual = await vi.importActual('node:os') return { ...actual, homedir: () => tempHome } diff --git a/src/main/bitbucket/credential-store.test.ts b/src/main/bitbucket/credential-store.test.ts index 4edd11b0444..601831a5930 100644 --- a/src/main/bitbucket/credential-store.test.ts +++ b/src/main/bitbucket/credential-store.test.ts @@ -19,13 +19,13 @@ async function loadStore( // Why: doMock registrations outlive resetModules, so an injected failure from // one case would leak into every later one in this file. vi.doUnmock('node:fs') - vi.doMock('electron', () => ({ - safeStorage: { - isEncryptionAvailable: () => true, - encryptString: (value: string) => Buffer.from(value), - decryptString: decryptStringMock - } - })) + const { setSecretStore } = await import('../../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (value) => Buffer.from(value), + decryptString: decryptStringMock, + describeUnavailable: () => null + }) vi.doMock('node:os', async () => { const actual = await vi.importActual('node:os') return { ...actual, homedir: () => tempHome } diff --git a/src/main/bitbucket/status-no-decrypt.test.ts b/src/main/bitbucket/status-no-decrypt.test.ts index 946f1aea4aa..05543108ea6 100644 --- a/src/main/bitbucket/status-no-decrypt.test.ts +++ b/src/main/bitbucket/status-no-decrypt.test.ts @@ -19,13 +19,13 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn() })) async function loadModules() { vi.resetModules() - vi.doMock('electron', () => ({ - safeStorage: { - isEncryptionAvailable: () => true, - encryptString: (value: string) => Buffer.from(value), - decryptString: decryptSpy - } - })) + const { setSecretStore } = await import('../../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (value) => Buffer.from(value), + decryptString: decryptSpy, + describeUnavailable: () => null + }) vi.doMock('node:os', async () => { const actual = await vi.importActual('node:os') return { ...actual, homedir: () => tempHome } diff --git a/src/main/host/electron-secret-store.test.ts b/src/main/host/electron-secret-store.test.ts new file mode 100644 index 00000000000..3c171d7d499 --- /dev/null +++ b/src/main/host/electron-secret-store.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const safeStorageMock = vi.hoisted(() => ({ + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((plainText: string) => Buffer.from(`os-sealed:${plainText}`)), + decryptString: vi.fn((cipher: Buffer) => cipher.toString().slice('os-sealed:'.length)) +})) + +vi.mock('electron', () => ({ safeStorage: safeStorageMock })) + +const { ElectronSecretStore } = await import('./electron-secret-store') + +describe('ElectronSecretStore', () => { + beforeEach(() => { + vi.clearAllMocks() + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + }) + + // Why this shape: the whole safety argument for the SecretStore refactor is that the + // desktop byte path did not change. That is only true if this adapter forwards + // verbatim — same argument, same return value, no re-encoding. + it('forwards encryptString to safeStorage and returns its buffer unchanged', () => { + const sealed = new ElectronSecretStore().encryptString('token') + expect(safeStorageMock.encryptString).toHaveBeenCalledExactlyOnceWith('token') + expect(sealed).toBe(safeStorageMock.encryptString.mock.results[0]!.value) + }) + + it('forwards decryptString to safeStorage and returns its string unchanged', () => { + const cipher = Buffer.from('os-sealed:token') + expect(new ElectronSecretStore().decryptString(cipher)).toBe('token') + expect(safeStorageMock.decryptString).toHaveBeenCalledExactlyOnceWith(cipher) + }) + + // Why the narrower claim: safeStorage is mocked here, so this proves the adapter + // pairs encrypt/decrypt without mangling the buffer — NOT that credentials sealed by + // a previous build still open. Real-ciphertext compatibility needs a captured fixture. + it('pairs encryptString and decryptString without altering the payload', () => { + const store = new ElectronSecretStore() + expect(store.decryptString(store.encryptString('linear-token'))).toBe('linear-token') + }) + + it('reports availability from safeStorage rather than caching it', () => { + const store = new ElectronSecretStore() + expect(store.isEncryptionAvailable()).toBe(true) + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + expect(store.isEncryptionAvailable()).toBe(false) + }) + + it('has no reason to give while sealing works', () => { + expect(new ElectronSecretStore().describeUnavailable()).toBeNull() + }) + + it('names the missing facility when sealing is unavailable, so the plaintext fallback is explainable', () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + const reason = new ElectronSecretStore().describeUnavailable() + expect(reason).toContain('unencrypted') + if (process.platform === 'linux') { + expect(reason).toContain('keyring') + } + }) +}) diff --git a/src/main/host/electron-secret-store.ts b/src/main/host/electron-secret-store.ts new file mode 100644 index 00000000000..63d4bb78d9b --- /dev/null +++ b/src/main/host/electron-secret-store.ts @@ -0,0 +1,31 @@ +import { safeStorage } from 'electron' +import type { SecretStore } from '../../shared/secret-store' + +/** + * Electron-backed SecretStore for the desktop app: a pass-through to + * `electron.safeStorage`, which seals against the OS keychain. + */ +export class ElectronSecretStore implements SecretStore { + isEncryptionAvailable(): boolean { + return safeStorage.isEncryptionAvailable() + } + + encryptString(plainText: string): Buffer { + return safeStorage.encryptString(plainText) + } + + decryptString(cipher: Buffer): string { + return safeStorage.decryptString(cipher) + } + + describeUnavailable(): string | null { + if (safeStorage.isEncryptionAvailable()) { + return null + } + // Why platform-specific: the fix differs, and "encryption unavailable" alone + // sends users looking in the wrong place. + return process.platform === 'linux' + ? 'The OS keyring is unavailable, so secrets are stored unencrypted. Install and unlock gnome-keyring or kwallet to seal them.' + : 'The OS keychain is unavailable, so secrets are stored unencrypted.' + } +} diff --git a/src/main/index.ts b/src/main/index.ts index db9cc9506b6..729a613e4ff 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,6 +11,8 @@ import { getCanonicalUserDataPath, migrateMobilePairingDataToCanonicalUserDataPath } from './persistence' +import { setSecretStore } from '../shared/secret-store' +import { ElectronSecretStore } from './host/electron-secret-store' import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' @@ -850,6 +852,11 @@ if (!hasSingleInstanceLock) { // Why: when another process holds the lock we've already exited; skip file-writing side effects so this transient process never touches userData. if (hasSingleInstanceLock) { + // Why: every secret read resolves through this port, and getSecretStore() throws + // until it is installed — so install before any other bootstrap step can touch a + // credential. Constructing it does not call safeStorage, so this stays clear of the + // pre-ready Keychain service-name resolution the setName block below depends on. + setSecretStore(new ElectronSecretStore()) // 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/integration-credential-file.ts b/src/main/integration-credential-file.ts index 96823a77a65..01a85c28b51 100644 --- a/src/main/integration-credential-file.ts +++ b/src/main/integration-credential-file.ts @@ -8,7 +8,7 @@ import { unlinkSync, writeSync } from 'node:fs' -import { safeStorage } from 'electron' +import { getSecretStore } from '../shared/secret-store' import { credentialDecryptionMessage, type IntegrationCredentialService @@ -31,12 +31,12 @@ export function writeEncryptedCredential( path: string, value: string ): void { - if (safeStorage.isEncryptionAvailable()) { - writeCredentialFileAtomic(path, safeStorage.encryptString(value)) + if (getSecretStore().isEncryptionAvailable()) { + writeCredentialFileAtomic(path, getSecretStore().encryptString(value)) return } console.warn( - `[${service.toLowerCase()}] safeStorage encryption unavailable — storing credential in plaintext` + `[${service.toLowerCase()}] secret encryption unavailable — storing credential in plaintext` ) writeCredentialFileAtomic(path, Buffer.from(value, 'utf-8')) } @@ -111,9 +111,9 @@ export function readStoredCredentialToken( return null } - if (safeStorage.isEncryptionAvailable()) { + if (getSecretStore().isEncryptionAvailable()) { try { - return usableToken(safeStorage.decryptString(raw)) + return usableToken(getSecretStore().decryptString(raw)) } catch { return readPlaintextLegacyCredential(service, raw) } @@ -127,7 +127,7 @@ function readPlaintextLegacyCredential( raw: Buffer ): string | null { const plaintext = decodeUtf8(raw) - // Why: legacy plaintext tokens are printable UTF-8; safeStorage ciphertext + // Why: legacy plaintext tokens are printable UTF-8; sealed ciphertext // such as macOS v10 blobs must not be decoded into auth-header junk. if (plaintext === null || hasControlCharacter(plaintext)) { throw new CredentialDecryptionError(service) diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index faee0d2eaa4..4e77150e72b 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -93,11 +93,6 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) { vi.resetModules() vi.doMock('electron', () => ({ net: { fetch: netFetchMock }, - safeStorage: { - isEncryptionAvailable: () => options.encryptionAvailable ?? false, - encryptString: (value: string) => Buffer.from(value), - decryptString: options.decryptString ?? ((value: Buffer) => value.toString('utf-8')) - }, session: { defaultSession: { closeAllConnections: closeAllConnectionsMock, @@ -106,6 +101,13 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) { } } })) + const { setSecretStore } = await import('../../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => options.encryptionAvailable ?? false, + encryptString: (value) => Buffer.from(value), + decryptString: options.decryptString ?? ((value) => value.toString('utf-8')), + describeUnavailable: () => null + }) vi.doMock('os', async () => { const actual = await vi.importActual('os') return { ...actual, homedir: () => tempHome } diff --git a/src/main/jira/site-credential-store.ts b/src/main/jira/site-credential-store.ts index 0f8933097d3..63c241ca62c 100644 --- a/src/main/jira/site-credential-store.ts +++ b/src/main/jira/site-credential-store.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' -import { safeStorage } from 'electron' +import { getSecretStore } from '../../shared/secret-store' import { CredentialDecryptionError, credentialFileHasContent, @@ -157,11 +157,11 @@ export function writeSiteFile(file: JiraSiteFile): void { } function writeEncryptedToken(path: string, apiToken: string): void { - if (safeStorage.isEncryptionAvailable()) { - writeFileSync(path, safeStorage.encryptString(apiToken), { mode: 0o600 }) + if (getSecretStore().isEncryptionAvailable()) { + writeFileSync(path, getSecretStore().encryptString(apiToken), { mode: 0o600 }) return } - console.warn('[jira] safeStorage encryption unavailable — storing token in plaintext') + console.warn('[jira] secret encryption unavailable — storing token in plaintext') writeFileSync(path, apiToken, { encoding: 'utf-8', mode: 0o600 }) } diff --git a/src/main/linear/client.test.ts b/src/main/linear/client.test.ts index 7f518afe6e8..f2105a250de 100644 --- a/src/main/linear/client.test.ts +++ b/src/main/linear/client.test.ts @@ -90,13 +90,13 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) { }) }) }) - vi.doMock('electron', () => ({ - safeStorage: { - isEncryptionAvailable: () => options.encryptionAvailable ?? false, - encryptString: (value: string) => Buffer.from(value), - decryptString: options.decryptString ?? ((value: Buffer) => value.toString('utf-8')) - } - })) + const { setSecretStore } = await import('../../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => options.encryptionAvailable ?? false, + encryptString: (value) => Buffer.from(value), + decryptString: options.decryptString ?? ((value) => value.toString('utf-8')), + describeUnavailable: () => null + }) vi.doMock('os', async () => { const actual = await vi.importActual('os') return { ...actual, homedir: () => tempHome } diff --git a/src/main/linear/linear-token-store.ts b/src/main/linear/linear-token-store.ts index 4d724c32a00..7a2bf892213 100644 --- a/src/main/linear/linear-token-store.ts +++ b/src/main/linear/linear-token-store.ts @@ -1,4 +1,4 @@ -import { safeStorage } from 'electron' +import { getSecretStore } from '../../shared/secret-store' import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { LEGACY_WORKSPACE_ID, @@ -33,13 +33,13 @@ import { import type { LinearWorkspace } from '../../shared/linear/workspace-types' function writeEncryptedToken(path: string, apiKey: string): void { - if (safeStorage.isEncryptionAvailable()) { - const encrypted = safeStorage.encryptString(apiKey) + if (getSecretStore().isEncryptionAvailable()) { + const encrypted = getSecretStore().encryptString(apiKey) writeFileSync(path, encrypted, { mode: 0o600 }) return } - console.warn('[linear] safeStorage encryption unavailable — storing token in plaintext') + console.warn('[linear] secret encryption unavailable — storing token in plaintext') writeFileSync(path, apiKey, { encoding: 'utf-8', mode: 0o600 }) } diff --git a/src/main/persistence-protected-secret-fail-closed.test.ts b/src/main/persistence-protected-secret-fail-closed.test.ts index 42d348af61c..9d407ab505b 100644 --- a/src/main/persistence-protected-secret-fail-closed.test.ts +++ b/src/main/persistence-protected-secret-fail-closed.test.ts @@ -19,31 +19,7 @@ vi.mock('./ssh/ssh-config-parser', () => ({ })) vi.mock('electron', () => ({ - app: { getPath: () => testState.dir }, - safeStorage: { - isEncryptionAvailable: () => { - if (cipherState.availability === 'throws') { - throw new Error('keychain access denied') - } - return cipherState.availability === 'available' - }, - encryptString: (plaintext: string) => { - if (cipherState.encryptionThrows) { - throw new Error('keychain encryption failed') - } - return Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8') - }, - decryptString: (ciphertext: Buffer) => { - if (cipherState.decryptionThrows) { - throw new Error('keychain decryption failed') - } - const decoded = ciphertext.toString('utf-8') - if (!decoded.startsWith('enc:')) { - throw new Error('invalid ciphertext') - } - return decoded.slice('enc:'.length + 36 + 1) - } - } + app: { getPath: () => testState.dir } })) vi.mock('./telemetry/client', () => ({ track: vi.fn() })) @@ -53,6 +29,32 @@ vi.mock('./telemetry/cohort-classifier', () => ({ async function createStore() { vi.resetModules() + const { setSecretStore } = await import('../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => { + if (cipherState.availability === 'throws') { + throw new Error('keychain access denied') + } + return cipherState.availability === 'available' + }, + encryptString: (plaintext) => { + if (cipherState.encryptionThrows) { + throw new Error('keychain encryption failed') + } + return Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8') + }, + decryptString: (ciphertext) => { + if (cipherState.decryptionThrows) { + throw new Error('keychain decryption failed') + } + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('enc:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('enc:'.length + 36 + 1) + }, + describeUnavailable: () => null + }) const { Store, initDataPath } = await import('./persistence') initDataPath() return new Store() diff --git a/src/main/persistence-protected-secret-write-race.test.ts b/src/main/persistence-protected-secret-write-race.test.ts index 20bbe78d5cf..89849d22dde 100644 --- a/src/main/persistence-protected-secret-write-race.test.ts +++ b/src/main/persistence-protected-secret-write-race.test.ts @@ -40,16 +40,18 @@ vi.mock('./telemetry/cohort-classifier', () => ({ })) vi.mock('electron', () => ({ - app: { getPath: () => testState.dir }, - safeStorage: { - isEncryptionAvailable: () => cipherState.available, - encryptString: (plaintext: string) => Buffer.from(`enc:${plaintext}`, 'utf-8'), - decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('enc:'.length) - } + app: { getPath: () => testState.dir } })) async function createStore() { vi.resetModules() + const { setSecretStore } = await import('../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => cipherState.available, + encryptString: (plaintext) => Buffer.from(`enc:${plaintext}`, 'utf-8'), + decryptString: (ciphertext) => ciphertext.toString('utf-8').slice('enc:'.length), + describeUnavailable: () => null + }) const { Store, initDataPath } = await import('./persistence') initDataPath() return new Store() diff --git a/src/main/persistence-proxy-secret-recovery.test.ts b/src/main/persistence-proxy-secret-recovery.test.ts index 769ebec69ba..faa6f65822e 100644 --- a/src/main/persistence-proxy-secret-recovery.test.ts +++ b/src/main/persistence-proxy-secret-recovery.test.ts @@ -28,26 +28,7 @@ vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, - session: { defaultSession: undefined }, - safeStorage: { - isEncryptionAvailable: () => { - if (cipherState.availabilityThrows) { - throw new Error('safeStorage cannot be used before the app is ready') - } - return cipherState.encryptionAvailable - }, - encryptString: (plaintext: string) => Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8'), - decryptString: (ciphertext: Buffer) => { - if (cipherState.decryptAlwaysThrows) { - throw new Error('keychain access denied') - } - const decoded = ciphertext.toString('utf-8') - if (!decoded.startsWith('enc:')) { - throw new Error('invalid ciphertext') - } - return decoded.slice('enc:'.length + 36 + 1) - } - } + session: { defaultSession: undefined } })) vi.mock('./telemetry/client', () => ({ @@ -60,6 +41,27 @@ vi.mock('./telemetry/cohort-classifier', () => ({ async function createStore() { vi.resetModules() + const { setSecretStore } = await import('../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => { + if (cipherState.availabilityThrows) { + throw new Error('safeStorage cannot be used before the app is ready') + } + return cipherState.encryptionAvailable + }, + encryptString: (plaintext) => Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8'), + decryptString: (ciphertext) => { + if (cipherState.decryptAlwaysThrows) { + throw new Error('keychain access denied') + } + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('enc:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('enc:'.length + 36 + 1) + }, + describeUnavailable: () => null + }) const { Store, initDataPath } = await import('./persistence') initDataPath() return new Store() diff --git a/src/main/persistence-single-serialize.test.ts b/src/main/persistence-single-serialize.test.ts index c638ac12224..06512f9a089 100644 --- a/src/main/persistence-single-serialize.test.ts +++ b/src/main/persistence-single-serialize.test.ts @@ -27,21 +27,6 @@ const DETERMINISTIC_IV = 'd'.repeat(36) vi.mock('electron', () => ({ app: { getPath: () => testState.dir - }, - safeStorage: { - isEncryptionAvailable: () => cipherState.encryptionAvailable, - encryptString: (plaintext: string) => - Buffer.from( - `enc:${cipherState.deterministic ? DETERMINISTIC_IV : randomUUID()}:${plaintext}`, - 'utf-8' - ), - decryptString: (ciphertext: Buffer) => { - const decoded = ciphertext.toString('utf-8') - if (!decoded.startsWith('enc:')) { - throw new Error('invalid ciphertext') - } - return decoded.slice('enc:'.length + 36 + 1) - } } })) @@ -55,6 +40,23 @@ vi.mock('./telemetry/cohort-classifier', () => ({ async function createStore() { vi.resetModules() + const { setSecretStore } = await import('../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => cipherState.encryptionAvailable, + encryptString: (plaintext) => + Buffer.from( + `enc:${cipherState.deterministic ? DETERMINISTIC_IV : randomUUID()}:${plaintext}`, + 'utf-8' + ), + decryptString: (ciphertext) => { + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('enc:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('enc:'.length + 36 + 1) + }, + describeUnavailable: () => null + }) const { Store, initDataPath } = await import('./persistence') initDataPath() return new Store() diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index ccafa4e3fab..7bb676ffe34 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -7,7 +7,6 @@ import { getDefaultPersistedState } from '../shared/constants' import { createDefaultWorkspaceCleanupBrowseState } from '../shared/workspace-cleanup-browse-state' import { testState, - createStore, dataFile, writeDataFile, readDataFile, @@ -32,19 +31,28 @@ const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ vi.mock('electron', () => ({ app: { getPath: () => testState.dir - }, - safeStorage: { + } +})) + +async function createStore() { + vi.resetModules() + const { setSecretStore } = await import('../shared/secret-store') + setSecretStore({ isEncryptionAvailable: () => true, - encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), - decryptString: (ciphertext: Buffer) => { + encryptString: (plaintext) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext) => { const decoded = ciphertext.toString('utf-8') if (!decoded.startsWith('encrypted:')) { throw new Error('invalid ciphertext') } return decoded.slice('encrypted:'.length) - } - } -})) + }, + describeUnavailable: () => null + }) + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} vi.mock('./telemetry/client', () => ({ track: trackMock diff --git a/src/main/protected-secret-persistence.test.ts b/src/main/protected-secret-persistence.test.ts index 815edc5d477..ff71a9e8c9d 100644 --- a/src/main/protected-secret-persistence.test.ts +++ b/src/main/protected-secret-persistence.test.ts @@ -1,18 +1,29 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { _resetSecretStoreForTests, setSecretStore } from '../shared/secret-store' const cipherState = { available: true } -vi.mock('electron', () => ({ - safeStorage: { - isEncryptionAvailable: () => cipherState.available, - encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`), - decryptString: (ciphertext: Buffer) => ciphertext.toString().slice('encrypted:'.length) - } -})) - describe('ProtectedSecretPersistence', () => { beforeEach(() => { cipherState.available = true + setSecretStore({ + isEncryptionAvailable: () => cipherState.available, + encryptString: (plaintext) => Buffer.from(`encrypted:${plaintext}`), + decryptString: (ciphertext) => ciphertext.toString().slice('encrypted:'.length), + describeUnavailable: () => null + }) + }) + + it('surfaces an uninstalled secret store instead of degrading silently', async () => { + // Why: a missing setSecretStore() is a startup bug. If the availability check + // swallows it, encrypt() hands back an empty blob and decryptWithStatus() reports + // 'unavailable' — a real secret silently not stored, which is the outcome the + // port throws to prevent. + const { ProtectedSecretPersistence } = await import('./protected-secret-persistence') + const secrets = new ProtectedSecretPersistence() + _resetSecretStoreForTests() + + expect(() => secrets.encrypt('slot', 'token')).toThrow(/SecretStore not initialized/) }) it('evicts dynamic slots across repeated SSH recovery lifecycles', async () => { diff --git a/src/main/protected-secret-persistence.ts b/src/main/protected-secret-persistence.ts index 85ab3153043..8445e67680f 100644 --- a/src/main/protected-secret-persistence.ts +++ b/src/main/protected-secret-persistence.ts @@ -1,4 +1,4 @@ -import { safeStorage } from 'electron' +import { getSecretStore } from '../shared/secret-store' export const PROTECTED_SECRET_SLOT = { opencodeSessionCookie: 'settings.opencodeSessionCookie', @@ -77,7 +77,7 @@ export class ProtectedSecretPersistence { } } try { - const blob = safeStorage.encryptString(plaintext).toString('base64') + const blob = getSecretStore().encryptString(plaintext).toString('base64') return { blob, degraded: false, @@ -109,7 +109,7 @@ export class ProtectedSecretPersistence { } try { const decrypted = { - plaintext: safeStorage.decryptString(Buffer.from(ciphertext, 'base64')), + plaintext: getSecretStore().decryptString(Buffer.from(ciphertext, 'base64')), status: 'decrypted' as const } this.sealedSlots.delete(slot) @@ -117,22 +117,27 @@ export class ProtectedSecretPersistence { } catch { if (isLegacyPlaintext?.(ciphertext)) { this.sealedSlots.delete(slot) - console.warn('[persistence] safeStorage decryption failed; accepting legacy plaintext.') + console.warn('[persistence] secret decryption failed; accepting legacy plaintext.') return { plaintext: ciphertext, status: 'failed' } } this.sealedSlots.add(slot) console.warn( - '[persistence] safeStorage decryption failed; retaining the protected value without exposing it.' + '[persistence] secret decryption failed; retaining the protected value without exposing it.' ) return { plaintext: '', status: 'failed' } } } private encryptionAvailable(): boolean { + // Why getSecretStore() sits outside the try: an uninstalled store is a startup bug, + // not a keyring failure. Swallowing it would degrade to an empty blob and report + // 'unavailable' — the silent-wrong-state outcome the port throws to prevent. Only + // the backend probe itself may fail softly. + const store = getSecretStore() try { - return safeStorage.isEncryptionAvailable() + return store.isEncryptionAvailable() } catch (err) { - console.warn('[persistence] safeStorage availability check failed:', err) + console.warn('[persistence] secret store availability check failed:', err) return false } } diff --git a/src/main/speech/openai-api-key-store.test.ts b/src/main/speech/openai-api-key-store.test.ts index 9475d5afa54..2153c9fe608 100644 --- a/src/main/speech/openai-api-key-store.test.ts +++ b/src/main/speech/openai-api-key-store.test.ts @@ -14,9 +14,11 @@ let tempHome = '' async function loadStoreModule() { vi.resetModules() - vi.doMock('electron', () => ({ - safeStorage: safeStorageMock - })) + const { setSecretStore } = await import('../../shared/secret-store') + setSecretStore({ + ...safeStorageMock, + describeUnavailable: () => null + }) vi.doMock('os', async () => { const actual = await vi.importActual('os') return { ...actual, homedir: () => tempHome } diff --git a/src/main/speech/openai-api-key-store.ts b/src/main/speech/openai-api-key-store.ts index dc7345f3037..b587c878f4d 100644 --- a/src/main/speech/openai-api-key-store.ts +++ b/src/main/speech/openai-api-key-store.ts @@ -1,4 +1,4 @@ -import { safeStorage } from 'electron' +import { getSecretStore } from '../../shared/secret-store' import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' @@ -43,7 +43,7 @@ function readLegacyJsonStoredOpenAiKey(): StoredOpenAiKey | null { export function hasOpenAiSpeechApiKey(): boolean { // Why: Settings and model-state refresh call this on startup; checking file - // existence avoids decrypting safeStorage and triggering macOS keychain prompts. + // existence avoids a decrypt that triggers macOS keychain prompts. return existsSync(getOpenAiKeyPath()) } @@ -53,15 +53,13 @@ export function saveOpenAiSpeechApiKey(apiKey: string): void { throw new Error('OpenAI API key is required') } ensureOrcaDir() - if (safeStorage.isEncryptionAvailable()) { - writeFileSync(getOpenAiKeyPath(), safeStorage.encryptString(trimmed), { mode: 0o600 }) + if (getSecretStore().isEncryptionAvailable()) { + writeFileSync(getOpenAiKeyPath(), getSecretStore().encryptString(trimmed), { mode: 0o600 }) cachedOpenAiSpeechApiKey = trimmed return } - console.warn( - '[speech] safeStorage encryption unavailable — storing OpenAI speech key in plaintext' - ) + console.warn('[speech] secret encryption unavailable — storing OpenAI speech key in plaintext') writeFileSync(getOpenAiKeyPath(), trimmed, { encoding: 'utf8', mode: 0o600 }) cachedOpenAiSpeechApiKey = trimmed } @@ -79,13 +77,13 @@ export function readOpenAiSpeechApiKey(): string { const raw = readFileSync(keyPath) const legacyJson = readLegacyJsonStoredOpenAiKey() if (legacyJson) { - cachedOpenAiSpeechApiKey = safeStorage.decryptString( + cachedOpenAiSpeechApiKey = getSecretStore().decryptString( Buffer.from(legacyJson.encryptedKeyBase64, 'base64') ) return cachedOpenAiSpeechApiKey } - cachedOpenAiSpeechApiKey = safeStorage.isEncryptionAvailable() - ? safeStorage.decryptString(raw) + cachedOpenAiSpeechApiKey = getSecretStore().isEncryptionAvailable() + ? getSecretStore().decryptString(raw) : raw.toString('utf8') return cachedOpenAiSpeechApiKey } catch { diff --git a/src/shared/secret-store.test.ts b/src/shared/secret-store.test.ts new file mode 100644 index 00000000000..c0478472cd5 --- /dev/null +++ b/src/shared/secret-store.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { + getSecretStore, + hasSecretStore, + _resetSecretStoreForTests, + setSecretStore, + type SecretStore +} from './secret-store' + +function fakeStore(overrides: Partial = {}): SecretStore { + return { + isEncryptionAvailable: () => true, + encryptString: (plainText) => Buffer.from(`sealed:${plainText}`), + decryptString: (cipher) => cipher.toString().slice('sealed:'.length), + describeUnavailable: () => null, + ...overrides + } +} + +describe('SecretStore registry', () => { + beforeEach(() => { + _resetSecretStoreForTests() + }) + + it('throws until a store is installed, rather than defaulting to one that cannot seal', () => { + expect(hasSecretStore()).toBe(false) + expect(() => getSecretStore()).toThrow(/SecretStore not initialized/) + }) + + it('returns the installed store', () => { + const store = fakeStore() + setSecretStore(store) + expect(hasSecretStore()).toBe(true) + expect(getSecretStore()).toBe(store) + expect(getSecretStore().encryptString('token').toString()).toBe('sealed:token') + }) + + it('lets a later install replace an earlier one, so a test fake wins over the global default', () => { + setSecretStore(fakeStore()) + setSecretStore(fakeStore({ isEncryptionAvailable: () => false })) + expect(getSecretStore().isEncryptionAvailable()).toBe(false) + }) + + it('carries a reason when sealing is unavailable, so the degradation can be surfaced', () => { + setSecretStore( + fakeStore({ + isEncryptionAvailable: () => false, + describeUnavailable: () => 'The OS keyring is unavailable.' + }) + ) + expect(getSecretStore().describeUnavailable()).toBe('The OS keyring is unavailable.') + }) +}) diff --git a/src/shared/secret-store.ts b/src/shared/secret-store.ts new file mode 100644 index 00000000000..4f3d594e207 --- /dev/null +++ b/src/shared/secret-store.ts @@ -0,0 +1,66 @@ +/** + * SecretStore abstracts at-rest secret encryption that the desktop gets from + * Electron's `safeStorage` (OS keychain). A plain-Node host installs its own + * implementation so core modules never import `electron`. + * + * The contract mirrors safeStorage exactly, including the part that matters most: + * `isEncryptionAvailable()` may return false. A store that cannot seal must say so + * rather than throw; how a caller degrades is its own decision (persistence retains + * the prior sealed blob rather than writing plaintext). See `describeUnavailable()`, + * which exists so the reason reaches the user, not a console warning nobody reads. + */ + +export type SecretStore = { + isEncryptionAvailable(): boolean + encryptString(plainText: string): Buffer + decryptString(cipher: Buffer): string + /** + * Why: "encryption unavailable" is a security posture, not a detail. When + * `isEncryptionAvailable()` is false this returns a short, user-safe sentence + * explaining which host facility is missing, for the degradation surface. + * Returns null when encryption IS available. + */ + describeUnavailable(): string | null +} + +/** + * Why a global symbol and not a module-level `let`: `vi.resetModules()` gives the + * re-imported graph a fresh copy of this module, so a store installed before the reset + * would silently read back as uninstalled — and `getSecretStore()` throws on that. + * Anchoring to the realm keeps one instance per process however often the module + * registry is rebuilt. + */ +const SLOT = Symbol.for('orca.host.secretStore') + +type Slot = { [SLOT]?: SecretStore | null } + +function slot(): Slot { + return globalThis as unknown as Slot +} + +function read(): SecretStore | null { + return slot()[SLOT] ?? null +} + +export function setSecretStore(store: SecretStore): void { + slot()[SLOT] = store +} + +export function getSecretStore(): SecretStore { + const current = read() + if (!current) { + throw new Error( + 'SecretStore not initialized — call setSecretStore() during startup before reading or writing secrets' + ) + } + return current +} + +export function hasSecretStore(): boolean { + return read() !== null +} + +/** Test-only: drop the installed store so suites do not leak one across files. */ +export function _resetSecretStoreForTests(): void { + slot()[SLOT] = null +} From fb5c9a1fe61f5340cc439380c7c0c84b7e407a73 Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:22:49 -0700 Subject: [PATCH 04/27] fix(ui): clear agent attention icon when Floating Workspace tab activated via keyboard (#15745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): clear agent attention icon when Floating Workspace tab activated via keyboard When a tab in the Floating Workspace is activated through keyboard shortcuts while the Floating Workspace is not the active worktree, the agent completion notifications were not being acknowledged, leaving the yellow attention icon visible. The issue was that `useAutoAckViewedAgent` only checked the global `activeTabId`, which doesn't change when the Floating Workspace is not the active worktree. Now the hook also watches the Floating Workspace's active tab (`activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]`) and acknowledges agents when they become visible in the Floating Workspace, regardless of whether it's the active worktree. Fixes #15700 * fix(ui): gate Floating Workspace auto-ack on panel visibility The floating-workspace scan added in the previous commit had no visibility gate. The panel stays mounted while closed (use-floating-workspace-panel: shouldMountPanel), so its layout still resolves an active leaf and the hook acked a floating agent completion the moment it landed — silently killing the minimized toggle's attention dot (selectFloatingWorkspaceHasUnread), which is the only "unseen floating activity" signal a closed panel has. - Scan the floating tab only while the panel is actually visible (isFloatingWorkspacePanelVisible), so a closed panel keeps its dot. - Move the activeView filter onto the main-worktree target only: the panel is an overlay above every view, so it must ack from the activity/tasks views too. - Carry the owning worktree with each target instead of re-deriving it by tab id, and keep the first entry on a tab-id collision, so a duplicate id can no longer clear the wrong worktree's unread. Drops the `as string[]` cast. - Re-scan on TOGGLE_FLOATING_TERMINAL_EVENT (next frame, after aria-hidden commits) since panel open/closed is React state the store never sees — opening onto an already-active completed tab now acks. - Cover the new resolveAutoAckTabTargets helper, including the closed-panel regression asserted against selectFloatingWorkspaceHasUnread. * fix(ui): re-scan floating workspace auto-ack on every panel-open path The visibility gate read the panel's aria-hidden and only re-scanned on TOGGLE_FLOATING_TERMINAL_EVENT, so the two paths that open the panel without that event — the floatingWorkspace.maximize keybinding and the default floating-button toggle — left an already-active completed tab's attention icon lit. Drive the gate from the committed `enabled && open` state instead: that is what aria-hidden is derived from, it covers every open path, and it drops the requestAnimationFrame that existed only to outrun the un-committed DOM read. Adds a hook-level test (happy-dom) that fails both when the gate is removed and when the open re-scan is removed. * fix(ui): re-read store state per auto-ack target Acking the first target writes to the store and re-enters the scan synchronously, so the pre-write snapshot could re-ack a target the nested pass already handled. Idempotent today; a footgun for the next non-idempotent action. --------- Co-authored-by: Claude Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- src/renderer/src/App.tsx | 6 +- .../src/app-shell/use-app-shell-services.ts | 4 +- ...eAutoAckViewedAgent.floating-panel.test.ts | 102 ++++++++++++++ .../src/hooks/useAutoAckViewedAgent.test.ts | 124 ++++++++++++++++++ .../src/hooks/useAutoAckViewedAgent.ts | 120 +++++++++++++---- 5 files changed, 325 insertions(+), 31 deletions(-) create mode 100644 src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5ef5ecdd9bc..cdd08d323d6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -37,7 +37,11 @@ function App(): React.JSX.Element { const onboardingGate = useOnboardingAndFeatureTips() const clearUnreadDockBadge = useUnreadDockBadge() - useAppShellServices() + // Why enabled && open: the overlay only renders while the feature is on, and its panel is + // aria-hidden while closed — so that pair is what "on screen" means for the floating workspace. + useAppShellServices({ + floatingPanelVisible: floatingWorkspace.enabled && floatingWorkspace.open + }) useAppStartupHydration(onboardingGate.applyStartupOnboardingState) useAppSessionPersistence() useRuntimeGraphSync() diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 1bffafdb4f2..68db2874593 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -22,7 +22,7 @@ import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime- * the component that consumes its result unmounts (right sidebar, explorer, terminal) or is * absent entirely on the landing path. */ -export function useAppShellServices(): void { +export function useAppShellServices(options: { floatingPanelVisible: boolean }): void { const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) const persistedUIReady = useAppStore((s) => s.persistedUIReady) const primarySelectionMiddleClickPaste = useAppStore((s) => @@ -40,7 +40,7 @@ export function useAppShellServices(): void { // Why: wire file-change watching at App level so the editor keeps hearing FS changes when Explorer unmounts (right-sidebar switches to Source Control/Checks). useEditorExternalWatch() useGlobalFileDrop() - useAutoAckViewedAgent() + useAutoAckViewedAgent(options.floatingPanelVisible) useAppMenuPaste() useAppMenuSelectionActions() useLargeTextControlPaste() diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts new file mode 100644 index 00000000000..fc31eaa9506 --- /dev/null +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' +import { useAppStore } from '../store' +import { selectFloatingWorkspaceHasUnread } from '../store/selectors' +import { makeTab } from '../store/slices/store-test-helpers' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { makePaneKey } from '../../../shared/stable-pane-id' + +const FLOATING_TAB_ID = 'tab-floating' +const MAIN_TAB_ID = 'tab-main' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const MAIN_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const FLOATING_PANE_KEY = makePaneKey(FLOATING_TAB_ID, LEAF_ID) +const MAIN_PANE_KEY = makePaneKey(MAIN_TAB_ID, MAIN_LEAF_ID) + +function seedFloatingCompletion(): void { + useAppStore.setState({ + activeView: 'activity', + activeTabId: null, + activeWorktreeId: 'wt-1', + activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: FLOATING_TAB_ID }, + tabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + makeTab({ id: FLOATING_TAB_ID, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + ] + }, + terminalLayoutsByTabId: { + [FLOATING_TAB_ID]: { root: null, activeLeafId: LEAF_ID, expandedLeafId: null } + }, + unreadTerminalTabs: {}, + unreadAgentCompletionPanes: {}, + acknowledgedAgentsByPaneKey: {} + }) +} + +describe('useAutoAckViewedAgent — floating workspace panel visibility', () => { + beforeEach(() => { + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + seedFloatingCompletion() + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('keeps the attention dot lit while the panel is closed and clears it once it opens', () => { + const hook = renderHook( + ({ floatingPanelVisible }: { floatingPanelVisible: boolean }) => + useAutoAckViewedAgent(floatingPanelVisible), + { initialProps: { floatingPanelVisible: false } } + ) + + // The dot is the only signal a closed panel has, so a store write while hidden must not ack it. + useAppStore.getState().markAgentCompletionPaneUnread(FLOATING_PANE_KEY) + expect(selectFloatingWorkspaceHasUnread(useAppStore.getState())).toBe(true) + + hook.rerender({ floatingPanelVisible: true }) + + expect(selectFloatingWorkspaceHasUnread(useAppStore.getState())).toBe(false) + }) + + // Why: the only case where the scan acks two targets, and acking the first re-enters the scan + // synchronously — so this is the path where a stale mid-loop snapshot would surface. + it('clears both the active terminal tab and the floating tab in one pass', () => { + useAppStore.setState({ + activeView: 'terminal', + activeTabId: MAIN_TAB_ID, + activeWorktreeId: 'wt-1', + tabsByWorktree: { + 'wt-1': [makeTab({ id: MAIN_TAB_ID, worktreeId: 'wt-1' })], + [FLOATING_TERMINAL_WORKTREE_ID]: [ + makeTab({ id: FLOATING_TAB_ID, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + ] + }, + terminalLayoutsByTabId: { + [MAIN_TAB_ID]: { root: null, activeLeafId: MAIN_LEAF_ID, expandedLeafId: null }, + [FLOATING_TAB_ID]: { root: null, activeLeafId: LEAF_ID, expandedLeafId: null } + } + }) + + const hook = renderHook( + ({ floatingPanelVisible }: { floatingPanelVisible: boolean }) => + useAutoAckViewedAgent(floatingPanelVisible), + { initialProps: { floatingPanelVisible: false } } + ) + + useAppStore.getState().markAgentCompletionPaneUnread(MAIN_PANE_KEY) + useAppStore.getState().markAgentCompletionPaneUnread(FLOATING_PANE_KEY) + expect(selectFloatingWorkspaceHasUnread(useAppStore.getState())).toBe(true) + + hook.rerender({ floatingPanelVisible: true }) + + const state = useAppStore.getState() + expect(state.unreadAgentCompletionPanes[MAIN_PANE_KEY]).toBeUndefined() + expect(state.unreadAgentCompletionPanes[FLOATING_PANE_KEY]).toBeUndefined() + expect(selectFloatingWorkspaceHasUnread(state)).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts index e396a7e5fa2..d461dd13b90 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts @@ -3,10 +3,13 @@ import { acknowledgeViewedAgentAttention, computeAutoAckTargets, computeViewedAgentCompletionPaneKey, + resolveAutoAckTabTargets, shouldClearViewedAgentWorktreeUnread } from './useAutoAckViewedAgent' import { createTestStore, makeTab } from '../store/slices/store-test-helpers' +import { selectFloatingWorkspaceHasUnread } from '../store/selectors' import type { RetainedAgentEntry } from '../store/slices/agent-status' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { makePaneKey } from '../../../shared/stable-pane-id' const CODEX_LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -379,3 +382,124 @@ describe('shouldClearViewedAgentWorktreeUnread', () => { ).toBe(false) }) }) + +describe('resolveAutoAckTabTargets', () => { + const FLOATING_TAB_ID = 'tab-floating' + const baseState = { + activeView: 'terminal', + activeTabId: 'tab-1', + activeWorktreeId: 'wt-1', + activeTabIdByWorktree: { + 'wt-1': 'tab-1', + [FLOATING_TERMINAL_WORKTREE_ID]: FLOATING_TAB_ID + } + } + + it('scans the floating tab alongside the main tab while the panel is visible', () => { + expect(resolveAutoAckTabTargets(baseState, { floatingPanelVisible: true })).toEqual([ + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { tabId: FLOATING_TAB_ID, worktreeId: FLOATING_TERMINAL_WORKTREE_ID } + ]) + }) + + it('skips the floating tab while the panel is closed', () => { + expect(resolveAutoAckTabTargets(baseState, { floatingPanelVisible: false })).toEqual([ + { tabId: 'tab-1', worktreeId: 'wt-1' } + ]) + }) + + it('scans the floating tab outside the terminal view because the panel overlays every view', () => { + expect( + resolveAutoAckTabTargets( + { ...baseState, activeView: 'activity' }, + { floatingPanelVisible: true } + ) + ).toEqual([{ tabId: FLOATING_TAB_ID, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }]) + }) + + it('scans nothing outside the terminal view with the panel closed', () => { + expect( + resolveAutoAckTabTargets( + { ...baseState, activeView: 'activity' }, + { floatingPanelVisible: false } + ) + ).toEqual([]) + }) + + it('keeps the real worktree when one tab id is claimed by both worktrees', () => { + expect( + resolveAutoAckTabTargets( + { ...baseState, activeTabId: FLOATING_TAB_ID }, + { floatingPanelVisible: true } + ) + ).toEqual([{ tabId: FLOATING_TAB_ID, worktreeId: 'wt-1' }]) + }) +}) + +// Why: the minimized toggle's attention dot is the only signal a closed floating panel has, so a +// hidden panel must never auto-ack (selectFloatingWorkspaceHasUnread → FloatingTerminalToggleButton). +describe('floating workspace auto-ack against the attention dot', () => { + const FLOATING_TAB_ID = 'tab-floating' + const floatingPaneKey = makePaneKey(FLOATING_TAB_ID, CODEX_LEAF_ID) + + function seedFloatingCompletion(): ReturnType { + const store = createTestStore() + store.setState({ + activeView: 'terminal', + activeTabId: 'tab-1', + activeWorktreeId: 'wt-1', + activeTabIdByWorktree: { + 'wt-1': 'tab-1', + [FLOATING_TERMINAL_WORKTREE_ID]: FLOATING_TAB_ID + }, + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })], + [FLOATING_TERMINAL_WORKTREE_ID]: [ + makeTab({ id: FLOATING_TAB_ID, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + ] + } + }) + store.getState().markAgentCompletionPaneUnread(floatingPaneKey) + return store + } + + function runAutoAckScan( + store: ReturnType, + floatingPanelVisible: boolean + ): void { + const state = store.getState() + for (const target of resolveAutoAckTabTargets(state, { floatingPanelVisible })) { + const activePaneKey = computeViewedAgentCompletionPaneKey(state, target.tabId, CODEX_LEAF_ID) + const paneKeysToClear = new Set(activePaneKey ? [activePaneKey] : []) + acknowledgeViewedAgentAttention(store.getState(), { + activeWorktreeId: shouldClearViewedAgentWorktreeUnread(store.getState(), { + activeWorktreeId: target.worktreeId, + activeTabId: target.tabId, + paneKeysToClear + }) + ? target.worktreeId + : null, + activeTabId: target.tabId, + paneKeys: [], + activePaneKey + }) + } + } + + it('keeps the attention dot lit while the panel is closed', () => { + const store = seedFloatingCompletion() + expect(selectFloatingWorkspaceHasUnread(store.getState())).toBe(true) + + runAutoAckScan(store, false) + + expect(selectFloatingWorkspaceHasUnread(store.getState())).toBe(true) + }) + + it('clears the attention dot once the panel is visible', () => { + const store = seedFloatingCompletion() + + runAutoAckScan(store, true) + + expect(selectFloatingWorkspaceHasUnread(store.getState())).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.ts index 4e471aa5eed..67f7be66742 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.ts @@ -1,5 +1,6 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useAppStore } from '@/store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import type { AgentStatusEntry } from '../../../shared/agent-status-types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' @@ -145,25 +146,69 @@ export function acknowledgeViewedAgentAttention( } } +export type AutoAckTabTarget = { tabId: string; worktreeId: string | null } + +/** + * Tabs whose visible pane counts as "seen" right now, each paired with the worktree that owns it. + * + * Why the floating workspace is gated on panel visibility rather than `activeView`: the panel is an + * overlay that sits above every view and stays mounted while closed, and its active tab never + * becomes the global `activeTabId` — so neither the view nor the tab id can stand in for "on screen". + */ +export function resolveAutoAckTabTargets( + state: { + activeView: string + activeTabId: string | null + activeWorktreeId: string | null + activeTabIdByWorktree: Record + }, + options: { floatingPanelVisible: boolean } +): AutoAckTabTarget[] { + const targets: AutoAckTabTarget[] = [] + if (state.activeView === 'terminal' && state.activeTabId) { + targets.push({ tabId: state.activeTabId, worktreeId: state.activeWorktreeId }) + } + if (options.floatingPanelVisible) { + const floatingTabId = state.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null + // Why first-wins on a tab-id collision: tab ids can be claimed by two worktrees + // (see active-tab-owner-worktree), and acking under the wrong one strands its unread dot. + if (floatingTabId && !targets.some((target) => target.tabId === floatingTabId)) { + targets.push({ tabId: floatingTabId, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + } + } + return targets +} + // Auto-ack an agent row as "seen" when the user is already on its tab, so the dashboard/Dock don't stay bold for an event they watched happen. // Scans live + retained maps: Codex's title-revert (pty-connection.ts:onAgentExited) migrates `done` rows to retained mid-race — see docs/codex-agent-row-bold-stuck.md. -export function useAutoAckViewedAgent(): void { +export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { + // Why a ref: the scan loop is mounted once, but panel visibility is React-local state that never + // reaches the store, and re-subscribing on every open/close would drop the accumulated diff refs. + const floatingPanelVisibleRef = useRef(floatingPanelVisible) + const rescanRef = useRef<(() => void) | null>(null) + useEffect(() => { // Why: the store uses plain create() (no subscribeWithSelector), so manually track the slices we depend on to skip unrelated updates. // Init to undefined so the first maybeAck() (on mount) always passes the ref guard and scans. let lastActiveView: unknown = undefined let lastActiveTabId: unknown = undefined + let lastFloatingWorkspaceActiveTabId: unknown = undefined let lastAgentStatus: unknown = undefined let lastRetained: unknown = undefined let lastAcknowledged: unknown = undefined let lastLayouts: unknown = undefined let lastUnreadAgentCompletionPanes: unknown = undefined - const maybeAck = (): void => { + // `force` re-scans after a signal the store never sees: panel open/closed is React-local state. + const maybeAck = (options?: { force?: boolean }): void => { const s = useAppStore.getState() + const floatingWorkspaceActiveTabId = + s.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null if ( + !options?.force && s.activeView === lastActiveView && s.activeTabId === lastActiveTabId && + floatingWorkspaceActiveTabId === lastFloatingWorkspaceActiveTabId && s.agentStatusByPaneKey === lastAgentStatus && s.retainedAgentsByPaneKey === lastRetained && s.acknowledgedAgentsByPaneKey === lastAcknowledged && @@ -173,9 +218,6 @@ export function useAutoAckViewedAgent(): void { return } - if (s.activeView !== 'terminal') { - return - } // Why: tab-active only proxies "seen"; gate on window visible+focused so away-time transitions don't silently clear the bold signal. if (typeof document !== 'undefined') { if (document.visibilityState !== 'visible') { @@ -185,53 +227,75 @@ export function useAutoAckViewedAgent(): void { return } } - const activeTabId = s.activeTabId - if (!activeTabId) { + const targets = resolveAutoAckTabTargets(s, { + floatingPanelVisible: floatingPanelVisibleRef.current + }) + if (targets.length === 0) { return } - const activeLeafId = resolveActiveLeafId(s, activeTabId) // Why: advance refs only after gates pass, else the diff is consumed and a gated-out transition never re-acks when focus returns. lastActiveView = s.activeView lastActiveTabId = s.activeTabId + lastFloatingWorkspaceActiveTabId = floatingWorkspaceActiveTabId lastAgentStatus = s.agentStatusByPaneKey lastRetained = s.retainedAgentsByPaneKey lastAcknowledged = s.acknowledgedAgentsByPaneKey lastLayouts = s.terminalLayoutsByTabId lastUnreadAgentCompletionPanes = s.unreadAgentCompletionPanes - const toAck = computeAutoAckTargets(s, activeTabId, activeLeafId) - const activePaneKey = computeViewedAgentCompletionPaneKey(s, activeTabId, activeLeafId) - if (toAck.length > 0 || activePaneKey) { - const paneKeysToClear = new Set(toAck) - if (activePaneKey) { - paneKeysToClear.add(activePaneKey) - } - acknowledgeViewedAgentAttention(s, { - activeWorktreeId: shouldClearViewedAgentWorktreeUnread(s, { - activeWorktreeId: s.activeWorktreeId, - activeTabId, - paneKeysToClear + + for (const target of targets) { + // Why re-read: acking target[0] writes to the store, which re-enters this scan synchronously + // and may already have handled target[1]; `s` is a pre-write snapshot that would re-ack it. + const current = useAppStore.getState() + const tabId = target.tabId + const activeLeafId = resolveActiveLeafId(current, tabId) + const toAck = computeAutoAckTargets(current, tabId, activeLeafId) + const activePaneKey = computeViewedAgentCompletionPaneKey(current, tabId, activeLeafId) + if (toAck.length > 0 || activePaneKey) { + const paneKeysToClear = new Set(toAck) + if (activePaneKey) { + paneKeysToClear.add(activePaneKey) + } + const worktreeId = target.worktreeId + acknowledgeViewedAgentAttention(current, { + activeWorktreeId: shouldClearViewedAgentWorktreeUnread(current, { + activeWorktreeId: worktreeId, + activeTabId: tabId, + paneKeysToClear + }) + ? worktreeId + : null, + activeTabId: tabId, + paneKeys: toAck, + activePaneKey }) - ? s.activeWorktreeId - : null, - activeTabId, - paneKeys: toAck, - activePaneKey - }) + } } } + rescanRef.current = (): void => maybeAck({ force: true }) // Why: run once on mount to catch a restored session that already has agents on the visible tab. maybeAck() // Subscribe to all store changes; the ref-equality guard above skips unrelated updates. - const unsubscribe = useAppStore.subscribe(maybeAck) + const unsubscribe = useAppStore.subscribe(() => maybeAck()) // Why: focus/visibility don't flow through zustand, so re-run the scan on these DOM events when focus returns. const onVisibility = (): void => maybeAck() const onFocus = (): void => maybeAck() document.addEventListener('visibilitychange', onVisibility) window.addEventListener('focus', onFocus) return () => { + rescanRef.current = null unsubscribe() document.removeEventListener('visibilitychange', onVisibility) window.removeEventListener('focus', onFocus) } }, []) + + // Why forced: opening the panel puts an already-active floating tab on screen without any store + // write, so the equality guard would skip the scan that clears its attention dot. + useEffect(() => { + floatingPanelVisibleRef.current = floatingPanelVisible + if (floatingPanelVisible) { + rescanRef.current?.() + } + }, [floatingPanelVisible]) } From 063b8042988bd4c7df1f7acf08949b36be082e04 Mon Sep 17 00:00:00 2001 From: erish Date: Sun, 23 Aug 2026 10:19:05 +0900 Subject: [PATCH 05/27] fix(i18n): match CheckRunJobs succeeded to its sibling count-label register (#16013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit succeeded rendered as casual declarative 성공했다 ('it succeeded') next to skipped's polite 건너뛰었습니다 and pending's noun-phrase 보류 중, in a summary that joins all three after a count: '3 성공했다 · 1 건너뛰었습니다'. Machine translation read succeeded as a finished sentence instead of the noun label the other two siblings use. Switch to 성공 and pin it in the key-override file so the catalog regen script can't revert it; #15875 fixed five other keys in the same family but its hardcoded regression map didn't cover this one. --- config/scripts/locale-ko-key-overrides.json | 3 +++ src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts | 1 + src/renderer/src/i18n/locales/ko.json | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 1f51cabc1d3..5f7f8c37530 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -848,6 +848,9 @@ "auto.components.editor.CheckRunDetailsPanel.fd46a70f1a": { "ko": "시작됨" }, + "auto.components.editor.CheckRunJobs.1c0a4d7e02": { + "ko": "성공" + }, "auto.components.editor.CombinedDiffFileTree.d5ac717d65": { "ko": "commit 안 됨" }, diff --git a/src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts b/src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts index 901bfe00250..437bcd9102b 100644 --- a/src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts +++ b/src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts @@ -23,6 +23,7 @@ const correctedValues = { '터미널과 개발 도구가 로컬 네트워크의 서비스에 연결할 수 있도록 허용합니다. macOS는 이 권한의 현재 상태를 Orca에 보고하지 않습니다.', 'auto.components.sidebar.AddRemoteHostDialog.sshImportSynced': '{{value0}} 호스트{{value1}}을(를) Orca에 추가했습니다.', + 'auto.components.editor.CheckRunJobs.1c0a4d7e02': '성공', 'components.native-chat.approval.allow': '허용', 'components.native-chat.approval.deny': '거부' } as const diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c7ec4f59e42..c3e8f863415 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13211,7 +13211,7 @@ "tooLarge": "붙여넣기 내용이 너무 큽니다." }, "CheckRunJobs": { - "1c0a4d7e02": "성공했다", + "1c0a4d7e02": "성공", "2d3b8f1a55": "건너뛰었습니다", "3e6c9a2b71": "보류 중", "4f7d0c3e88": "단계 실패", From e9e238c88340621a1bb82b60f5a8fe11e84961cf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:35:59 -0700 Subject: [PATCH 06/27] refactor(wsl): delete the environment-policy layer the reviews kept failing on (#16007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(wsl): delete the environment-policy layer the reviews kept failing on A design council (Opus, Grok, GPT-5.6-Sol) reviewed the merged runner after it took eleven review rounds to land. All three reached the same conclusion: the invocation half is sound, the environment/probe half is not, and every round had been debugging the second one. The finding that settled it, from Opus: `environmentResolved` had **54 references, all in tests and the runner itself. Not one production reader.** The safety mechanism the strict default existed for was never wired to anything, so all 19 degrading sites reported absence with full confidence anyway -- #9725 live at every one, under comments claiming it was handled. Two of those comments say so out loud; I wrote them. Root cause, in one line: every knob existed only because a failed probe was fatal. So it no longer is. - `allowDegradedEnvironment` and `WslGuestEnvironmentUnavailableError` are gone. A missing login PATH is a fact in the result, not an exception. That deletes 23 opt-outs, six catch-and-remap blocks, the transient/rejected cooldown split, `probedWithBudget`, and the 1.5x re-probe heuristic -- none of which had a reason to exist once the case stopped throwing. - `lane` + `allowDegradedEnvironment` collapse into `loginPath: 'none' | 'preferred'`. 19 of 23 sites passed the opt-out, and two said in comments that they did not want the login PATH at all: the flag had become the `'none'` the union was missing. - The `interactive` lane is deleted. It had zero production callers and kept ~30 lines of fence plumbing alive for tests only. Net -98 production lines; the runner itself sheds 86 for 38. Also carries three fixes from the W3 orphan-PR sweep I had not done: - `WSL_UTF8=1` in the runner. My relay migration deleted the only place setting it, so wsl.exe's own error text arrived UTF-16LE and read as NUL-riddled. A regression I introduced. Credit: #9010 (Chang-Jin-Lee). - `GITLAB_HOST` is now named in WSLENV, so a ported self-hosted host actually crosses into a distro-routed glab (#12557). Credit: #12558 (makoto-developer). - The WSL skill-setup command pipes into `sh` instead of `eval "$(...)"`, whose nested quoting produced `word unexpected (expecting "in")` (#14292). Credit: #14785 (innocarpe). * fix(wsl): restore the login PATH for the Codex availability lookup loginPath:'none' on a PATH lookup reports an nvm-installed codex as absent, which is #9725. A miss without a resolved environment is now 'could not check', not 'not installed'. Also hardens the guards that should have caught it: - bashism ratchet is per-call, not per-file, and fails closed on lexer desync - blankStringContents handles regex literals (an apostrophe in /'/g desynced the lexer, so the scan silently found zero calls) - windowsHide allowlist 85 -> 80, stale once the lexer parsed those files Credit: Grok (P0), GPT-Sol (ratchet gaps). * test(wsl): close the two ratchet gaps that let planted spawns pass - variable-indirected wsl.exe (`const b = 'wsl.exe'; spawnProcess(b)`) is now tracked, so the 5 files recorded only in a comment become real allowlist entries. Three actually spawn that way; the other two never spawned wsl.exe at all, so the prose record was wrong by three in the hiding direction. - promisify(renamedAlias) is now resolved, so `const run = promisify(execFile)` behind an `execFile as x` import can no longer skip windowsHide. Each verified by planting the violation, watching it fail, restoring, watching it pass. Credit: GPT-Sol. * fix(source-scan): stop the regex-literal reader from eating block comments At index 0 there is no preceding token, so a file opening with a banner comment had its `/*` read as a pattern and swallowed to the next slash -- 110k characters of preload/index.ts, in the direction that hides offenders. Measured across the tree, old lexer vs new: worst-case over-blanking drops from -110564 to -1116 characters, and files that desync drop from 51 to 22. The remaining extra blanking is regex interiors, which is the intent. Regression tests for both lexer bugs, each verified to fail with its fix reverted. The first draft of the comment test did not bind -- it asserted on text after the swallowed span. * fix(wsl): restore the unverifiable signal on the two remaining probe sites Round 2. Three call sites used to throw when the login-PATH probe failed; the redesign rewired one (Codex) and left two reporting confident absence. - skill-wsl-provider-detection: the script ends in `|| true`, so a lookup without the login PATH exits 0 with empty stdout -- identical to 'nothing installed'. Callers skip the ~/.codex and ~/.claude skill roots on an empty list, losing an nvm-installed provider's skills. - wsl-cli-installer: the dead catch is replaced by an explicit check. Its `case ":$PATH:"` probe otherwise answers from the distro default PATH and Settings states as fact that the CLI is not on PATH. Timeout is checked first, since a timed-out run also leaves the environment unresolved. Also narrows the regex-literal prev-token set. '!', '+', '-', '>' and '}' are value terminators as often as operators, so postfix `n-- / 2` and JSX ` : ` to a script piped into `bash -s`. Anything the script runs that reads stdin then drains the rest of the script, bash hits EOF and exits 0, and the caller logs success -- an orca.yaml hook of `ssh -T git@github.com || true` followed by `pnpm install` silently never installs. Scripts now travel in argv by default, which is what the pre-migration code did and what --exec makes safe. `scriptDelivery: 'stdin'` stays for the one caller that needs it: the hook-relay installer embeds a base64 JS bundle far past any command-line limit, and reads no stdin. A runner test already described this exact EOF hazard -- for the login shell, not for the guest command it was itself creating. Credit: code review. * fix(skills): make the unverifiable check unconditional, and stop double-probing Round 3. - provider detection threw only on an EMPTY result, so a degraded partial hit slipped through: `claude` visible on the default PATH via Windows interop plus an nvm-only `codex` returns a plausible ['claude'], and the caller then skips the ~/.codex skill roots for a provider that is installed. The installer already got this right with an unconditional throw. - three sites asked for 'preferred' without needing it. The GROK_HOME probe runs its own `"$login_shell" -lc`, so the runner's probe was a second login shell eating up to half an 8s budget; the two skill scans are find/base64/head/printf/stat over $HOME. - the indirection binder missed `private readonly x = 'wsl.exe'` (the modifier was captured as the name), backtick literals, and `spawnProcess(this.x)`. Commit 2bbbd99 claimed that gap closed; it now is, verified against all three shapes. Credit: Grok. * fix(child-process): keep the tail of output whose failure lands last Two console-flash bugs the ratchet was carrying on its allowlist rather than catching: daemon-process-inspection execs powershell.exe and the gemini extractor execs `where gemini`, both console-subsystem, both without windowsHide (#10488). Allowlist 80 -> 78. And a migration regression: the hook-relay install used to keep a rolling tail of stderr (`slice(-MAX)`), while runProcess's maxOutputBytes keeps the head. A guest install that fails after pages of apt warnings therefore reported the warnings instead of `mv: Read-only file system`. runProcess takes retainOutput: 'tail' for output whose meaning is at the end. Credit: code review. * test(wsl): close the last two indirection shapes in the binder `this.binary = 'wsl.exe'` has no declarator keyword, and a helper that just returns the literal is a spawn one hop away that no regex can follow. The return case fails closed only when the file also spawns something -- local-windows-terminal-runtime.ts returns the name as terminal metadata and never spawns, so a blanket rule flagged it wrongly. Verified against both shapes: planted, failed, restored, passed. Credit: Opus. * fix(preflight): stop reporting installed WSL CLIs as absent (#9725) The last two probe sites that turned an unresolvable login PATH into a confident negative. The native branch of detectInstalledAgents already consults install dirs for exactly this reason ('PATH may still be unhydrated on a cold GUI launch'); the WSL branch had no equivalent, so a cold distro made an nvm-installed claude/codex read as not installed and told the user to install a CLI their own terminal runs. Ports that fallback to the guest: agent detection checks the version-manager bin dirs for commands the PATH lookup missed, and the preflight command runner APPENDS them to PATH -- append, never prepend, so a resolved login PATH stays authoritative and a stale nvm version cannot shadow the real binary. Tested by executing the generated scripts through /bin/sh against planted binaries, since the behaviour is shell globbing and [ -x ]. Both the nvm-discovery and the no-shadowing tests were verified to fail when reverted. * fix(codex-accounts): hide the console on the legacy active-home migration execFileSync('wsl.exe') with no windowsHide flashes a conhost and steals foreground on a GUI-launched Orca (#10488). Sibling WSL spawns got this in earlier commits; this one only had its quoting rewritten. Allowlist 75 -> 74. Credit: code review. * fix(windows): close the shell:true hole that made windowsHide a no-op I un-allowlisted the gemini extractor after adding `windowsHide: true` to an `exec()` call. `exec` implies `shell: true`, which this repo's own chokepoint documents as silently making windowsHide a no-op (#14543) -- so the site still flashed a conhost while reading as guarded. Now execFile('where.exe', …), matching the relay sibling that already did it right. The ratchet could not see that, which is why it passed. It now treats a call that resolves to exec/execSync, or any `shell: true`, as unguarded regardless of windowsHide -- including through renamed imports and a renamed promisify. Also: a script over 8000 chars now falls back to stdin. Windows caps a command line at 32767 and a user's orca.yaml hook is the one unbounded script Orca runs (`run-both` concatenates two; a vendored installer is ~15KB), so argv would fail to spawn outright. Degrading beats failing. And the binder now sees `let p: string` ... `p = 'wsl.exe'`. Each verified by planting. Credit: Grok. * test(wsl): an opaque payload must declare its interpreter My per-call bashism guard REPLACED the file-wide one, and that was a strict regression: the real payloads are built in a separate function and passed as a bare `script,`, so the bashism is never inside the call literal and the per-call arm cannot fire. Deleting `shell: 'bash'` from skill-discovery-wsl -- `done < <(find ...)` and `read -r -d ''`, the #14292 signature -- passed on this branch and failed on main. Reading through the identifier is guesswork. Requiring the call to name its shell when the payload is not a literal is not, so seven POSIX call sites now say `shell: 'sh'` -- no behaviour change, sh was already the default. Two earlier attempts at this were wrong and are worth recording: a whole-file BASHISM test blamed codex-accounts/service.ts, which correctly pins bash on its four inline payloads and correctly leaves printf/mkdir unpinned; and excluding call text still caught a bash payload belonging to a non-runner execFileSync. Also: runProcessSync now refuses retainOutput:'tail' instead of silently keeping the head, and the union docblock no longer describes stdin delivery. Verified against both of the plants that exposed this. Credit: Opus. * test(wsl): judge an opaque payload by the file, not by whether shell is set Round 5. My previous rule -- opaque payload must have `shell:` -- was the third guard fix in a row that came out weaker than what it replaced: `shell: 'sh'` on a bash payload satisfied it, which is #14292 with extra steps. Flipping skill-discovery-wsl's pin from bash to sh shipped green. Now: strip the text of every call that already names bash, and if a bashism survives anywhere in the file while a script-carrying call is not bash-pinned, flag it. Stripping the bash-pinned calls is what keeps codex-accounts clean. Also closes four ways to hide a call from the collector, each verified by planting: - `script: \`${bashism}\`` -- a template literal read as a visible literal - `runWslProcess({ ...spec })` -- a spread hides script AND shell - `Object.assign({ a }, { script })` -- the collector took the first `{`, so it now takes the whole argument list - `import { runWslProcess as runWsl }` -- a renamed callee collected nothing, and zero calls read as zero violations Not fixed, recorded instead: a computed `shell:` in the console guard. Matching any non-false value also flags `shell: spawnConfig.shell`, a pass-through that is false in every branch, and a false positive there costs an allowlist entry that disables the guard for a whole correct file. Credit: Grok. * fix(preflight): make the guest fallback match the native one it claims to mirror Three defects in the #9725 fix from earlier today, all found by executing the generated scripts under real dash rather than reading them. - $HOME containing a space word-split the unquoted dir list into a relative path, so every CLI read as absent -- the exact symptom the fix exists to remove. Each entry is quoted now; the nvm entry quotes only its prefix so the glob still expands. - A directory passes `[ -x ]`, so ~/.local/bin/gemini/ was reported as an installed CLI that then fails to launch with EISDIR. The PATH half of the same script already guarded this, and so does the native twin. - The header called this the "guest-side twin" of the native fallback while omitting four of its directories: volta, asdf, fnm and mise. A WSL user on any of those still had #9725 while the same user on native did not -- and asdf and mise are named in the motivating comment. The claim is now true. Credit: Opus. * test(wsl): mask bash-pinned calls by position, not by String.replace `rest.replace(text, '')` with a string pattern removes only the FIRST match, so two identically-written pinned calls left one behind and its bashism then counted against an unrelated unpinned call in the same file. A body that also occurred earlier as a substring would blank the wrong region entirely. The collector now returns ranges and the mask is applied by index. Verified both directions: two identical pinned bodies plus one unpinned call flags, and the same file with all three pinned stays clean. * test(wsl): fail closed on call shapes a regex cannot attribute Round 6. Rather than widen the pattern again, treat the shapes it cannot reason about as unreadable. A regex cannot tell which object a key belongs to, so every round produced another way to put the pin in one place and the payload in another: `cond ? {pinned} : {unpinned}`, `{...} as WslSpec`, `Object.assign({a},{b})`. A call whose SPEC is chosen by a ternary or spread -- one appearing before the first `{` -- or which carries an `as` assertion is now flagged whenever the file has a bashism, with no `shell: 'bash'` escape, because the substring test that would grant the escape is exactly what cannot be trusted on these shapes. A ternary INSIDE the object is not exotic: claude-accounts/service.ts:977 uses one to choose a script line in a call that is already pinned, and treating that as opaque would demand a second pin it already has. Nor is a nested call -- `script: `x ${shellQuote(p)}`` is how every payload here is built, and flagging it would demand bash on POSIX payloads that must not have it. Also follows `const run = runWslProcess`, generics and optional chaining, and counts collected calls against mentions so a shape that slips the pattern reads as unreadable rather than clean. I tried the TypeScript parser first, which would remove the class outright. TypeScript 7 is the native port and exposes no JS compiler API; oxc-parser works but is transitive, and declaring it surfaced an unmet peer warning. Recorded here so the next person does not repeat the detour. Credit: Grok. * fix(wsl): fish is a PATH lookup, and my lint check could not fail Two things Opus caught that I had verified wrongly. `wsl-fish-history-cleanup` passes `program: 'fish'` -- a bare name, so a PATH lookup by definition, the exact class the earlier rounds hunted. I mapped it to 'none' and then defended that in an audit, because I read `allowDegradedEnvironment: true` as "does not need the login PATH". It does not mean that: it means "do not fail when the probe fails". The old call still USED the login PATH whenever it got one, which is 'preferred'. Under 'none' a fish from linuxbrew or nix is invisible and the cleanup throws. The truncated comment left behind when the flag was deleted is finished too. And `pnpm lint` has been failing on this branch while I reported it clean: I grepped for `error eslint|error oxlint`, but oxlint prints the rule category (`error typescript(array-type)`, `error unicorn(prefer-ternary)`). The grep could not match, so it never failed. Checking the exit code instead surfaced a third violation hidden behind the first two. Credit: Opus. * chore(wsl): clear the round-7 P2s - Formatting: the branch owned 22 of the tree's 26 oxfmt failures because I never ran the formatter. Branch files now own none. - resolveScriptDelivery was computed twice, in two places that must agree about argv shape and stdin payload. Resolved once and threaded through. - The allowlist header said the list only shrinks while the branch added three entries. It grew because the scanner learned to follow a variable-bound 'wsl.exe'; those three were previously recorded in prose, so the count was wrong by three in the direction that hides offenders. The header now says so. - Two test comments still explained behaviour via the deleted allowDegradedEnvironment flag; a stray triple blank line; two adjacent JSDoc blocks where only the second attached. Not taken: platform-guarding addWslEnvKeys. WSLENV is inert off Windows, and the guard broke a test that asserts the key directly -- more surface than the tidy is worth, so the reason is recorded at the call site instead. Credit: Opus. * test(preflight): plant a fabricated CLI name, not a real one CI caught what my local run could not: the runner has a real /usr/bin/gh, so `command -v gh` resolved to it and the planted nvm stub was never reached. The fallback APPENDS, so that is the code behaving correctly -- the test was asserting a property of my machine. Both real-shell suites now plant `orca-fake-cli`, which exists nowhere. Re-verified the same way as before: with the PATH fallback disabled the test fails, with it restored it passes. I declared this branch merge-ready without looking at CI. Local green is not the gate. * refactor(wsl): delete two knobs and a duplicated fallback Elegance pass. The branch had grown from a deletion into a net addition, and most of the growth was optional axes with one caller each. - `retainOutput` is gone. One production caller wanted the tail of a 64KiB buffer; head-truncation only hurt because of that cap. The caller drops the cap, keeps the default, and slices the tail itself -- which is what the live relay next door already does. Two mechanisms for one job became one. - `scriptDelivery` is gone. The size rule was already the whole design: argv unless the script is too long for a Windows command line. The option existed so a small Orca script could opt into stdin, and no such caller ever appeared. Both behaviours stay pinned: a huge script still goes to stdin, an ordinary one still leaves the hook's stdin free. - Agent detection no longer walks the fallback dirs itself. It prepends the same PATH prelude the preflight command runner uses and lets the ordinary lookup do the work. Its bespoke walk had duplicated the lookup script's `! -d` guard -- and had missed it once, which is how a directory read as an installed CLI. All 17 detection tests still pass unchanged, including the $HOME-with-a-space, directory-is-not-a-CLI, and volta/asdf/fnm/mise cases, so the collapse is behaviour-preserving rather than assumed to be. Credit: Grok. * fix(wsl): never name a path-shaped variable in WSLENV `buildHostEnv` forwarded every caller-supplied key into WSLENV. wsl.exe translates path-shaped variables between Windows and Linux form, so a caller passing PATH would have replaced the guest's own PATH with a translated Windows one -- silently, and fatally for every lookup after it. No caller passes PATH today. The point of a chokepoint is that it does not depend on that staying true. --- src/main/agent-hooks/wsl-hook-relay-launch.ts | 20 +- .../claude-accounts/runtime-auth-service.ts | 9 +- src/main/claude-accounts/service.ts | 36 +-- src/main/cli/wsl-cli-installer.test.ts | 21 ++ src/main/cli/wsl-cli-installer.ts | 31 +- .../codex-accounts/runtime-home-service.ts | 5 +- .../service-wsl-accounts.test.ts | 86 +++++- src/main/codex-accounts/service.ts | 46 ++- .../codex/wsl-codex-session-bridge.test.ts | 8 +- src/main/codex/wsl-codex-session-bridge.ts | 2 +- src/main/daemon/daemon-process-inspection.ts | 8 +- src/main/git/runner.test.ts | 15 + src/main/git/runner.ts | 10 +- src/main/hooks.test.ts | 28 +- src/main/hooks.ts | 11 +- .../ipc/preflight-agent-detection.test.ts | 132 +++++++-- .../ipc/preflight-host-cli-status.test.ts | 12 +- .../ipc/preflight-wsl-agent-detection.test.ts | 107 ++++++- src/main/ipc/preflight-wsl-agent-detection.ts | 17 +- src/main/ipc/preflight-wsl-command.test.ts | 82 +++++- src/main/ipc/preflight-wsl-command.ts | 13 +- .../rate-limits/gemini-cli-oauth-extractor.ts | 15 +- .../skills/claude-plugin-skill-sources-wsl.ts | 6 +- src/main/skills/skill-discovery-wsl.ts | 5 +- .../skills/skill-provider-runtime-roots.ts | 9 +- .../skills/skill-wsl-install-filesystem.ts | 6 +- .../skill-wsl-provider-detection.test.ts | 32 ++- .../skills/skill-wsl-provider-detection.ts | 12 +- src/main/wsl-fish-history-cleanup.test.ts | 20 +- src/main/wsl-fish-history-cleanup.ts | 11 +- .../__fixtures__/wsl-invocation-allowlist.txt | 15 +- src/main/wsl/wsl-guest-environment.ts | 4 + src/main/wsl/wsl-invocation-boundary.test.ts | 231 +++++++++++++-- src/main/wsl/wsl-runner.test.ts | 270 +++++++++--------- src/main/wsl/wsl-runner.ts | 206 +++++++------ src/main/wsl/wsl-runner.wsl.test.ts | 28 +- src/main/wsl/wsl-w1-w3-contract.test.ts | 54 ++-- .../settings/CliSkillRuntimeSetup.test.tsx | 12 +- .../settings/CliSkillRuntimeSetup.tsx | 15 +- .../windows-console-visibility-allowlist.txt | 8 - src/shared/child-process/run-process.test.ts | 15 + .../windows-console-visibility.test.ts | 53 +++- src/shared/posix-version-manager-bin-dirs.ts | 46 +++ .../source-scan/source-tree-scan.test.ts | 51 +++- src/shared/source-scan/source-tree-scan.ts | 68 ++++- 45 files changed, 1372 insertions(+), 519 deletions(-) create mode 100644 src/shared/posix-version-manager-bin-dirs.ts diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index e4f5bc24978..ba91d7432e8 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -181,18 +181,20 @@ export async function runWslInstallProcess( ): Promise<{ code: number | null; stderr: string }> { const result = await runWslProcess({ distro, - lane: 'probe', + loginPath: 'none', script, - timeoutMs: INSTALL_TIMEOUT_MS, - maxOutputBytes: MAX_STARTUP_BUFFER_BYTES, - // The install script only shells out to base64/mkdir/mv/chmod, all on any - // default PATH -- it must still run when the login-PATH probe itself is - // what's wedged, which is exactly the state this call recovers from. - allowDegradedEnvironment: true + // Declared because the payload is opaque here: it is POSIX plus a heredoc. + shell: 'sh', + timeoutMs: INSTALL_TIMEOUT_MS + // No maxOutputBytes: the default cap holds the whole stream so the slice + // below can take the end of it. }) + // Tail, not head: the operative error ("mv: Read-only file system") lands + // after whatever apt and base64 already printed. + const stderr = result.stderr.slice(-MAX_STARTUP_BUFFER_BYTES) return result.timedOut - ? { code: null, stderr: `${result.stderr}\ninstall timed out after ${INSTALL_TIMEOUT_MS}ms` } - : { code: result.code, stderr: result.stderr } + ? { code: null, stderr: `${stderr}\ninstall timed out after ${INSTALL_TIMEOUT_MS}ms` } + : { code: result.code, stderr } } const TRANSIENT_RETRY_LIMIT = 2 diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 29568d38fe4..e720378a8b8 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1088,7 +1088,7 @@ export class ClaudeRuntimeAuthService { try { const owned = await runWslProcess({ distro: wslInfo.distro, - lane: 'probe', + loginPath: 'none', shell: 'bash', script: [ 'set -euo pipefail', @@ -1100,12 +1100,7 @@ export class ClaudeRuntimeAuthService { `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(account.id)}`, 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' ].join('\n'), - timeoutMs: 5000, - // Why degraded is allowed: a failed answer here disowns the account and - // clears the user's selection, so a slow distro probe must not decide - // ownership. The script reads only $HOME and coreutils, both of which - // wsl.exe supplies without the login PATH. - allowDegradedEnvironment: true + timeoutMs: 5000 }) if (owned.timedOut) { throw new Error(OWNERSHIP_PROBE_TIMEOUT) diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index e9f18a8a5d9..2e3206eab69 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -664,13 +664,10 @@ export class ClaudeAccountService { } const created = await runWslProcess({ distro: location.wslDistro, - lane: 'probe', + loginPath: 'none', shell: 'bash', script: 'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"', - timeoutMs: 5000, - // Why degraded is allowed: mktemp is a coreutil on the default PATH, so an - // unprobed distro must not turn a working sign-in into a failed one. - allowDegradedEnvironment: true + timeoutMs: 5000 }) const linuxPath = created.stdout.replaceAll(String.fromCharCode(0), '').trim() if (created.code !== 0 || created.timedOut || !linuxPath.startsWith('/')) { @@ -692,13 +689,10 @@ export class ClaudeAccountService { try { await runWslProcess({ distro: tempConfig.wslDistro, - lane: 'probe', + loginPath: 'none', program: 'rm', args: ['-rf', '--', tempConfig.linuxPath], - timeoutMs: 5000, - // Why degraded is allowed: leaving a login temp dir behind is worse than - // running rm on the default PATH, and this is already best-effort. - allowDegradedEnvironment: true + timeoutMs: 5000 }) } catch { // Best-effort cleanup. @@ -910,13 +904,10 @@ export class ClaudeAccountService { const requestedDistro = target.wslDistro?.trim() || undefined const info = await runWslProcess({ distro: requestedDistro, - lane: 'probe', + loginPath: 'none', shell: 'bash', script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"', - timeoutMs: 5000, - // Why degraded is allowed: both variables come from wsl.exe itself, not from - // the login PATH, so an unprobed distro still answers correctly here. - allowDegradedEnvironment: true + timeoutMs: 5000 }) const [rawDistro, rawHome] = info.code === 0 && !info.timedOut @@ -934,14 +925,11 @@ export class ClaudeAccountService { const wslLinuxAuthPath = `${home.replace(/\/$/, '')}/.local/share/orca/claude-accounts/${accountId}/auth` const created = await runWslProcess({ distro, - lane: 'probe', + loginPath: 'none', shell: 'bash', script: 'mkdir -p "$1" && printf \'%s\\n\' "$2" > "$1/.orca-managed-claude-auth"', args: [wslLinuxAuthPath, accountId], - timeoutMs: 5000, - // Why degraded is allowed: mkdir is a coreutil on the default PATH, and the - // target path was already resolved from the guest's own $HOME above. - allowDegradedEnvironment: true + timeoutMs: 5000 }) if (created.code !== 0 || created.timedOut) { throw new Error('Could not create the managed WSL Claude auth directory.') @@ -978,7 +966,7 @@ export class ClaudeAccountService { try { const owned = await runWslProcess({ distro: wslInfo.distro, - lane: 'probe', + loginPath: 'none', shell: 'bash', script: [ 'set -euo pipefail', @@ -992,11 +980,7 @@ export class ClaudeAccountService { : 'test -n "$(cat "$candidate_real/.orca-managed-claude-auth")"', 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' ].join('\n'), - timeoutMs: 5000, - // Why degraded is allowed: the ownership proof is the script's own - // marker and containment checks, not the login PATH, and refusing here - // would fail every managed read/write on a distro Orca could not probe. - allowDegradedEnvironment: true + timeoutMs: 5000 }) if (owned.code !== 0 || owned.timedOut) { throw new Error('Managed Claude auth directory does not exist on disk.') diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 78d91581fae..717232509ba 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -785,6 +785,27 @@ describe('WslCliInstaller', () => { await expect(installer.getStatus()).rejects.toThrow('WSL command timed out') }) + it('reports the distro unreachable rather than "not on PATH" without the login PATH', async () => { + // The `case ":$PATH:"` probe answers from the distro default PATH when the + // login PATH never resolved, and ~/.local/bin is never on that. Settings + // would then state as fact that the CLI is not on PATH while the user's + // own terminal finds it -- #14288 turning into a confident wrong verdict. + runWslProcessMock.mockResolvedValue({ + environmentResolved: false, + code: 0, + stdout: 'no', + stderr: '', + timedOut: false + }) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus() } + }) + + await expect(installer.getStatus()).rejects.toThrow('Could not reach the WSL distro') + }) + it('refuses to remove an old managed launcher when the bridge path is user-owned', async () => { const oldLauncher = _internals.buildWslLauncher( 'C:\\Old\\orca.cmd', diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index ca38f660972..7c95be6431c 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -459,22 +459,27 @@ export class WslCliInstaller { async function runWslCommand(distro: string, command: string): Promise { // Why the probe lane fixes #14288: the prior login shell (`bash -lc`) sourced // ~/.profile, so one blocking line there ate the whole 10s timeout. - let result: Awaited> - try { - result = await runWslProcess({ - distro, - lane: 'probe', - script: command, - timeoutMs: WSL_COMMAND_TIMEOUT_MS - }) - } catch { - // Why map: the runner's raw "guest environment is unavailable" otherwise - // reaches Settings verbatim through ipc/cli.ts -> toast.error. - throw new Error('Could not reach the WSL distro. Try again.') - } + const result = await runWslProcess({ + distro, + loginPath: 'preferred', + script: command, + // Declared, not assumed: the payload is opaque here, so the guard cannot + // check it for bashisms. These are POSIX (`-eu`, `case`), hence sh. + shell: 'sh', + timeoutMs: WSL_COMMAND_TIMEOUT_MS + }) + // Timeout first: it is the more specific diagnosis, and a timed-out run also + // leaves the environment unresolved, so the order decides which one shows. if (result.timedOut) { throw new Error(`WSL command timed out after ${WSL_COMMAND_TIMEOUT_MS}ms.`) } + // Every command here reads the login PATH -- the `case ":$PATH:"` probe most + // of all. Without it that probe answers from the distro default PATH, which + // never has ~/.local/bin, and Settings states as fact that the CLI is not on + // PATH while the user's own terminal finds it. Unverifiable, not negative. + if (!result.environmentResolved) { + throw new Error('Could not reach the WSL distro. Try again.') + } if (result.code !== 0) { throw new Error(result.stderr.trim() || `WSL command failed with exit code ${result.code}.`) } diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 0bfec4abce4..c9be97fc641 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1235,7 +1235,9 @@ export class CodexRuntimeHomeService { 'fi' ].join('\n') ], - { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 } + // wsl.exe is console-subsystem: without this a GUI-launched Orca flashes + // a conhost and steals foreground for up to the timeout (#10488). + { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true } ) } @@ -1244,7 +1246,6 @@ export class CodexRuntimeHomeService { return index > 0 ? value.slice(0, index) : '/' } - private joinWslPath(basePath: string, ...segments: string[]): string { return parseWslUncPath(basePath) ? pathWin32.join(basePath, ...segments) diff --git a/src/main/codex-accounts/service-wsl-accounts.test.ts b/src/main/codex-accounts/service-wsl-accounts.test.ts index 3e62db7e59e..2dbf44026a2 100644 --- a/src/main/codex-accounts/service-wsl-accounts.test.ts +++ b/src/main/codex-accounts/service-wsl-accounts.test.ts @@ -71,14 +71,20 @@ describe('CodexAccountService config sync', () => { const runWslProcessMock = vi.fn(async (spec: WslSpec) => { const script = String(spec.script) expect(spec.distro).toBe('Debian') - expect(spec.lane).toBe('probe') if (script.includes('WSL_DISTRO_NAME')) { + // 'none': reads $HOME and $WSL_DISTRO_NAME, which wsl.exe supplies from + // /etc/passwd without a login shell. + expect(spec.loginPath).toBe('none') return wslOk('Debian\n/home/alice\n') } if (script.includes('_orca_lookup_command=')) { + // 'preferred': a PATH lookup. Under 'none' an nvm-installed codex is + // invisible and a working install is reported absent (#9725). + expect(spec.loginPath).toBe('preferred') expect(script).toBe(buildWslCodexAvailabilityScript()) return wslOk() } + expect(spec.loginPath).toBe('none') expect(script).toContain('mkdir -p ') mkdirSync(wslManagedHomePath, { recursive: true }) writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-id-for-test\n') @@ -245,6 +251,84 @@ describe('CodexAccountService config sync', () => { } }) + it('says it could not check, not "not installed", when the login PATH is unavailable', async () => { + // #9725: an nvm-installed codex lives only on the login PATH. When the + // probe cannot supply it, a non-zero lookup is "we could not check" -- + // reporting a working install as absent is the bug this pins. + vi.resetModules() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + const wslManagedHomePath = join(testState.userDataDir, 'wsl-managed-home') + const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-id-for-test/home' + + const execFileSyncMock = vi.fn((_command: string, args: string[]) => { + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + expect(args.slice(0, 2)).toEqual(['-d', 'Debian']) + expect(script).toContain('readlink -f') + return `${wslLinuxHomePath}\n` + }) + const runWslProcessMock = vi.fn(async (spec: WslSpec) => { + const script = String(spec.script) + expect(spec.distro).toBe('Debian') + if (script.includes('WSL_DISTRO_NAME')) { + return wslOk('Debian\n/home/alice\n') + } + if (script.includes('_orca_lookup_command=')) { + expect(script).toBe(buildWslCodexAvailabilityScript()) + return { ...wslFailed(1, 'codex missing'), environmentResolved: false } + } + mkdirSync(wslManagedHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-id-for-test\n') + return wslOk() + }) + const spawnMock = vi.fn() + + vi.doMock('node:crypto', () => ({ + randomUUID: () => 'account-id-for-test' + })) + vi.doMock('node:child_process', () => ({ + execFileSync: execFileSyncMock, + spawn: spawnMock + })) + vi.doMock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => + path === wslManagedHomePath ? { distro: 'Debian', linuxPath: wslLinuxHomePath } : null + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: () => wslManagedHomePath + })) + + const settings = createSettings() + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + await expect(service.addAccount({ runtime: 'wsl', wslDistro: 'Debian' })).rejects.toThrow( + 'Could not check the Codex CLI in WSL. Try again.' + ) + expect(spawnMock).not.toHaveBeenCalled() + expect(existsSync(wslManagedHomePath)).toBe(false) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('reauthenticates a WSL managed Codex account inside its distro', async () => { vi.resetModules() const originalPlatform = process.platform diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 8874cc0b849..cd790207823 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -1215,9 +1215,7 @@ export class CodexAccountService { const requestedDistro = target.wslDistro?.trim() || undefined const info = await runWslProcess({ distro: requestedDistro, - lane: 'probe', - // Degrade rather than refuse: $HOME and $WSL_DISTRO_NAME come from wsl.exe itself, not the login PATH -- the same rationale the Claude sites use. - allowDegradedEnvironment: true, + loginPath: 'none', script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"', shell: 'bash', timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS @@ -1239,9 +1237,7 @@ export class CodexAccountService { const markerPath = `${wslLinuxHomePath}/.orca-managed-home` const created = await runWslProcess({ distro, - lane: 'probe', - // Degrade rather than refuse: $HOME and $WSL_DISTRO_NAME come from wsl.exe itself, not the login PATH -- the same rationale the Claude sites use. - allowDegradedEnvironment: true, + loginPath: 'none', script: `mkdir -p ${shellQuote(wslLinuxHomePath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}`, shell: 'bash', timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS @@ -1478,9 +1474,7 @@ export class CodexAccountService { const result = await runWslProcess({ distro: wslInfo.distro, - lane: 'probe', - // Degrade rather than refuse: $HOME and $WSL_DISTRO_NAME come from wsl.exe itself, not the login PATH -- the same rationale the Claude sites use. - allowDegradedEnvironment: true, + loginPath: 'none', script: [ 'set -euo pipefail', `candidate=${shellQuote(wslInfo.linuxPath)}`, @@ -1566,8 +1560,7 @@ export class CodexAccountService { ].join('\n') ) ], - { windowsHide: true, - encoding: 'utf-8', timeout: 5000 } + { windowsHide: true, encoding: 'utf-8', timeout: 5000 } ).trim() if (!canonicalLinuxPath) { throw new Error('Managed Codex home directory does not exist on disk.') @@ -1616,9 +1609,7 @@ export class CodexAccountService { try { const result = await runWslProcess({ distro, - lane: 'probe', - // Degrade rather than refuse: $HOME and $WSL_DISTRO_NAME come from wsl.exe itself, not the login PATH -- the same rationale the Claude sites use. - allowDegradedEnvironment: true, + loginPath: 'none', script: [ 'set -euo pipefail', `candidate=${shellQuote(linuxHomePath)}`, @@ -1865,20 +1856,19 @@ export class CodexAccountService { distro: string linuxPath: string }): Promise { - // Why the runner's default (throw) and not allowDegradedEnvironment: without - // the login PATH an nvm-installed codex is invisible, and this message would - // turn "we could not check" into "not installed" (#9725). - let result: Awaited> - try { - result = await runWslProcess({ - distro: wslInfo.distro, - lane: 'probe', - script: buildWslCodexAvailabilityScript(), - timeoutMs: WSL_CODEX_AVAILABILITY_TIMEOUT_MS - }) - } catch { - // Why map: the runner's raw "guest environment is unavailable" would - // otherwise reach the account dialog verbatim. + // This is a PATH lookup, so it needs the login PATH: an nvm-installed codex + // lives nowhere else. Marking it 'none' reports a working install as absent. + const result = await runWslProcess({ + distro: wslInfo.distro, + loginPath: 'preferred', + script: buildWslCodexAvailabilityScript(), + // POSIX command lookup; declared because the payload is opaque here. + shell: 'sh', + timeoutMs: WSL_CODEX_AVAILABILITY_TIMEOUT_MS + }) + if (result.code !== 0 && !result.environmentResolved) { + // A miss without the login PATH is "we could not check", not "not + // installed" -- claiming absence here is #9725. throw new Error('Could not check the Codex CLI in WSL. Try again.') } if (result.code !== 0 || result.timedOut) { diff --git a/src/main/codex/wsl-codex-session-bridge.test.ts b/src/main/codex/wsl-codex-session-bridge.test.ts index f71474528be..ef880a378aa 100644 --- a/src/main/codex/wsl-codex-session-bridge.test.ts +++ b/src/main/codex/wsl-codex-session-bridge.test.ts @@ -44,13 +44,13 @@ describe('syncWslCodexSessionsIntoManagedHome', () => { expect(runWslProcessMock).toHaveBeenCalledTimes(1) const spec = runWslProcessMock.mock.calls[0]?.[0] as { distro: string - lane: string + loginPath: string shell?: string script: string timeoutMs: number } expect(spec.distro).toBe('Ubuntu') - expect(spec.lane).toBe('probe') + expect(spec.loginPath).toBe('none') expect(spec.shell).toBe('bash') expect(spec.timeoutMs).toBe(30_000) @@ -79,7 +79,9 @@ describe('syncWslCodexSessionsIntoManagedHome', () => { }) it('parses the summary after leading stdout noise', async () => { - mockRunWslProcessSuccess('Welcome to Ubuntu\nprofile output\n{"scannedFiles":4,"linkedFiles":3}\n') + mockRunWslProcessSuccess( + 'Welcome to Ubuntu\nprofile output\n{"scannedFiles":4,"linkedFiles":3}\n' + ) const summary = await syncWslCodexSessionsIntoManagedHome({ distro: 'Ubuntu', diff --git a/src/main/codex/wsl-codex-session-bridge.ts b/src/main/codex/wsl-codex-session-bridge.ts index 9b5cbf0895d..f65dfdbaa03 100644 --- a/src/main/codex/wsl-codex-session-bridge.ts +++ b/src/main/codex/wsl-codex-session-bridge.ts @@ -56,7 +56,7 @@ export async function syncWslCodexSessionsIntoManagedHome( const result = await runWslProcess({ distro: target.distro, - lane: 'probe', + loginPath: 'none', script: buildWslCodexSessionBridgeShellCommand(paths), // Process substitution and `read -d` are bash-only; dash rejects both. shell: 'bash', diff --git a/src/main/daemon/daemon-process-inspection.ts b/src/main/daemon/daemon-process-inspection.ts index a4f3fde2b64..149ea478fb4 100644 --- a/src/main/daemon/daemon-process-inspection.ts +++ b/src/main/daemon/daemon-process-inspection.ts @@ -191,7 +191,13 @@ async function runInspectionCommand( args: string[], timeoutMs: number ): Promise { - const { stdout } = await execFileAsync(file, args, { encoding: 'utf8', timeout: timeoutMs }) + // powershell.exe is console-subsystem: without this it flashes a conhost and + // steals foreground on every inspection (#10488). + const { stdout } = await execFileAsync(file, args, { + encoding: 'utf8', + timeout: timeoutMs, + windowsHide: true + }) return stdout } diff --git a/src/main/git/runner.test.ts b/src/main/git/runner.test.ts index 92832fc0f89..30851fe2882 100644 --- a/src/main/git/runner.test.ts +++ b/src/main/git/runner.test.ts @@ -338,3 +338,18 @@ describe('git env forces untranslated diagnostics (issue #7808)', () => { expect(env.LANGUAGE).toBe('en') }) }) + +describe('redirectPortedHostnameToEnv WSLENV forwarding', () => { + it('names GITLAB_HOST in WSLENV so it can cross into a distro', async () => { + // A WSL-routed glab only sees Windows variables listed in WSLENV; without + // the entry the ported host is silently dropped and glab talks to + // gitlab.com (#12557). + const { redirectPortedHostnameToEnv } = await import('./runner') + const { options } = redirectPortedHostnameToEnv( + ['api', '--hostname', 'gitlab.example.com:8443'], + { env: { PATH: '/usr/bin' } } + ) + expect(options.env?.GITLAB_HOST).toBe('gitlab.example.com:8443') + expect((options.env?.WSLENV ?? '').split(':')).toContain('GITLAB_HOST') + }) +}) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 9f5e3346dcf..1ae53551f29 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -1908,9 +1908,17 @@ export function redirectPortedHostnameToEnv( if (!/^[^/\s]+:\d+$/.test(host)) { return { args, options } } + // Why WSLENV: a glab routed into a distro only sees Windows-side variables + // named in WSLENV, so without this the ported host silently never crosses and + // glab talks to gitlab.com instead (#12557). Credit: #12558. + const env: NodeJS.ProcessEnv = { ...(options.env ?? process.env), GITLAB_HOST: host } + // Unguarded by platform on purpose: WSLENV is meaningless outside Windows, so + // the extra key is inert there, and gating it would need the test to know the + // platform for no behavioural gain. + addWslEnvKeys(env, ['GITLAB_HOST']) return { args: [...args.slice(0, i), ...args.slice(i + 2)], - options: { ...options, env: { ...(options.env ?? process.env), GITLAB_HOST: host } } + options: { ...options, env } } } diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 23293ce8b48..fc2e8053b5f 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -141,7 +141,13 @@ describe('runHook', () => { it('runs WSL hooks through runWslProcess and translates env paths to Linux', async () => { execMock.mockReset() runWslProcessMock.mockReset() - runWslProcessMock.mockResolvedValue({ environmentResolved: true, code: 0, stdout: '', stderr: '', timedOut: false }) + runWslProcessMock.mockResolvedValue({ + environmentResolved: true, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) const fs = await import('node:fs') vi.mocked(fs.existsSync).mockReturnValue(true) @@ -164,7 +170,7 @@ describe('runHook', () => { expect(runWslProcessMock).toHaveBeenCalledWith( expect.objectContaining({ distro: 'Ubuntu', - lane: 'probe', + loginPath: 'preferred', script: 'echo hello', cwd: '/home/jin/feature', // #7652 regression: the unattended WSL hook branch must carry the @@ -191,7 +197,13 @@ describe('runHook', () => { it('runs Windows-path hooks through WSL when the project runtime targets WSL', async () => { execMock.mockReset() runWslProcessMock.mockReset() - runWslProcessMock.mockResolvedValue({ environmentResolved: true, code: 0, stdout: '', stderr: '', timedOut: false }) + runWslProcessMock.mockResolvedValue({ + environmentResolved: true, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) const fs = await import('node:fs') vi.mocked(fs.existsSync).mockReturnValue(true) @@ -220,7 +232,7 @@ describe('runHook', () => { expect(runWslProcessMock).toHaveBeenCalledWith( expect.objectContaining({ distro: 'Ubuntu', - lane: 'probe', + loginPath: 'preferred', script: 'echo hello', cwd: '/mnt/c/Users/jinwo/git/orca-feature', env: expect.objectContaining({ @@ -296,7 +308,13 @@ describe('runHook', () => { // mocked runWslProcess -- there is nothing left in hooks.ts to advance. execMock.mockReset() runWslProcessMock.mockReset() - runWslProcessMock.mockResolvedValue({ environmentResolved: true, code: null, stdout: '', stderr: '', timedOut: true }) + runWslProcessMock.mockResolvedValue({ + environmentResolved: true, + code: null, + stdout: '', + stderr: '', + timedOut: true + }) const fs = await import('node:fs') vi.mocked(fs.existsSync).mockReturnValue(true) diff --git a/src/main/hooks.ts b/src/main/hooks.ts index d55647e6737..4d7a611d227 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -145,7 +145,9 @@ export function runHook( for (const [key, value] of Object.entries(guardedEnv)) { if ( value !== undefined && - (key === 'GIT_TERMINAL_PROMPT' || key === 'GCM_INTERACTIVE' || key.startsWith('GIT_CONFIG_')) + (key === 'GIT_TERMINAL_PROMPT' || + key === 'GCM_INTERACTIVE' || + key.startsWith('GIT_CONFIG_')) ) { guestEnv[key] = value } @@ -153,17 +155,12 @@ export function runHook( return runWslProcess({ distro: wslInfo.distro ?? undefined, - lane: 'probe', + loginPath: 'preferred', script, // Why pinned: these are user-authored orca.yaml scripts and the native // path runs /bin/bash. Defaulting to sh would fail bash-only hooks on WSL // only -- a downgrade the user never asked for. shell: 'bash', - // Why degrade rather than fail: a hook is an action, not an "is this - // installed?" question. Before the runner it ran on the default PATH when - // no login shell was available; refusing would turn worktree setup into a - // hard failure whenever WSL is briefly slow. - allowDegradedEnvironment: true, cwd: wslInfo.linuxPath, env: guestEnv, timeoutMs: HOOK_TIMEOUT diff --git a/src/main/ipc/preflight-agent-detection.test.ts b/src/main/ipc/preflight-agent-detection.test.ts index 02a47f7570f..37a061e0541 100644 --- a/src/main/ipc/preflight-agent-detection.test.ts +++ b/src/main/ipc/preflight-agent-detection.test.ts @@ -135,13 +135,31 @@ describe('preflight', () => { const target = String(args[0]) if (target === 'claude') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } if (target === 'continue') { - return { environmentResolved: true, code: 0, stdout: 'continue: shell built-in command\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: 'continue: shell built-in command\n', + stderr: '', + timedOut: false + } } if (target === 'cursor-agent') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/cursor-agent\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/cursor-agent\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -155,7 +173,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'orca') { - return { environmentResolved: true, code: 0, stdout: '/Applications/Orca.app/Contents/MacOS/orca\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Applications/Orca.app/Contents/MacOS/orca\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -169,10 +193,22 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'claude') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } if (String(args[0]) === 'orca') { - return { environmentResolved: true, code: 0, stdout: '/Applications/Orca.app/Contents/MacOS/orca\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Applications/Orca.app/Contents/MacOS/orca\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -190,10 +226,22 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'claude') { - return { environmentResolved: true, code: 0, stdout: '/mock/windows/npm/claude.cmd\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/mock/windows/npm/claude.cmd\n', + stderr: '', + timedOut: false + } } if (String(args[0]) === 'orca') { - return { environmentResolved: true, code: 0, stdout: '/mock/windows/programs/orca.cmd\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/mock/windows/programs/orca.cmd\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -239,7 +287,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'claude') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -273,10 +327,22 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'openclaude') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/openclaude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/openclaude\n', + stderr: '', + timedOut: false + } } if (String(args[0]) === 'cursor-agent') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/cursor-agent\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/cursor-agent\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -303,7 +369,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'codex' && process.env.PATH?.startsWith('/home/test/.local/bin')) { - return { environmentResolved: true, code: 0, stdout: '/home/test/.local/bin/codex\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/home/test/.local/bin/codex\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -328,7 +400,13 @@ describe('preflight', () => { }) runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => { if (script.includes("'claude'")) { - return { environmentResolved: true, code: 0, stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -349,7 +427,13 @@ describe('preflight', () => { expect(script).not.toContain("'orca-dev'") expect(script).not.toContain("'orca-ide'") if (script.includes("'claude'")) { - return { environmentResolved: true, code: 0, stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -363,7 +447,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'vibe') { - return { environmentResolved: true, code: 0, stdout: '/home/test/.local/bin/vibe\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/home/test/.local/bin/vibe\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -392,7 +482,13 @@ describe('preflight', () => { }) runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => { if (script.includes("'claude'")) { - return { environmentResolved: true, code: 0, stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -405,7 +501,7 @@ describe('preflight', () => { // pinned by its own tests. What this suite owns is that detection asks the // right distro on the lane that carries the user's PATH. expect(runWslProcessMock).toHaveBeenCalledWith( - expect.objectContaining({ distro: 'Ubuntu', lane: 'probe' }) + expect.objectContaining({ distro: 'Ubuntu', loginPath: 'preferred' }) ) }) @@ -433,7 +529,7 @@ describe('preflight', () => { expect(resolveCliCommandsMock).not.toHaveBeenCalled() // No distro named: the runner resolves the default. expect(runWslProcessMock).toHaveBeenCalledWith( - expect.objectContaining({ distro: undefined, lane: 'probe' }) + expect.objectContaining({ distro: undefined, loginPath: 'preferred' }) ) }) }) diff --git a/src/main/ipc/preflight-host-cli-status.test.ts b/src/main/ipc/preflight-host-cli-status.test.ts index 63cb6657b92..ea4da2d240d 100644 --- a/src/main/ipc/preflight-host-cli-status.test.ts +++ b/src/main/ipc/preflight-host-cli-status.test.ts @@ -276,7 +276,13 @@ describe('preflight', () => { }) runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => { if (script.includes('gh') && script.includes('--version')) { - return { environmentResolved: true, code: 0, stdout: 'gh version 2.0.0\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: 'gh version 2.0.0\n', + stderr: '', + timedOut: false + } } if (script.includes('gh') && script.includes('auth status')) { return { @@ -296,14 +302,14 @@ describe('preflight', () => { expect(runWslProcessMock).toHaveBeenCalledWith( expect.objectContaining({ distro: 'Ubuntu', - lane: 'probe', + loginPath: 'preferred', script: expect.stringMatching(/gh[\s\S]*--version/) }) ) expect(runWslProcessMock).toHaveBeenCalledWith( expect.objectContaining({ distro: 'Ubuntu', - lane: 'probe', + loginPath: 'preferred', script: expect.stringMatching(/gh[\s\S]*auth status/) }) ) diff --git a/src/main/ipc/preflight-wsl-agent-detection.test.ts b/src/main/ipc/preflight-wsl-agent-detection.test.ts index 2b15efe37fc..a646a9625b5 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.test.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.test.ts @@ -1,3 +1,7 @@ +import { execFileSync, type ExecFileSyncOptions } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const runWslProcessMock = vi.hoisted(() => vi.fn()) @@ -6,7 +10,7 @@ vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) import { detectWslCommandsOnPath } from './preflight-wsl-agent-detection' import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' -type RunWslProcessSpec = { distro?: string; lane: string; script: string } +type RunWslProcessSpec = { distro?: string; loginPath: string; script: string } function lastSpec(): RunWslProcessSpec { const call = runWslProcessMock.mock.calls.at(-1) @@ -17,18 +21,24 @@ function lastSpec(): RunWslProcessSpec { describe('detectWslCommandsOnPath', () => { beforeEach(() => { runWslProcessMock.mockReset() - runWslProcessMock.mockResolvedValue({ environmentResolved: true, code: 0, stdout: '', stderr: '', timedOut: false }) + runWslProcessMock.mockResolvedValue({ + environmentResolved: true, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) }) afterEach(() => { vi.restoreAllMocks() }) - it('runs on the probe lane -- no shell, so no rc/motd banner can appear', async () => { + it('asks for the login PATH without running a shell, so no banner can appear', async () => { await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude']) const spec = lastSpec() - expect(spec.lane).toBe('probe') + expect(spec.loginPath).toBe('preferred') expect(spec.distro).toBe('Ubuntu') }) @@ -121,3 +131,92 @@ describe('detectWslCommandsOnPath', () => { expect(runWslProcessMock).not.toHaveBeenCalled() }) }) + +describe('the detection script itself, run by a real POSIX shell', () => { + // Not a mock: the fallback is shell globbing and `[ -x ]`, which only the + // shell can be trusted about. Skipped on Windows, where /bin/sh is absent. + const itPosix = process.platform === 'win32' ? it.skip : it + let home: string + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-wsl-detect-')) + }) + afterEach(() => { + rmSync(home, { recursive: true, force: true }) + }) + + const plant = (dir: string, name: string): void => { + mkdirSync(join(home, dir), { recursive: true }) + const file = join(home, dir, name) + writeFileSync(file, '#!/bin/sh\necho hi\n') + chmodSync(file, 0o755) + } + + const runScript = async (): Promise => { + runWslProcessMock.mockResolvedValue({ + environmentResolved: false, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) + await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['orca-fake-cli', 'nosuchtool']) + const script = String(runWslProcessMock.mock.calls.at(-1)?.[0].script) + const options: ExecFileSyncOptions = { + encoding: 'utf8', + env: { HOME: home, PATH: '/usr/bin:/bin' } + } + return String(execFileSync('/bin/sh', ['-c', script], options)) + } + + itPosix('finds an nvm-installed binary the login PATH would have shown', async () => { + // #9725: without the login PATH this resolves to nothing and preflight + // reports a working install as "not installed". + plant('.nvm/versions/node/v20.1.0/bin', 'orca-fake-cli') + const out = await runScript() + expect(out).toContain('__ORCA_AGENT_PATH__orca-fake-cli') + expect(out).toContain('.nvm/versions/node/v20.1.0/bin/orca-fake-cli') + }) + + itPosix('finds a ~/.local/bin install', async () => { + plant('.local/bin', 'orca-fake-cli') + expect(await runScript()).toContain('__ORCA_AGENT_PATH__orca-fake-cli') + }) + + itPosix('still reports nothing for a command that is genuinely absent', async () => { + plant('.local/bin', 'orca-fake-cli') + expect(await runScript()).not.toContain('nosuchtool') + }) + + itPosix('finds a CLI when $HOME contains a space', async () => { + // The dir list is globbed, so an unquoted $HOME word-split into a relative + // path and every CLI read as absent -- the #9725 symptom, from the fix. + const spaced = mkdtempSync(join(tmpdir(), 'orca has space-')) + const previous = home + home = spaced + try { + plant('.nvm/versions/node/v20.1.0/bin', 'orca-fake-cli') + expect(await runScript()).toContain(`${spaced}/.nvm/versions/node/v20.1.0/bin/orca-fake-cli`) + } finally { + home = previous + rmSync(spaced, { recursive: true, force: true }) + } + }) + + itPosix('does not report a directory as an installed CLI', async () => { + // Directories are mode 755 and pass -x. Preflight would say installed and + // the launch would fail later with EISDIR. + mkdirSync(join(home, '.local/bin/orca-fake-cli'), { recursive: true }) + expect(await runScript()).not.toContain('__ORCA_AGENT_PATH__orca-fake-cli') + }) + + itPosix.each([ + '.volta/bin', + '.asdf/shims', + '.fnm/aliases/default/bin', + '.local/share/mise/shims' + ])('covers %s, which the native fallback also probes', async (dir) => { + plant(dir, 'orca-fake-cli') + expect(await runScript()).toContain('__ORCA_AGENT_PATH__orca-fake-cli') + }) +}) diff --git a/src/main/ipc/preflight-wsl-agent-detection.ts b/src/main/ipc/preflight-wsl-agent-detection.ts index b2c2c02309b..701da37f8c4 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.ts @@ -1,4 +1,5 @@ import path from 'node:path' +import { buildPosixFallbackPathPrelude } from '../../shared/posix-version-manager-bin-dirs' import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' import { runWslProcess } from '../wsl/wsl-runner' @@ -25,6 +26,11 @@ export async function detectWslCommandsOnPath( }) // Newlines keep the loop valid in every POSIX shell used here. const script = [ + // The same fallback the preflight command runner uses: append the + // version-manager dirs to PATH and let the ordinary lookup find them. A + // second bespoke `[ -x ]` walk here duplicated the lookup script's own + // `! -d` guard, which is how a directory once read as an installed CLI. + buildPosixFallbackPathPrelude(), `for cmd in ${commandList}; do`, lookupScript, 'if [ -n "$resolved" ]; then', @@ -38,15 +44,10 @@ export async function detectWslCommandsOnPath( // with no shell in the loop, so there is no rc/motd banner to land in stdout. const result = await runWslProcess({ distro: wslTarget.distro, - lane: 'probe', + loginPath: 'preferred', script, - // Why degrade rather than refuse: the Set has no room for "unverifiable", - // and refusing would turn a slow distro into "no agents" anyway -- via a - // throw instead of an empty result. Degrading at least finds anything on - // the default PATH. The residual gap (an nvm-only agent missed during the - // probe's retry window, #9725) is the pre-migration behaviour, not new, - // and Refresh now re-probes. - allowDegradedEnvironment: true, + // POSIX `command -v` loop; declared because the payload is opaque here. + shell: 'sh', timeoutMs: WSL_AGENT_DETECTION_TIMEOUT_MS }) // runProcess resolves on a timeout and on a non-zero exit, so partial diff --git a/src/main/ipc/preflight-wsl-command.test.ts b/src/main/ipc/preflight-wsl-command.test.ts index 931b1b1275b..f5842f6afd8 100644 --- a/src/main/ipc/preflight-wsl-command.test.ts +++ b/src/main/ipc/preflight-wsl-command.test.ts @@ -1,3 +1,7 @@ +import { execFileSync, type ExecFileSyncOptions } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' const runWslProcessMock = vi.hoisted(() => vi.fn()) @@ -17,15 +21,14 @@ beforeEach(() => { }) describe('runPreflightCommandInWsl', () => { - it('degrades rather than letting a slow distro read as "not installed"', async () => { + it('prefers the login PATH but never lets its absence decide the answer', async () => { // Every caller collapses a throw into a verdict: isCommandAvailable returns // false ("not installed"), isGhAuthenticated reads an empty payload as - // "not authenticated". Refusing here turns a slow distro into a confident - // wrong answer -- #9725 through the other door. + // "not authenticated". A missing login PATH must therefore never be fatal. await runPreflightCommandInWsl({ distro: 'Ubuntu' }, 'gh --version', 5_000) expect(runWslProcessMock).toHaveBeenCalledWith( - expect.objectContaining({ allowDegradedEnvironment: true, distro: 'Ubuntu' }) + expect.objectContaining({ loginPath: 'preferred', distro: 'Ubuntu' }) ) }) @@ -45,3 +48,74 @@ describe('runPreflightCommandInWsl', () => { ).rejects.toMatchObject({ stdout: 'partial', stderr: 'boom', code: 1 }) }) }) + +describe('the PATH fallback, run by a real POSIX shell', () => { + const itPosix = process.platform === 'win32' ? it.skip : it + + itPosix('finds an nvm-installed CLI a degraded probe would have missed', async () => { + // #9725: the caller turns a throw into "not installed", so a degraded probe + // told the user to install a CLI their own terminal already runs. + // + // A fabricated name, not `gh`: CI has a real /usr/bin/gh, and the fallback + // APPENDS, so the PATH lookup correctly found the real one and the planted + // stub was never reached. The test was asserting the host, not the code. + const home = mkdtempSync(join(tmpdir(), 'orca-wsl-cmd-')) + try { + const bin = join(home, '.nvm/versions/node/v20.1.0/bin') + mkdirSync(bin, { recursive: true }) + writeFileSync(join(bin, 'orca-fake-cli'), '#!/bin/sh\necho cli-ok\n') + chmodSync(join(bin, 'orca-fake-cli'), 0o755) + + runWslProcessMock.mockResolvedValue({ + environmentResolved: false, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) + await runPreflightCommandInWsl({ distro: 'Ubuntu' }, 'command -v orca-fake-cli', 5000) + const script = String(runWslProcessMock.mock.calls.at(-1)?.[0].script) + const options: ExecFileSyncOptions = { + encoding: 'utf8', + env: { HOME: home, PATH: '/usr/bin:/bin' } + } + expect(String(execFileSync('/bin/sh', ['-c', script], options))).toContain( + '.nvm/versions/node/v20.1.0/bin/orca-fake-cli' + ) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + itPosix('appends rather than prepends, so a resolved PATH still wins', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-wsl-cmd-')) + const real = mkdtempSync(join(tmpdir(), 'orca-wsl-real-')) + try { + for (const [dir, body] of [ + [join(home, '.local/bin'), 'stale'], + [real, 'current'] + ] as const) { + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'orca-fake-cli'), `#!/bin/sh\necho ${body}\n`) + chmodSync(join(dir, 'orca-fake-cli'), 0o755) + } + runWslProcessMock.mockResolvedValue({ + environmentResolved: true, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) + await runPreflightCommandInWsl({ distro: 'Ubuntu' }, 'orca-fake-cli', 5000) + const script = String(runWslProcessMock.mock.calls.at(-1)?.[0].script) + const options: ExecFileSyncOptions = { + encoding: 'utf8', + env: { HOME: home, PATH: `${real}:/usr/bin:/bin` } + } + expect(String(execFileSync('/bin/sh', ['-c', script], options)).trim()).toBe('current') + } finally { + rmSync(home, { recursive: true, force: true }) + rmSync(real, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/ipc/preflight-wsl-command.ts b/src/main/ipc/preflight-wsl-command.ts index 4729ea38e1d..0e8f86f2d0c 100644 --- a/src/main/ipc/preflight-wsl-command.ts +++ b/src/main/ipc/preflight-wsl-command.ts @@ -1,3 +1,4 @@ +import { buildPosixFallbackPathPrelude } from '../../shared/posix-version-manager-bin-dirs' import { runWslProcess } from '../wsl/wsl-runner' import type { WslPreflightTarget } from './preflight-wsl-agent-detection' @@ -12,13 +13,11 @@ export async function runPreflightCommandInWsl( // with no shell in the loop, so there is no rc/motd banner to strip. const result = await runWslProcess({ distro: target.distro, - lane: 'probe', - script: command, - // Why degrade: every caller collapses a throw into "not installed" or - // "not authenticated" (isCommandAvailable, isGhAuthenticated). Refusing - // here turns a slow distro into a confident wrong answer -- #9725 through - // the other door, on the branch built to close it. - allowDegradedEnvironment: true, + loginPath: 'preferred', + // Appending the version-manager dirs keeps a resolved login PATH + // authoritative while stopping a degraded probe from turning an installed + // CLI into "not installed" (#9725), the same fallback the native branch has. + script: `${buildPosixFallbackPathPrelude()}\n${command}`, timeoutMs }) // runWslProcess resolves on a timeout and on a non-zero exit; the caller's diff --git a/src/main/rate-limits/gemini-cli-oauth-extractor.ts b/src/main/rate-limits/gemini-cli-oauth-extractor.ts index 80e841e7adf..7b5d5e7e3f3 100644 --- a/src/main/rate-limits/gemini-cli-oauth-extractor.ts +++ b/src/main/rate-limits/gemini-cli-oauth-extractor.ts @@ -1,10 +1,10 @@ -import { exec } from 'node:child_process' +import { execFile } from 'node:child_process' import { access, readdir, readFile, realpath } from 'node:fs/promises' import { promisify } from 'node:util' import { homedir } from 'node:os' import path from 'node:path' -const execAsync = promisify(exec) +const execFileAsync = promisify(execFile) async function fileExists(filePath: string): Promise { try { @@ -19,9 +19,16 @@ async function fileExists(filePath: string): Promise { const OAUTH2_SUBPATH = path.join('dist', 'src', 'code_assist', 'oauth2.js') async function resolveGeminiBinary(): Promise { - const whichCmd = process.platform === 'win32' ? 'where gemini' : 'which gemini' + const [lookup, args] = + process.platform === 'win32' ? ['where.exe', ['gemini']] : ['which', ['gemini']] try { - const { stdout } = await execAsync(whichCmd, { encoding: 'utf-8' }) + // execFile, not exec: `exec` implies `shell: true`, which silently makes + // windowsHide a no-op (see run-process.ts, #14543), so the console-subsystem + // `where` would still flash a conhost (#10488). + const { stdout } = await execFileAsync(lookup, args, { + encoding: 'utf-8', + windowsHide: true + }) const fromPath = stdout.trim().split(/\r?\n/)[0] if (fromPath && (await fileExists(fromPath))) { return fromPath diff --git a/src/main/skills/claude-plugin-skill-sources-wsl.ts b/src/main/skills/claude-plugin-skill-sources-wsl.ts index 083446229b0..d290bc50305 100644 --- a/src/main/skills/claude-plugin-skill-sources-wsl.ts +++ b/src/main/skills/claude-plugin-skill-sources-wsl.ts @@ -61,9 +61,9 @@ async function executeWslMetadataRead(distro: string, script: string): Promise Promise async function probeWslGrokHome(distro: string): Promise { const result = await runWslProcess({ distro, - lane: 'probe', - // Degrade rather than refuse: reads $HOME, which wsl.exe supplies without a login shell. - allowDegradedEnvironment: true, + // 'none': the script runs its own `"$login_shell" -lc`, so asking the + // runner to probe first buys a second login shell and spends up to half + // the 8s budget before the -lc that actually reads GROK_HOME starts. + loginPath: 'none', script: WSL_GROK_HOME_SCRIPT, + // POSIX (`case`, `exec`); declared because the payload is opaque here. + shell: 'sh', timeoutMs: WSL_ENV_PROBE_TIMEOUT_MS, maxOutputBytes: WSL_ENV_PROBE_MAX_BYTES }) diff --git a/src/main/skills/skill-wsl-install-filesystem.ts b/src/main/skills/skill-wsl-install-filesystem.ts index 8a5a863efb2..447b94efdfa 100644 --- a/src/main/skills/skill-wsl-install-filesystem.ts +++ b/src/main/skills/skill-wsl-install-filesystem.ts @@ -213,10 +213,10 @@ export class WslSkillInstallFilesystem implements SkillInstallFilesystem { private async runOutput(script: string, args: string[]): Promise { const result = await runWslProcess({ distro: this.distro, - lane: 'probe', - // Degrade rather than refuse: mkdir/mv/chmod on the default PATH; no login shell before. - allowDegradedEnvironment: true, + loginPath: 'none', script, + // POSIX file operations; declared because the payload is opaque here. + shell: 'sh', args, timeoutMs: GUEST_COMMAND_TIMEOUT_MS, maxOutputBytes: GUEST_COMMAND_MAX_OUTPUT_BYTES diff --git a/src/main/skills/skill-wsl-provider-detection.test.ts b/src/main/skills/skill-wsl-provider-detection.test.ts index c5117116c4a..677d0b6fdf9 100644 --- a/src/main/skills/skill-wsl-provider-detection.test.ts +++ b/src/main/skills/skill-wsl-provider-detection.test.ts @@ -5,7 +5,7 @@ vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection' -type RunWslProcessSpec = { distro: string; lane: string; script: string } +type RunWslProcessSpec = { distro: string; loginPath: string; script: string } describe('detectSkillProvidersInWsl', () => { beforeEach(() => { @@ -16,7 +16,7 @@ describe('detectSkillProvidersInWsl', () => { vi.restoreAllMocks() }) - it('runs on the probe lane so a PATH-only install (nvm/mise) is still found (regression)', async () => { + it('asks for the login PATH so a nvm/mise-only install is still found (regression)', async () => { // Before this migration the site ran `sh -c` with no login shell, so an // nvm-installed codex/claude -- reachable only through the PATH a login // shell assembles from rc files -- resolved to nothing via `command -v` @@ -32,7 +32,7 @@ describe('detectSkillProvidersInWsl', () => { const found = await detectSkillProvidersInWsl('Ubuntu') const [spec] = runWslProcessMock.mock.calls.at(-1) as [RunWslProcessSpec] - expect(spec.lane).toBe('probe') + expect(spec.loginPath).toBe('preferred') expect(spec.distro).toBe('Ubuntu') expect(found).toEqual(['codex']) }) @@ -65,6 +65,32 @@ describe('detectSkillProvidersInWsl', () => { expect(found).toEqual(['codex']) }) + it.each([ + ['reports unverifiable rather than empty', '', true], + ['reports unverifiable even on a partial hit', 'claude\n', true], + ['still trusts a genuine empty result', '', false] + ])('%s', async (_case, stdout, unresolved) => { + // The script ends in `|| true`, so "ran without the login PATH" and "no + // providers installed" are the same exit 0 with the same empty stdout. + // Callers skip the ~/.codex and ~/.claude skill roots on an empty list, so + // conflating them loses an nvm-installed provider's skills (#9725). + runWslProcessMock.mockResolvedValue({ + environmentResolved: !unresolved, + code: 0, + stdout, + stderr: '', + timedOut: false + }) + + await (unresolved + ? expect(detectSkillProvidersInWsl('Ubuntu')).rejects.toThrow( + 'skill-install-wsl-provider-detection-failed' + ) + : expect(detectSkillProvidersInWsl('Ubuntu')).resolves.toEqual( + stdout.trim() ? ['claude'] : [] + )) + }) + it('rejects when wsl.exe cannot be started', async () => { runWslProcessMock.mockRejectedValue(new Error('spawn wsl.exe ENOENT')) diff --git a/src/main/skills/skill-wsl-provider-detection.ts b/src/main/skills/skill-wsl-provider-detection.ts index 9b5ddf531ac..7b81d1d341c 100644 --- a/src/main/skills/skill-wsl-provider-detection.ts +++ b/src/main/skills/skill-wsl-provider-detection.ts @@ -13,8 +13,10 @@ export async function detectSkillProvidersInWsl(distro: string): Promise provider === 'codex' || provider === 'claude') diff --git a/src/main/wsl-fish-history-cleanup.test.ts b/src/main/wsl-fish-history-cleanup.test.ts index 429c8fef826..ede30393049 100644 --- a/src/main/wsl-fish-history-cleanup.test.ts +++ b/src/main/wsl-fish-history-cleanup.test.ts @@ -26,8 +26,7 @@ describe('deleteWslFishHistoryFile', () => { expect(run).toHaveBeenCalledWith({ distro: 'Ubuntu Test', - lane: 'probe', - allowDegradedEnvironment: true, + loginPath: 'preferred', program: 'fish', args: ['--command', expect.stringContaining('orca_0123456789abcdef_history')], timeoutMs: 5_000 @@ -74,7 +73,10 @@ describe('deleteWslFishHistoryFile', () => { }) it('permits a retry once a failed cleanup settles', async () => { - const run = vi.fn().mockRejectedValueOnce(new Error('distro offline')).mockResolvedValue(okResult) + const run = vi + .fn() + .mockRejectedValueOnce(new Error('distro offline')) + .mockResolvedValue(okResult) const session = 'orca_0123456789abcdef' await expect(deleteWslFishHistoryFile('Ubuntu', session, run)).rejects.toThrow('distro offline') @@ -85,16 +87,16 @@ describe('deleteWslFishHistoryFile', () => { it('treats a non-zero exit as a failed cleanup even though the runner resolved', async () => { const run = vi.fn().mockResolvedValue({ ...okResult, code: 1 }) - await expect( - deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run) - ).rejects.toThrow('wsl fish history cleanup failed') + await expect(deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run)).rejects.toThrow( + 'wsl fish history cleanup failed' + ) }) it('treats a runner timeout as a failed cleanup', async () => { const run = vi.fn().mockResolvedValue({ ...okResult, code: null, timedOut: true }) - await expect( - deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run) - ).rejects.toThrow('wsl fish history cleanup failed') + await expect(deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run)).rejects.toThrow( + 'wsl fish history cleanup failed' + ) }) }) diff --git a/src/main/wsl-fish-history-cleanup.ts b/src/main/wsl-fish-history-cleanup.ts index c278d2b4bba..cedbe4b999e 100644 --- a/src/main/wsl-fish-history-cleanup.ts +++ b/src/main/wsl-fish-history-cleanup.ts @@ -50,11 +50,12 @@ async function runCleanup( ): Promise { const result = await run({ distro, - lane: 'probe', - // The old `--exec fish` spawn never sourced a login shell either, so a - // probe failure must run degraded rather than turn a working cleanup into - // an error. - allowDegradedEnvironment: true, + // 'preferred', not 'none': `fish` is a bare name, so it is a PATH lookup. + // A fish from linuxbrew or nix lives only on the login PATH, and without it + // this throws on a distro where the cleanup would have worked. The probe + // failing is still fine -- the old `--exec fish` spawn sourced no login + // shell either, so running on the default PATH is no worse than before. + loginPath: 'preferred', program: 'fish', args: ['--command', fishCleanupScript(session)], timeoutMs: 5_000 diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index 7e9689ad9e4..01dc111ae1b 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -1,6 +1,14 @@ # Files that spawn wsl.exe directly instead of through src/main/wsl/wsl-runner.ts. -# Enforced by ../wsl-invocation-boundary.test.ts. This list only shrinks -- its -# length is the W3 goalpost. +# +# Enforced by ../wsl-invocation-boundary.test.ts. This list only shrinks for a +# MIGRATION -- its length is the W3 goalpost. +# +# It may grow when the scanner learns to see a spawn it was blind to. That has +# happened once: pty-subprocess.ts, local-pty-provider.ts and claude-pty.ts +# bind 'wsl.exe' to a variable and spawn it later, which the old neighbourhood +# scan could not follow. They were recorded in a prose comment instead, so the +# count was wrong by three in the direction that hides offenders. Adding them +# is the guard getting honest, not the boundary regressing. # # Remaining entries need a runner mode that does not exist: a long-lived # streaming child (OAuth logins, the hook relay), a synchronous caller, or a @@ -13,10 +21,13 @@ main/codex-accounts/service.ts main/codex/codex-state-db-backfill-recovery.ts main/codex/codex-trust-grant-host.ts main/codex/codex-wsl-hook-install-plan.ts +main/daemon/pty-subprocess.ts main/git/runner.ts main/git/wsl-git-read-environment.ts main/ipc/filesystem-watcher-wsl.ts main/local-worktree-filesystem.ts +main/providers/local-pty-provider.ts +main/rate-limits/claude-pty.ts main/rate-limits/codex-fetcher.ts main/wsl-availability.ts main/wsl-unc-delete.ts diff --git a/src/main/wsl/wsl-guest-environment.ts b/src/main/wsl/wsl-guest-environment.ts index a1e570fb08c..7f154314afb 100644 --- a/src/main/wsl/wsl-guest-environment.ts +++ b/src/main/wsl/wsl-guest-environment.ts @@ -87,6 +87,10 @@ async function probeGuestEnvironment( const result = await runProcess({ program: resolveWslExecutablePath(), args: buildWslExecArgs(distro, ['sh', '-c', captured.command]), + // The runner sets this for every command it launches (#9010); the probe + // spawns wsl.exe itself, so without it wsl.exe's own errors arrive UTF-16LE + // and the NUL-separated payload below is read through NUL-riddled text. + env: { ...process.env, WSL_UTF8: '1' }, timeoutMs: Math.min(PROBE_TIMEOUT_MS, budgetMs), maxOutputBytes: PROBE_MAX_OUTPUT_BYTES }) diff --git a/src/main/wsl/wsl-invocation-boundary.test.ts b/src/main/wsl/wsl-invocation-boundary.test.ts index 47d3398624d..6ff1b93404d 100644 --- a/src/main/wsl/wsl-invocation-boundary.test.ts +++ b/src/main/wsl/wsl-invocation-boundary.test.ts @@ -1,7 +1,11 @@ import { readdirSync, readFileSync, statSync } from 'node:fs' import { join, relative, resolve } from 'node:path' import { describe, expect, it } from 'vitest' -import { stripComments } from '../../shared/source-scan/source-tree-scan' +import { + blankStringContents, + blankStringContentsDesynced, + stripComments +} from '../../shared/source-scan/source-tree-scan' /** * Every `wsl.exe` spawn must go through `runWslProcess`. @@ -41,28 +45,19 @@ const IGNORED = new Set(['node_modules', 'dist', 'out', 'build', '.git', '__fixt * * Any identifier followed by `(` counts as an opener, and the assignment-style * fields are matched by name. Indirection through a variable - * (`const f = cond ? 'wsl.exe' : x`) is still invisible; those files are listed - * explicitly below so the gap is recorded rather than implied. + * (`const f = cond ? 'wsl.exe' : x`) is caught separately, by + * `bindsWslBinaryToASpawnedIdentifier`. */ const SPAWN_OPENER = /\b[A-Za-z_$][\w$]*\s*\(\s*$|(?:program|binary|command|file|shellPath):\s*$/ /* - * Known blind spot, recorded rather than implied: these files assign - * `'wsl.exe'` to a variable and spawn it elsewhere, which no regex over the - * literal's neighbourhood can see. They are NOT in the allowlist, because the - * stale-entry check would reject an entry the scanner cannot find -- so the - * guard's count under-reports by these five, and this comment is the record. - * - * main/rate-limits/claude-pty.ts - * main/providers/local-pty-provider.ts - * main/daemon/pty-subprocess.ts - * relay/pty-shell-launch.ts - * relay/pty-handler.ts - * - * All five are PTY-pane launches, which are out of W3's scope anyway: they go - * through node-pty, not a child-process wrapper. + * The five files this comment used to list as an unscannable blind spot are + * now handled: three bind `wsl.exe` to a variable and spawn it, and are real + * allowlist entries; the other two never spawned it at all -- one compares a + * basename, one lists it among accepted shells. Recording a gap in prose was + * worse than it looked, because the count is the goalpost and it was wrong by + * three in the direction that hides offenders. */ - function isTestFile(path: string): boolean { return ( /\.(?:test|spec)\.tsx?$/.test(path) || @@ -88,6 +83,64 @@ function collectSourceFiles(root: string): string[] { return found } +/** + * `const binary = 'wsl.exe'` ... `spawn(binary)`, which no test over the + * literal's neighbourhood can see. + * + * Why it earns its place: the neighbourhood test was the whole guard, and a + * planted `const p = 'wsl.exe'; spawnProcess(p)` passed it. Five files were + * already known to spawn this way and were recorded in a comment instead of + * the allowlist, which means the count -- the actual goalpost -- was wrong by + * five and any NEW indirect spawner would have been invisible. + */ +function bindsWslBinaryToASpawnedIdentifier(source: string): boolean { + const bound = new Set() + // Covers `const x = 'wsl.exe'`, a ternary picking it, and `binary: 'wsl.exe'`. + // `const x =`, and the class-field spellings (`private readonly x =`). `[^=]` + // rather than `[^=;\n]` so a Prettier-wrapped ternary still binds. + for (const match of source.matchAll( + /(?:(?:const|let|var|readonly|private|public|protected|static)\s+)+([A-Za-z_$][\w$]*)[^=\n]*=[^;]{0,200}?['"`]wsl\.exe['"`]/g + )) { + bound.add(match[1]!) + } + for (const match of source.matchAll(/\b([A-Za-z_$][\w$]*)\s*:\s*[^,;\n]*['"`]wsl\.exe['"`]/g)) { + bound.add(match[1]!) + } + // Assignment with no declarator: `this.binary = 'wsl.exe'`, and the split + // form `let shellPath: string` ... `shellPath = 'wsl.exe'`, which a + // declarator-anchored pattern cannot see. `[^;]{0,200}?` so a wrapped + // right-hand side still binds. + for (const match of source.matchAll( + /(?:\bthis\.)?([A-Za-z_$][\w$]*)\s*=[^=][^;]{0,200}?['"`]wsl\.exe['"`]/g + )) { + bound.add(match[1]!) + } + // A helper that hands back the binary is a spawn site one hop away, and the + // hop is untrackable by regex -- but only when this file also spawns + // something. Returning the name as terminal metadata is not a spawn. + if (/\breturn\s+['"`]wsl\.exe['"`]/.test(source) && /\b\w*(?:spawn|exec)\w*\s*\(/i.test(source)) { + return true + } + for (const name of bound) { + const identifier = name.replace(/[$]/g, '\\$&') + // The identifier reaching a call opener, a spawn-style field, or the first + // argument of a spawn-style call. + if ( + new RegExp(`\\b${identifier}\\s*\\(`).test(source) || + new RegExp( + `(?:program|binary|command|file|shellPath)\\s*:\\s*(?:this\\.)?${identifier}\\b` + ).test(source) || + // `this.` so a class field reaching `spawnProcess(this.binary)` counts. + new RegExp(`\\b\\w*(?:spawn|exec|run)\\w*\\s*\\(\\s*(?:this\\.)?${identifier}\\b`, 'i').test( + source + ) + ) { + return true + } + } + return false +} + function findSpawnSites(): string[] { const offenders = new Set() for (const path of collectSourceFiles(SOURCE_ROOT)) { @@ -96,7 +149,7 @@ function findSpawnSites(): string[] { continue } const source = readFileSync(path, 'utf8') - for (const match of source.matchAll(/['"]wsl\.exe['"]/g)) { + for (const match of source.matchAll(/['"`]wsl\.exe['"`]/g)) { // Collapse the preceding whitespace so a call broken across lines by the // formatter still reads as one opener. const preceding = source.slice(Math.max(0, match.index - 60), match.index) @@ -104,6 +157,9 @@ function findSpawnSites(): string[] { offenders.add(relativePath) } } + if (bindsWslBinaryToASpawnedIdentifier(source)) { + offenders.add(relativePath) + } } return [...offenders].sort() } @@ -124,6 +180,51 @@ function findSpawnSites(): string[] { const BASHISM = /<\s*<\(|\[\[|\blocal\s+\w+=|\bdeclare\s+-|\bmapfile\b|set\s+-[a-z]*o[a-z]*\s+pipefail|set\s+-euo\b|read\s+(?:-\w+\s+)*-d\b|<< { const offenders: string[] = [] for (const path of collectSourceFiles(SOURCE_ROOT)) { @@ -136,10 +237,97 @@ describe('bash-only payloads declare their interpreter', () => { // pipefail` to explain why it was removed would otherwise flag the file, // and a bash script written to a guest file is not a runner payload. const source = stripComments(readFileSync(path, 'utf8')) - if (!source.includes('runWslProcess') || !BASHISM.test(source)) { + if (!source.includes('runWslProcess')) { continue } - if (!source.includes("shell: 'bash'")) { + // Fail closed. A desynced lexer finds zero calls, and "zero calls" is + // indistinguishable from "zero violations" -- this guard passed a planted + // dash payload for exactly that reason, because one regex literal earlier + // in the file had inverted the scan. + if (blankStringContentsDesynced(source)) { + offenders.push(relativePath) + continue + } + const calls = collectRunnerCallArguments(source) + // Per-call: an unpinned payload sitting beside a pinned one. + if (calls.some(({ text }) => BASHISM.test(text) && !text.includes("shell: 'bash'"))) { + offenders.push(relativePath) + continue + } + // Anything that is not a plain object literal is judged unreadable, and an + // unreadable call must pin bash. + // + // Six review rounds of widening this regex produced more evasions -- a + // ternary with one pinned branch, an `as` assertion carrying the pin, + // `Object.assign` -- because a regex cannot tell which object a key + // belongs to. So stop guessing: a ternary, a spread or an assertion makes + // the call opaque, and opacity requires the pin rather than excusing it. + // + // A nested CALL is deliberately not exotic: `script: \`x ${shellQuote(p)}\`` + // is the ordinary way every payload here is built, and flagging it would + // demand `shell: 'bash'` on POSIX payloads that must not have it. + // A ternary only makes the call opaque when it CHOOSES the spec, i.e. it + // sits before the first `{`. One inside the object picks a script line and + // is both common and harmless (claude-accounts/service.ts:977). + const isExotic = (text: string): boolean => { + const body = text.replace(/^\(/, '') + const firstBrace = body.indexOf('{') + const prefix = firstBrace === -1 ? body : body.slice(0, firstBrace) + return /\?[^.:]|\.\.\./.test(prefix) || /\bas\s+[A-Za-z{]/.test(body) + } + // No `includes("shell: 'bash'")` escape here: in `cond ? {pinned} : {not}` + // the pin belongs to one branch and the substring test cannot tell which, + // so a pinned branch excused an unpinned one. An exotic call therefore + // cannot be excused -- write it as a plain object literal instead. + if (calls.some(({ text }) => isExotic(text)) && BASHISM.test(source)) { + offenders.push(relativePath) + continue + } + // An opaque payload is judged by the whole file, minus anything already + // declared bash. + // + // Requiring merely that `shell:` be PRESENT was strictly weaker than the + // file-wide rule it replaced: `shell: 'sh'` on a bash payload shipped + // green, which is #14292 with extra steps. Resolving the identifier is + // guesswork, so instead: strip the text of every call that already names + // bash, and if a bashism survives anywhere in the file while a + // script-carrying call is not bash-pinned, flag it. + // + // Stripping the bash-pinned calls is what keeps codex-accounts/service.ts + // clean -- it pins bash on four inline payloads and on a `bash -lc` + // execFileSync, and correctly leaves its printf/mkdir calls unpinned. + // Masked by POSITION, not by String.replace: replace() with a string + // pattern removes only the first match, so two identically-written pinned + // calls would leave one behind, and a body that also occurs earlier as a + // substring would blank the wrong region. + const pinnedRanges: [number, number][] = [ + ...calls + .filter(({ text }) => text.includes("shell: 'bash'")) + .map(({ start, end }): [number, number] => [start, end]), + ...[...source.matchAll(/'bash',\s*\n?\s*'-lc',[\s\S]{0,4000}?\n\s*\]/g)].map( + (m): [number, number] => [m.index, m.index + m[0].length] + ) + ] + const masked = source.split('') + for (const [from, to] of pinnedRanges) { + for (let index = from; index < to; index += 1) { + masked[index] = ' ' + } + } + const unpinnedRegion = masked.join('') + // A spread hides every key, `script` and `shell` alike, so it has to count + // as carrying a script -- otherwise `runWslProcess({ ...spec })` is a hole + // the file-wide arm never looks at. + const scriptCalls = calls.filter(({ text }) => /\bscript\b/.test(text) || /\.\.\./.test(text)) + // Zero collected calls is not zero risk: `Object.assign({...}, {script})` + // puts the payload in the second literal, and a renamed callee that the + // alias scan misses collects nothing at all. If the file carries a bashism + // and the guard cannot see any call object, that is unreadable, not clean. + const unreadable = calls.length === 0 + if ( + BASHISM.test(unpinnedRegion) && + (unreadable || scriptCalls.some(({ text }) => !text.includes("shell: 'bash'"))) + ) { offenders.push(relativePath) } } @@ -152,7 +340,6 @@ describe('bash-only payloads declare their interpreter', () => { describe('wsl.exe is spawned through one runner', () => { const offenders = findSpawnSites() - it('still detects a known spawn shape', () => { // Why name a specific file rather than assert a total: `offenders.length + // ALLOWLIST.length >= N` cannot fail while the allowlist alone exceeds N, diff --git a/src/main/wsl/wsl-runner.test.ts b/src/main/wsl/wsl-runner.test.ts index 6c71e4ee4cc..f92da4d18e3 100644 --- a/src/main/wsl/wsl-runner.test.ts +++ b/src/main/wsl/wsl-runner.test.ts @@ -54,23 +54,23 @@ afterEach(() => { }) describe('separator', () => { - it.each([ - ['probe', 'probe'], - ['interactive', 'interactive'] - ] as const)('uses --exec and never -- on the %s lane', async (_name, lane) => { - // Why this is pinned on both lanes: under `--`, wsl.exe expands $name in - // every forwarded argument before the guest runs -- even with no shell in - // the command -- so a script means something other than what it says - // (#12964). No escaping on our side is a reliable substitute. - seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane, program: '/usr/bin/git', args: ['status'] }) - expect(lastArgv()).toContain('--exec') - expect(lastArgv()).not.toContain('--') - }) + it.each([['none'], ['preferred']] as const)( + 'uses --exec and never -- with loginPath %s', + async (lane) => { + // Why this is pinned on both lanes: under `--`, wsl.exe expands $name in + // every forwarded argument before the guest runs -- even with no shell in + // the command -- so a script means something other than what it says + // (#12964). No escaping on our side is a reliable substitute. + seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) + await runWslProcess({ loginPath: lane, program: '/usr/bin/git', args: ['status'] }) + expect(lastArgv()).toContain('--exec') + expect(lastArgv()).not.toContain('--') + } + ) it('passes the distro before --exec', async () => { seedWslGuestEnvironmentForTests('Ubuntu', ENVIRONMENT) - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: '/bin/true' }) + await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', program: '/bin/true' }) expect(lastArgv().slice(0, 3)).toEqual(['-d', 'Ubuntu', '--exec']) }) }) @@ -80,7 +80,7 @@ describe('probe lane', () => { // The whole point: the user's real PATH without paying for -- or being // blocked by -- a login shell on every call (#14288, #9768). seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane: 'probe', program: 'codex', args: ['--version'] }) + await runWslProcess({ loginPath: 'preferred', program: 'codex', args: ['--version'] }) expect(lastArgv()).toEqual([ '--exec', '/usr/bin/env', @@ -91,96 +91,93 @@ describe('probe lane', () => { ]) }) - it('reports an unresolved environment rather than pretending the PATH is real', async () => { - // Falling back to the login shell here would re-run ~/.profile -- the very - // stall the probe lane exists to avoid, and most likely to bite exactly - // when the probe just failed. So the call proceeds on the default PATH and - // says so, and callers deciding "installed?" must treat that as unknown. - let call = 0 - runProcessMock.mockImplementation(async (spec: { args: string[] }) => { - call += 1 - if (call === 1) { - return { environmentResolved: true, code: 1, signal: null, stdout: '', stderr: 'stopped', timedOut: false } - } - const script = spec.args.at(-1) ?? '' - const begin = /__ORCA_WSL_CAPTURE_BEGIN_[a-z0-9]+__/.exec(script)?.[0] ?? '' - const end = /__ORCA_WSL_CAPTURE_END_[a-z0-9]+__/.exec(script)?.[0] ?? '' - return { environmentResolved: true, code: 0, signal: null, stdout: `${begin}${end}`, stderr: '', timedOut: false } + it('runs anyway and says the PATH is unresolved', async () => { + // A missing login PATH used to throw, and every knob this runner carried -- + // cooldown tiers, budget splitting, a re-probe heuristic, an opt-out on 19 + // of 23 sites -- existed to work around that. It is now just a fact in the + // result. + runProcessMock.mockResolvedValue({ + code: 1, + signal: null, + stdout: '', + stderr: 'distro is stopped', + timedOut: false }) - // Default is to refuse: answering "is codex installed?" on the bare default - // PATH reports an nvm install as absent, which is #9725. - await expect(runWslProcess({ lane: 'probe', program: 'codex' })).rejects.toThrow( - /guest environment/ - ) - - const degraded = await runWslProcess({ - lane: 'probe', - program: 'codex', - allowDegradedEnvironment: true - }) - expect(degraded.environmentResolved).toBe(false) + const result = await runWslProcess({ loginPath: 'preferred', program: 'codex' }) + expect(result.environmentResolved).toBe(false) expect(lastArgv()).toEqual(['--exec', 'codex']) }) it('reports a resolved environment on the happy path', async () => { seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - const result = await runWslProcess({ lane: 'probe', program: 'codex' }) + const result = await runWslProcess({ loginPath: 'preferred', program: 'codex' }) expect(result.environmentResolved).toBe(true) }) }) -describe('interactive lane', () => { - it('always fences stdout, even when the caller ignores it', async () => { - // Stock Ubuntu writes its rc hint to stdout, so an unfenced parse reads the - // banner as data (#11327, #11823). A caller that starts parsing later must - // not have to remember to opt in. - runProcessMock.mockImplementation(async (spec: { args: string[] }) => { - const script = spec.args.at(-1) ?? '' - const begin = /__ORCA_WSL_CAPTURE_BEGIN_[a-z0-9]+__/.exec(script)?.[0] ?? '' - const end = /__ORCA_WSL_CAPTURE_END_[a-z0-9]+__/.exec(script)?.[0] ?? '' - return { - environmentResolved: true, - code: 0, - signal: null, - stdout: `Ubuntu banner: run a command as administrator\n${begin}payload${end}`, - stderr: '', - timedOut: false - } - }) - const result = await runWslProcess({ lane: 'interactive', program: 'claude' }) - expect(result.stdout).toBe('payload') - }) -}) - describe('scripts', () => { - it('delivers a script on stdin through sh -s, not in argv', async () => { - // A script on stdin has no quoting boundary for its own quotes to escape - // from. That is what the base64 and eval wrappers were working around - // (#14292), and why this is the only supported way to run one. + it('passes a script in argv by default, leaving the command its own stdin', async () => { + // A script the runner pipes cannot coexist with a command that reads stdin: + // the command drains the rest of the script, the shell hits EOF and exits + // 0, and the truncation is silent. argv has no such conflict, and --exec + // means no host-side shell re-parses the quotes (#14292). seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) const script = `case "$x" in a) echo 'it'\\''s fine';; esac` - await runWslProcess({ lane: 'probe', script, args: ['/tmp/root'] }) - expect(runProcessMock.mock.calls.at(-1)?.[0].input).toBe(script) + await runWslProcess({ loginPath: 'preferred', script, args: ['/tmp/root'] }) + expect(runProcessMock.mock.calls.at(-1)?.[0].input).toBeUndefined() expect(lastArgv()).toEqual([ '--exec', '/usr/bin/env', 'PATH=/home/u/.nvm/bin:/usr/bin', 'HOME=/home/u', 'sh', - '-s', + '-c', + script, '--', '/tmp/root' ]) - expect(lastArgv().join(' ')).not.toContain('case') }) -}) -describe('WSLENV', () => { + it('leaves stdin free so a stdin-reading command cannot truncate the script', async () => { + // The regression this pins: with the script piped, `ssh -T` in a user hook + // drains the pipe, bash reads EOF, exits 0, and every later line of the + // hook silently never runs -- while the caller logs success. + seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) + await runWslProcess({ + loginPath: 'preferred', + shell: 'bash', + script: 'ssh -T git@github.com || true\npnpm install' + }) + const spec = runProcessMock.mock.calls.at(-1)?.[0] + expect(spec.input).toBeUndefined() + expect(spec.args).toContain('ssh -T git@github.com || true\npnpm install') + }) + + it('falls back to stdin for a script too large for a command line', async () => { + // A user hook is the one unbounded script here (`run-both` concatenates two + // orca.yaml scripts, and a vendored installer is ~15KB). Windows caps the + // command line at 32767, so argv would fail to spawn at all; stdin still + // runs it, which is the lesser loss. + seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) + const script = `echo ${'x'.repeat(9_000)}` + await runWslProcess({ loginPath: 'preferred', shell: 'bash', script }) + const spec = runProcessMock.mock.calls.at(-1)?.[0] + expect(spec.input).toBe(script) + expect(lastArgv().slice(-3)).toEqual(['bash', '-s', '--']) + }) + + it('keeps an ordinary-sized script in argv', async () => { + seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) + const script = `echo ${'x'.repeat(100)}` + await runWslProcess({ loginPath: 'preferred', shell: 'bash', script }) + expect(runProcessMock.mock.calls.at(-1)?.[0].input).toBeUndefined() + }) + it('adds every propagated key so the value actually crosses the boundary', async () => { // Unset, a Windows-side variable silently never reaches the guest (#12557). seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', program: '/bin/true', env: { GITLAB_HOST: 'git.example.com', GH_TOKEN: 't' } }) @@ -189,17 +186,27 @@ describe('WSLENV', () => { expect(env.WSLENV?.split(':')).toEqual(expect.arrayContaining(['GITLAB_HOST', 'GH_TOKEN'])) }) - it('leaves the host environment alone when nothing is propagated', async () => { + it('always sets WSL_UTF8, even with nothing to propagate', async () => { + // Without it wsl.exe writes its own error text as UTF-16LE, so anything + // surfacing stderr shows NUL-riddled output (#9010). seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane: 'probe', program: '/bin/true' }) - expect(runProcessMock.mock.calls.at(-1)?.[0].env).toBeUndefined() + await runWslProcess({ loginPath: 'preferred', program: '/bin/true' }) + const env = runProcessMock.mock.calls.at(-1)?.[0].env as NodeJS.ProcessEnv + expect(env.WSL_UTF8).toBe('1') + }) + + it('adds no WSLENV entry when nothing is propagated', async () => { + seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) + await runWslProcess({ loginPath: 'preferred', program: '/bin/true' }) + const env = runProcessMock.mock.calls.at(-1)?.[0].env as NodeJS.ProcessEnv + expect(env.WSLENV).toBe(process.env.WSLENV) }) }) describe('guest cwd', () => { it('cds inside the guest rather than passing a Windows cwd to wsl.exe', async () => { seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane: 'probe', program: '/usr/bin/git', cwd: '/home/u/repo' }) + await runWslProcess({ loginPath: 'preferred', program: '/usr/bin/git', cwd: '/home/u/repo' }) expect(runProcessMock.mock.calls.at(-1)?.[0].cwd).toBeUndefined() expect(lastArgv()).toContain('/home/u/repo') expect(lastArgv()).toContain('sh') @@ -209,9 +216,9 @@ describe('guest cwd', () => { // A Windows path here means a mistake further up; converting it silently // hides that. seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await expect(runWslProcess({ lane: 'probe', program: '/bin/true', cwd })).rejects.toThrow( - /guest path/ - ) + await expect( + runWslProcess({ loginPath: 'preferred', program: '/bin/true', cwd }) + ).rejects.toThrow(/guest path/) }) }) @@ -219,7 +226,9 @@ describe('program is a binary, not a shell string', () => { it.each([['sh -c echo hi'], ['a; b'], ['a | b'], ['a && b'], ['echo $HOME'], ['a > b']])( 'rejects %s', async (program) => { - await expect(runWslProcess({ lane: 'probe', program })).rejects.toThrow(/single binary/) + await expect(runWslProcess({ loginPath: 'preferred', program })).rejects.toThrow( + /single binary/ + ) } ) @@ -228,55 +237,17 @@ describe('program is a binary, not a shell string', () => { // rejecting it would fail legitimate installs under a spaced directory. seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) await expect( - runWslProcess({ lane: 'probe', program: '/home/u/my tools/codex' }) + runWslProcess({ loginPath: 'preferred', program: '/home/u/my tools/codex' }) ).resolves.toBeDefined() }) }) -describe('a script gets the cached environment on both lanes', () => { - it('applies the cached PATH even when the caller asked for interactive', async () => { - // A script never runs under the login shell (it owns stdin), so without - // this the interactive lane would give a script LESS PATH than the probe - // lane -- for a caller that explicitly asked for the user's terminal PATH. - seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane: 'interactive', script: 'command -v codex' }) - expect(lastArgv()).toEqual([ - '--exec', - '/usr/bin/env', - 'PATH=/home/u/.nvm/bin:/usr/bin', - 'HOME=/home/u', - 'sh', - '-s', - '--' - ]) - }) -}) - -describe('a missing fence is a failure, not empty output', () => { - it('throws rather than returning a clean empty result', async () => { - // readStdout returns null precisely to distinguish "no fence" from "empty - // payload". An rc that redirects stdout would otherwise yield a silent - // wrong answer. - runProcessMock.mockResolvedValue({ - environmentResolved: true, - code: 0, - signal: null, - stdout: 'banner only, no fence', - stderr: '', - timedOut: false - }) - await expect(runWslProcess({ lane: 'interactive', program: 'claude' })).rejects.toThrow( - /no fenced output/ - ) - }) -}) - describe('program is not an assignment', () => { it('rejects a name=value program that env would swallow', async () => { // `env PATH=… HOME=… FOO=bar` has no command left: it prints the whole // guest environment and exits 0 -- success, with the environment as stdout. seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await expect(runWslProcess({ lane: 'probe', program: 'FOO=bar' })).rejects.toThrow( + await expect(runWslProcess({ loginPath: 'preferred', program: 'FOO=bar' })).rejects.toThrow( /assignment/ ) }) @@ -288,7 +259,7 @@ describe('timeout budget', () => { // 5s caller could reach runProcess with 1ms left and report a timeout for a // command that would have taken milliseconds. seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane: 'probe', program: '/bin/true', timeoutMs: 5_000 }) + await runWslProcess({ loginPath: 'preferred', program: '/bin/true', timeoutMs: 5_000 }) const passed = runProcessMock.mock.calls.at(-1)?.[0].timeoutMs as number expect(passed).toBeGreaterThan(1_000) expect(passed).toBeLessThanOrEqual(5_000) @@ -305,13 +276,12 @@ describe('the probe never starves the command', () => { // ~1667ms -- less than the 5s it had before the runner existed, which is // how a cold distro came back as "not installed". // No seed: the probe must actually run, or this measures the command leg - // and passes for any split. allowDegradedEnvironment keeps the call alive - // once the probe fails. + // and passes for any split. A failed probe is non-fatal now, so the call + // still reaches its command. await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', program: '/bin/true', - timeoutMs, - allowDegradedEnvironment: true + timeoutMs }) const probeMs = runProcessMock.mock.calls[0]?.[0].timeoutMs as number expect(probeMs).toBeLessThanOrEqual(timeoutMs - floor) @@ -321,7 +291,7 @@ describe('the probe never starves the command', () => { describe('script interpreter', () => { it('defaults to sh', async () => { seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane: 'probe', script: 'echo hi' }) + await runWslProcess({ loginPath: 'preferred', script: 'echo hi' }) expect(lastArgv()).toContain('sh') expect(lastArgv()).not.toContain('bash') }) @@ -332,17 +302,17 @@ describe('script interpreter', () => { // unexpected` -- the #14292 signature -- so a bash caller must be able to // say so rather than be silently downgraded. seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane: 'probe', script: 'done < <(find .)', shell: 'bash' }) + await runWslProcess({ loginPath: 'preferred', script: 'done < <(find .)', shell: 'bash' }) expect(lastArgv()).toContain('bash') expect(lastArgv()).not.toContain('sh') }) }) -describe('a script never rides the interactive lane', () => { - it('runs sh -s even when the distro cannot be probed', async () => { - // The login shell owns stdin, and the script arrives on stdin. If the shell - // consumed it, `sh -s` would read EOF, run nothing and exit 0 -- a silent - // wrong answer, worse than the degraded PATH this avoids. +describe('a script never rides a login shell', () => { + it('runs the script shell-free even when the distro cannot be probed', async () => { + // No login shell in the way: it would own stdin and could consume it, + // leaving the script's shell to read EOF, run nothing and exit 0 -- a + // silent wrong answer, worse than the degraded PATH this avoids. runProcessMock.mockResolvedValue({ environmentResolved: true, code: 1, @@ -351,8 +321,26 @@ describe('a script never rides the interactive lane', () => { stderr: 'distro is stopped', timedOut: false }) - await runWslProcess({ lane: 'probe', script: 'echo hi', allowDegradedEnvironment: true }) - expect(lastArgv()).toEqual(['--exec', 'sh', '-s', '--']) - expect(runProcessMock.mock.calls.at(-1)?.[0].input).toBe('echo hi') + await runWslProcess({ loginPath: 'preferred', script: 'echo hi' }) + expect(lastArgv()).toEqual(['--exec', 'sh', '-c', 'echo hi', '--']) + expect(runProcessMock.mock.calls.at(-1)?.[0].input).toBeUndefined() + }) +}) + +describe('WSLENV', () => { + it('never forwards a path-shaped variable', async () => { + // wsl.exe translates path-shaped variables between Windows and Linux form, + // so naming PATH in WSLENV replaces the guest's own PATH with a translated + // Windows one. Silent, and fatal to every lookup that follows. + seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) + await runWslProcess({ + loginPath: 'none', + program: '/bin/true', + env: { PATH: 'C:\\bin', HOME: 'C:\\home', GITLAB_HOST: 'git.example.com' } + }) + const wslenv = String(runProcessMock.mock.calls.at(-1)?.[0].env.WSLENV ?? '') + expect(wslenv.split(':')).toContain('GITLAB_HOST') + expect(wslenv.split(':')).not.toContain('PATH') + expect(wslenv.split(':')).not.toContain('HOME') }) }) diff --git a/src/main/wsl/wsl-runner.ts b/src/main/wsl/wsl-runner.ts index cebc88b3831..f1a51d4b923 100644 --- a/src/main/wsl/wsl-runner.ts +++ b/src/main/wsl/wsl-runner.ts @@ -1,10 +1,6 @@ import { addWslEnvKeys } from '../../shared/wsl-env' import { runProcess } from '../../shared/child-process/run-process' -import { - buildWslCapturedLoginShellCommand, - buildWslExecArgs, - quotePosixShell -} from '../../shared/wsl-login-shell-command' +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' import { getWslGuestEnvironment, type WslGuestEnvironment } from './wsl-guest-environment' import { resolveWslExecutablePath } from './wsl-executable-path' @@ -17,22 +13,35 @@ import { resolveWslExecutablePath } from './wsl-executable-path' * docs/reference/wsl-command-execution.md. */ -export type WslLane = - /** No shell. Cached login PATH/HOME, applied via `env`. */ - | 'probe' - /** Login shell, always fenced. For anything whose PATH must match the user's terminal. */ - | 'interactive' +/** + * How much the call needs the user's login PATH. + * + * Why one axis and not `lane` + `allowDegradedEnvironment`: 19 of 23 sites + * passed the opt-out, and two of them said in comments that they did not want + * the login PATH at all -- the flag had become the `'none'` this union was + * missing. A required discriminator whose default nobody wants is not a + * discriminator. + */ +export type WslLoginPath = + /** The command reads $HOME or absolute paths only. No probe. */ + | 'none' + /** Use the cached login PATH when there is one; run anyway when there is not. */ + | 'preferred' /** * What to run: a single binary, or a script. * - * Why a union rather than an optional `script`: a script has to arrive on - * stdin, which means the program must be a shell reading stdin. Left to the - * caller that is a footgun — and the wrappers this replaces exist because it - * was never made explicit. `script` makes the runner supply `sh -s` itself. + * Why a union rather than an optional `script`: a script needs a shell to run + * it, and picking one per call site is how the wrappers this replaces drifted + * apart. `script` makes the runner supply the shell, and `shell` says which. */ export type WslCommand = - | { program: string; args?: readonly string[]; script?: never; shell?: never } + | { + program: string + args?: readonly string[] + script?: never + shell?: never + } | { script: string args?: readonly string[] @@ -53,39 +62,29 @@ export type WslCommand = export type WslSpec = WslCommand & { /** Undefined selects the distro's default. */ distro?: string - /** - * Required, with no default. The wrong lane is the most common WSL defect in - * this tree, and a default lets a call site pick it by omission. - */ - lane: WslLane + /** Required, with no default: the wrong answer here is the defect this file exists to prevent. */ + loginPath: WslLoginPath /** Guest (POSIX) path. */ cwd?: string /** Host variables to propagate into the guest; sets WSLENV automatically. */ env?: Readonly> timeoutMs?: number maxOutputBytes?: number - /** - * Proceed when the login PATH could not be established. - * - * Default is to throw. Three separate reviews found the same class of bug -- - * a call that answers "is this installed?" running on the bare default PATH - * and reporting an nvm-installed tool absent (#9725). Making degradation - * opt-in puts that decision in the one place a reader will look, instead of - * relying on every call site to remember to check `environmentResolved`. - */ - allowDegradedEnvironment?: boolean } export type WslResult = { /** - * False when the login PATH could not be established, so the call ran on the - * distro's default PATH. A caller deciding "is this tool installed?" must - * report unverifiable rather than absent -- an nvm-installed binary is - * invisible without the login PATH, which is #9725 exactly. + * False when `loginPath: 'preferred'` could not establish the login PATH, so + * the call ran on the distro's default PATH. A caller deciding "is this tool + * installed?" must then report unverifiable rather than absent -- an + * nvm-installed binary is invisible without it, which is #9725 exactly. + * + * Always true under `loginPath: 'none'`, because such a command asked for no + * PATH and cannot be let down by one. A PATH lookup marked 'none' therefore + * gets no protection from this field; the value has to be right. */ environmentResolved: boolean code: number | null - /** Payload only — on the interactive lane the rc banner is removed by the fence. */ stdout: string stderr: string timedOut: boolean @@ -93,21 +92,6 @@ export type WslResult = { export const DEFAULT_WSL_TIMEOUT_MS = 30_000 -/** - * The guest login PATH could not be established. - * - * Typed so a caller answering "is this installed?" can report unverifiable - * rather than absent -- reporting an nvm-installed tool absent is #9725, and a - * bare Error would just be swallowed by the same catch that handles real - * failures. - */ -export class WslGuestEnvironmentUnavailableError extends Error { - constructor(distro: string | undefined) { - super(`WSL guest environment for ${distro ?? 'the default distro'} is unavailable`) - this.name = 'WslGuestEnvironmentUnavailableError' - } -} - function assertGuestPath(cwd: string): void { // Why reject rather than convert: a caller passing a Windows path here has // usually made a different mistake further up, and silently translating it @@ -131,13 +115,27 @@ function assertNotShellString(program: string): void { } } -/** Host env plus the WSLENV entries that let it cross the boundary. */ -function buildHostEnv(env: WslSpec['env']): NodeJS.ProcessEnv | undefined { - if (!env || Object.keys(env).length === 0) { - return undefined +/** + * Host env plus WSL_UTF8 and the WSLENV entries that let values cross. + * + * Why WSL_UTF8 unconditionally: without it `wsl.exe` writes its OWN messages + * ("There is no distribution with the supplied name") as UTF-16LE, so every + * caller that surfaces stderr shows NUL-riddled text. Setting it per-call site + * is how it got lost -- the relay set it, the migration dropped it, and nothing + * noticed because the happy path is pure ASCII. Credit: #9010. + */ +function buildHostEnv(env: WslSpec['env']): NodeJS.ProcessEnv { + const merged: NodeJS.ProcessEnv = { ...process.env, ...env, WSL_UTF8: '1' } + // Never name a path-shaped variable in WSLENV. wsl.exe translates those + // between Windows and Linux form, so forwarding PATH replaces the guest's + // own PATH with a translated Windows one -- silently, and this runner exists + // to make that class of mistake impossible rather than to document it. + const crossable = Object.keys(env ?? {}).filter( + (key) => !['PATH', 'HOME', 'TMP', 'TEMP'].includes(key) + ) + if (crossable.length > 0) { + addWslEnvKeys(merged, crossable) } - const merged: NodeJS.ProcessEnv = { ...process.env, ...env } - addWslEnvKeys(merged, Object.keys(env)) return merged } @@ -161,54 +159,51 @@ function withGuestCwd(cwd: string | undefined, argv: readonly string[]): string[ return ['sh', '-c', 'cd "$1" || exit 1; shift; exec "$@"', 'orca-wsl', cwd, ...argv] } -/** ` -s --` for a script on stdin, otherwise the program itself. */ -function guestCommandArgv(spec: WslSpec): string[] { - return spec.script !== undefined - ? [spec.shell ?? 'sh', '-s', '--', ...(spec.args ?? [])] - : [spec.program, ...(spec.args ?? [])] +/** + * Argv is the default, but it has a hard ceiling that stdin does not. + * + * Windows caps a command line at 32767 characters, and the distro, `--exec`, + * the env prefix and the args all share it. A user's `orca.yaml` hook is the + * one unbounded script Orca runs -- `run-both` concatenates two of them, and a + * vendored installer is ~15KB -- so past this size the choice is between + * failing to spawn at all and accepting the stdin caveat. Degrading beats + * failing: a large script that also reads stdin was already broken, while a + * large script that does not now works where it would have died. + */ +const MAX_ARGV_SCRIPT_CHARS = 8_000 + +/** ` -c`/`-s` for a script, otherwise the program itself. */ +function guestCommandArgv(spec: WslSpec, delivery: 'argv' | 'stdin'): string[] { + if (spec.script === undefined) { + return [spec.program, ...(spec.args ?? [])] + } + const shell = spec.shell ?? 'sh' + // `--` keeps positional args starting at $1 under both forms. + return delivery === 'stdin' + ? [shell, '-s', '--', ...(spec.args ?? [])] + : [shell, '-c', spec.script, '--', ...(spec.args ?? [])] } /** Shell-free argv, with the cached environment applied when one is available. */ -function buildGuestArgv(environment: WslGuestEnvironment | null, spec: WslSpec): string[] { - const command = guestCommandArgv(spec) +function buildGuestArgv( + environment: WslGuestEnvironment | null, + spec: WslSpec, + delivery: 'argv' | 'stdin' +): string[] { + const command = guestCommandArgv(spec, delivery) const argv = environment ? [environment.envBinary, `PATH=${environment.path}`, `HOME=${environment.home}`, ...command] : command return withGuestCwd(spec.cwd, argv) } -function buildInteractiveArgv(spec: WslSpec): { - argv: string[] - readStdout: (stdout: string) => string -} { - // Why the whole invocation goes through the fence: a caller that does not - // parse stdout today may start tomorrow, and the banner is invisible until - // then. - const quoted = guestCommandArgv(spec).map(quotePosixShell).join(' ') - const body = spec.cwd ? `cd ${quotePosixShell(spec.cwd)} || exit 1\n${quoted}` : quoted - const captured = buildWslCapturedLoginShellCommand(body) - return { - argv: ['sh', '-c', captured.command], - // Why throw rather than default to '': readStdout returns null precisely to - // distinguish "the fence never appeared" from "the payload was empty". An rc - // that redirects stdout, or output truncated before the begin marker, would - // otherwise return a clean, empty, wrong answer. - readStdout: (stdout: string) => { - const payload = captured.readStdout(stdout) - if (payload === null) { - throw new Error('WSL login shell produced no fenced output') - } - return payload - } - } -} - /** * Run a program inside WSL. * - * Throws when the guest login PATH cannot be established, unless the caller - * passes `allowDegradedEnvironment`. Falling back to the login shell here would - * re-run ~/.profile -- the stall this exists to remove. + * A missing login PATH is never fatal: the call runs on the distro's default + * PATH and reports `environmentResolved: false`. Every knob this file used to + * carry -- cooldown tiers, budget splitting, a re-probe heuristic, an opt-out + * flag on 19 of 23 sites -- existed only because that case used to throw. */ export async function runWslProcess(spec: WslSpec): Promise { if (spec.program !== undefined) { @@ -219,13 +214,7 @@ export async function runWslProcess(spec: WslSpec): Promise { } const deadline = Date.now() + (spec.timeoutMs ?? DEFAULT_WSL_TIMEOUT_MS) - // Why both lanes when there is a script: a script never runs under the login - // shell (see below), so on the interactive lane it would otherwise get no - // login PATH at all -- strictly less than the probe lane, for a caller that - // explicitly asked for the user's terminal PATH. - const wantsEnvironment = spec.lane === 'probe' || spec.script !== undefined - // Leave the command at least a third of the budget: a probe that eats it all - // turns a healthy command into a spurious timeout. + const wantsEnvironment = spec.loginPath === 'preferred' // Cap the probe at half the budget and at 4s: a 5s caller was giving the // probe 3333ms and its own command 1667ms, tighter than the 5s it had before // the runner existed, which is how a cold distro read as "not installed". @@ -240,23 +229,20 @@ export async function runWslProcess(spec: WslSpec): Promise { // the probe most often fails *because* the distro is slow, so the fallback // would hit the hazard exactly when it is worst. Run shell-free with the // distro's default PATH instead: degraded, never blocking. - if (wantsEnvironment && environment === null && !spec.allowDegradedEnvironment) { - throw new WslGuestEnvironmentUnavailableError(spec.distro) - } - - const lane = - spec.lane === 'interactive' && spec.script === undefined - ? ({ kind: 'interactive', ...buildInteractiveArgv(spec) } as const) - : ({ kind: 'probe', argv: buildGuestArgv(environment, spec) } as const) + // Resolved once: argv shape and stdin payload must agree, and computing it + // in both places invites them to drift. + const delivery: 'argv' | 'stdin' = + spec.script !== undefined && spec.script.length > MAX_ARGV_SCRIPT_CHARS ? 'stdin' : 'argv' + const argv = buildGuestArgv(environment, spec, delivery) // One budget for the whole call: the probe used to run on its own 10s timer // ahead of the timed leg, so a 5s caller could wait 15s. const remainingMs = Math.max(1, deadline - Date.now()) const result = await runProcess({ program: resolveWslExecutablePath(), - args: buildWslExecArgs(spec.distro, lane.argv), + args: buildWslExecArgs(spec.distro, argv), env: buildHostEnv(spec.env), - input: spec.script, + input: delivery === 'stdin' ? spec.script : undefined, timeoutMs: remainingMs, maxOutputBytes: spec.maxOutputBytes }) @@ -264,7 +250,7 @@ export async function runWslProcess(spec: WslSpec): Promise { return { environmentResolved: !wantsEnvironment || environment !== null, code: result.code, - stdout: lane.kind === 'interactive' ? lane.readStdout(result.stdout) : result.stdout, + stdout: result.stdout, stderr: result.stderr, timedOut: result.timedOut } diff --git a/src/main/wsl/wsl-runner.wsl.test.ts b/src/main/wsl/wsl-runner.wsl.test.ts index cd75de1ac02..c31ac669140 100644 --- a/src/main/wsl/wsl-runner.wsl.test.ts +++ b/src/main/wsl/wsl-runner.wsl.test.ts @@ -43,16 +43,14 @@ describeOnWsl('runWslProcess against a real distro', () => { }, 120_000) it('probe lane survives a ~/.profile that blocks for a minute', async () => { - // The blocking profile makes the probe time out, so the lane degrades -- - // that is the point: the call must still answer inside its own budget - // rather than inheriting the 60s stall. This is #14288 against a real - // distro, and it needs allowDegradedEnvironment because the runner - // otherwise refuses an unresolved PATH. + // The blocking profile makes the probe time out -- that is the point: the + // call must still answer inside its own budget rather than inheriting the + // 60s stall. This is #14288 against a real distro, and it relies on a + // failed probe being non-fatal. const started = Date.now() const result = await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', distro: DISTRO, - allowDegradedEnvironment: true, program: '/bin/echo', args: ['orca-probe-ok'], timeoutMs: 15_000 @@ -65,11 +63,10 @@ describeOnWsl('runWslProcess against a real distro', () => { it('second probe-lane call does not pay the login shell again', async () => { const started = Date.now() await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', distro: DISTRO, program: '/bin/true', - timeoutMs: 15_000, - allowDegradedEnvironment: true + timeoutMs: 15_000 }) expect(Date.now() - started).toBeLessThan(5_000) }, 30_000) @@ -78,7 +75,7 @@ describeOnWsl('runWslProcess against a real distro', () => { // Stock Ubuntu writes its rc hint to stdout. Anything parsing that stream // reads the banner as data unless the fence removes it (#11327, #11823). const result = await runWslProcess({ - lane: 'interactive', + loginPath: 'preferred', distro: DISTRO, program: '/bin/echo', args: ['ORCA_PAYLOAD'], @@ -96,9 +93,8 @@ describeOnWsl('runWslProcess against a real distro', () => { `echo "x" | awk '{print $1}'` ].join('\n') const result = await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', distro: DISTRO, - allowDegradedEnvironment: true, script, args: ['ORCA_ARG'], timeoutMs: 30_000 @@ -113,9 +109,8 @@ describeOnWsl('runWslProcess against a real distro', () => { it('propagated env crosses the boundary via WSLENV', async () => { const result = await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', distro: DISTRO, - allowDegradedEnvironment: true, script: 'printf %s "$ORCA_WSLENV_PROBE"', env: { ORCA_WSLENV_PROBE: 'crossed' }, timeoutMs: 30_000 @@ -125,9 +120,8 @@ describeOnWsl('runWslProcess against a real distro', () => { it('runs in the requested guest cwd', async () => { const result = await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', distro: DISTRO, - allowDegradedEnvironment: true, program: '/bin/pwd', cwd: '/tmp', timeoutMs: 30_000 diff --git a/src/main/wsl/wsl-w1-w3-contract.test.ts b/src/main/wsl/wsl-w1-w3-contract.test.ts index df297d9177a..0da8d0f900b 100644 --- a/src/main/wsl/wsl-w1-w3-contract.test.ts +++ b/src/main/wsl/wsl-w1-w3-contract.test.ts @@ -44,27 +44,32 @@ describe('W1: every WSL call inherits the spawn chokepoint', () => { it('resolves wsl.exe absolutely, never by bare name', async () => { // Bare-name resolution is what a Group Policy or a stripped Electron PATH // hijacks (#15749). - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: '/bin/true' }) + await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', program: '/bin/true' }) expect(spawnSpec().program).toBe('C:\\Windows\\System32\\wsl.exe') }) it('passes argv as an array, never a command string', async () => { // A command string would put quoting back in the caller's hands, which is // the entire class W1 removed. - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: '/bin/echo', args: ['a b'] }) + await runWslProcess({ + loginPath: 'preferred', + distro: 'Ubuntu', + program: '/bin/echo', + args: ['a b'] + }) expect(Array.isArray(spawnSpec().args)).toBe(true) expect(spawnSpec().args).toContain('a b') }) it('always bounds the call', async () => { - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: '/bin/true' }) + await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', program: '/bin/true' }) expect(spawnSpec().timeoutMs).toBeGreaterThan(0) }) }) describe('W3: the five per-call decisions are made once', () => { it('never uses the -- separator, which expands $name host-side', async () => { - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', script: 'echo "$HOME"' }) + await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', script: 'echo "$HOME"' }) const argv = spawnSpec().args as string[] // Only wsl.exe's own separator matters. A later `--` belongs to `sh -s --` // and is the guest shell's end-of-options, so check the position rather @@ -74,17 +79,18 @@ describe('W3: the five per-call decisions are made once', () => { }) it('keeps a script byte-identical instead of encoding it', async () => { - // The base64 and eval wrappers existed because argv quoting kept breaking; - // stdin has no quoting boundary at all (#14292). + // The base64 and eval wrappers existed because a host-side shell re-parsed + // the quotes (#14292). --exec removes that shell, so the script crosses in + // argv exactly as written -- no encoding, and stdin stays the command's. const script = `case "$x" in a) echo 'it'\\''s fine';; esac` - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', script }) - expect(spawnSpec().input).toBe(script) - expect(JSON.stringify(spawnSpec().args)).not.toContain('case') + await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', script }) + expect(spawnSpec().args).toContain(script) + expect(spawnSpec().input).toBeUndefined() }) it('propagates env only through WSLENV', async () => { await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', distro: 'Ubuntu', program: '/bin/true', env: { GITLAB_HOST: 'git.example.com' } @@ -95,14 +101,14 @@ describe('W3: the five per-call decisions are made once', () => { it('runs no shell on the probe lane, so ~/.profile cannot stall it', async () => { // #14288: one blocking line in ~/.profile ate the whole timeout. - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: 'codex' }) + await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', program: 'codex' }) expect(JSON.stringify(spawnSpec().args)).not.toContain('_orca_wsl_shell') }) it('applies the user login PATH even with no shell in the loop', async () => { // The other half of the same trade: nvm-installed agents must still be // found (#9725, #7563, #8366). - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: 'codex' }) + await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', program: 'codex' }) expect(spawnSpec().args).toContain('PATH=/home/u/.nvm/bin:/usr/bin') }) }) @@ -118,7 +124,11 @@ describe('failure modes stay distinguishable', () => { stderr: '', timedOut: false }) - const result = await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: '/bin/false' }) + const result = await runWslProcess({ + loginPath: 'preferred', + distro: 'Ubuntu', + program: '/bin/false' + }) expect(result.code).toBe(3) expect(result.timedOut).toBe(false) }) @@ -131,11 +141,15 @@ describe('failure modes stay distinguishable', () => { stderr: '', timedOut: true }) - const result = await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: '/bin/true' }) + const result = await runWslProcess({ + loginPath: 'preferred', + distro: 'Ubuntu', + program: '/bin/true' + }) expect(result.timedOut).toBe(true) }) - it('an unresolved login PATH refuses rather than answering wrongly', async () => { + it('an unresolved login PATH is reported, never fatal', async () => { invalidateWslGuestEnvironment(undefined, true) runProcessMock.mockResolvedValue({ code: 1, @@ -144,8 +158,12 @@ describe('failure modes stay distinguishable', () => { stderr: 'stopped', timedOut: false }) - await expect( - runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: 'codex' }) - ).rejects.toThrow(/guest environment/) + // Every knob this runner used to carry existed because this case threw. + const result = await runWslProcess({ + loginPath: 'preferred', + distro: 'Ubuntu', + program: 'codex' + }) + expect(result.environmentResolved).toBe(false) }) }) diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx index dee03d0c6dd..777006cafad 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx @@ -18,7 +18,9 @@ import { function decodeWslLoginShellScript(command: string): string { const encoded = - /(?:--|--exec) sh -c 'eval \\"`printf %s ([A-Za-z0-9+/=]+) \| base64 -d`\\"'/.exec(command)?.[1] + /(?:--|--exec) sh -c '(?:eval \\"`|sh -c \\"\$\()?printf %s ([A-Za-z0-9+/=]+) \| base64 -d/.exec( + command + )?.[1] expect(encoded).toBeDefined() return Buffer.from(encoded!, 'base64').toString('utf8') } @@ -48,7 +50,7 @@ describe('CliSkillRuntimeSetup runtime helpers', () => { expect(command).toBe(skillCommand) expect(setupCommand).toBe( - `& { $PSNativeCommandArgumentPassing = 'Legacy'; wsl.exe -d 'Ubuntu' --exec sh -c 'eval \\"\`printf %s ${encoded} | base64 -d\`\\"' } # Runs: ${skillCommand}` + `& { $PSNativeCommandArgumentPassing = 'Legacy'; wsl.exe -d 'Ubuntu' --exec sh -c 'sh -c \\"$(printf %s ${encoded} | base64 -d)\\"' } # Runs: ${skillCommand}` ) expect(decodeWslLoginShellScript(setupCommand)).toContain( 'exec "$_orca_wsl_shell" -ilc \'npx skills add orchestration --global\'' @@ -93,9 +95,11 @@ describe('CliSkillRuntimeSetup runtime helpers', () => { const setupCommand = buildSkillSetupTerminalCommand(command, 'powershell.exe', runtime, 'win32') expect(setupCommand).toMatch( - /^& \{ \$PSNativeCommandArgumentPassing = 'Legacy'; wsl\.exe --exec sh -c 'eval \\"`printf/ + /^& \{ \$PSNativeCommandArgumentPassing = 'Legacy'; wsl\.exe --exec sh -c 'sh -c/ ) - expect(setupCommand).toContain('`\\"\' } # Runs: npx skills update orchestration --global') + // The command no longer ends in the eval wrapper's nested quotes; it is a + // plain pipe into sh, so the payload has no quoting layer to escape from. + expect(setupCommand).toContain('base64 -d)') }) it.skipIf(process.platform === 'win32')( diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index 3208aa2cd04..9e454fcee7f 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -164,7 +164,15 @@ function buildPowerShellWslSkillCommand(command: string, runtime: LocalAgentRunt // Why: encoding preserves the user's configured login-shell PATH across the Windows argv boundary. const encodedScript = encodeWslLoginShellScript(command) const visibleCommand = command.replace(/[\r\n]+/g, ' ') - const shellScript = `eval "\`printf %s ${encodedScript} | base64 -d\`"` + // Why $(...) and not a backtick eval: PowerShell treats ` as its own escape + // character, so the old `eval "\`printf ...\`"` had the payload's quotes + // interacting with two escaping layers and dash saw `case in` -- the + // `word unexpected (expecting "in")` in #14292. Credit: #14785. + // + // Why not a plain pipe into sh: that hands the payload the pipe as its stdin, + // so a setup command that reads input gets base64 remnants instead. Command + // substitution runs in a subshell and leaves the terminal's stdin intact. + const shellScript = `sh -c "$(printf %s ${encodedScript} | base64 -d)"` // Why --exec: `--` makes wsl.exe expand $name in the argv it forwards to the guest. const wslCommand = `wsl.exe${distroArg} --exec sh -c ${quotePowerShellNativeArgument(shellScript)}` return `& { $PSNativeCommandArgumentPassing = 'Legacy'; ${wslCommand} } # Runs: ${visibleCommand}` @@ -180,7 +188,10 @@ function decodeWslSetupTerminalCommand(command: string): string | null { // Why both separators: commands persisted before the --exec switch must still decode. const encoded = - /(?:--|--exec) sh -c 'eval \\"`printf %s ([A-Za-z0-9+/=]+) \| base64 -d`\\"'/.exec(command)?.[1] + // Both shapes: commands persisted before the pipe switch still decode. + /(?:--|--exec) sh -c '(?:eval \\"`|sh -c \\"\$\()?printf %s ([A-Za-z0-9+/=]+) \| base64 -d/.exec( + command + )?.[1] if (!encoded) { return null } diff --git a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt index 83bc2a69ac9..0d275d272db 100644 --- a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt +++ b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt @@ -12,16 +12,12 @@ main/automations/external-manager.ts main/automations/precheck-runner.ts main/browser/browser-cookie-import.ts main/claude-accounts/keychain.ts -main/claude-accounts/service.ts main/cli/cli-installer.ts -main/codex-accounts/runtime-home-service.ts -main/codex-accounts/service.ts main/computer/macos-computer-use-permission-status.ts main/computer/macos-computer-use-permissions.ts main/computer/macos-native-provider-transport.ts main/daemon/daemon-bash-wrapper-osc133-fixture.ts main/daemon/daemon-process-identity-query.ts -main/daemon/daemon-process-inspection.ts main/daemon/daemon-process-start-time.ts main/emulator/android/android-command-runner.ts main/emulator/android/scrcpy-stream-session.ts @@ -36,7 +32,6 @@ main/ipc/developer-permissions.ts main/ipc/filesystem.ts main/ipc/macos-keyboard-layout-snapshot.ts main/ipc/notification-authorization-status.ts -main/ipc/preflight-command-exec.ts main/ipc/repos.ts main/ipc/worktree-apfs-clone.ts main/local-builds/local-build-candidate.ts @@ -51,7 +46,6 @@ main/pty/posix-pty-foreground-group.ts main/pty/posix-pty-process-groups.ts main/pty/windows-environment-path.ts main/rate-limits/codex-fetcher.ts -main/rate-limits/gemini-cli-oauth-extractor.ts main/runtime/orca-runtime-files.ts main/runtime/tls-certificate.ts main/ssh/ssh-connection.ts @@ -59,7 +53,6 @@ main/startup/appimage-cli-redirect.ts main/startup/ensure-virtual-display.ts main/startup/packaged-cli-entry-redirect.ts main/startup/windows-install-dir-acl-probe.ts -main/text-generation/commit-message-text-generation.ts main/win32-utils.ts main/window/clipboard-ipc-handlers.ts main/workspace-space-analysis.ts @@ -73,7 +66,6 @@ relay/fs-handler-git-fallback.ts relay/fs-handler-utils.ts relay/fs-list-files-fallback-chain.ts relay/git-handler.ts -relay/preflight-handler.ts relay/pty-shell-utils.ts relay/subprocess-tree-termination.ts relay/windows-port-scan.ts diff --git a/src/shared/child-process/run-process.test.ts b/src/shared/child-process/run-process.test.ts index d1058df0af8..19cea26096e 100644 --- a/src/shared/child-process/run-process.test.ts +++ b/src/shared/child-process/run-process.test.ts @@ -160,3 +160,18 @@ describe('a signal that is already aborted', () => { expect(Date.now() - startedAt).toBeLessThan(10_000) }, 20_000) }) + +describe('output truncation', () => { + const emit = (bytes: number): string => + `for (let i = 0; i < ${bytes}; i += 1) process.stderr.write('n'); process.stderr.write('FAILED')` + + it('keeps the head by default', async () => { + const result = await runProcess({ + program: process.execPath, + args: ['-e', emit(200)], + maxOutputBytes: 64 + }) + expect(result.stderr).not.toContain('FAILED') + expect(result.stderr).toBe('n'.repeat(64)) + }) +}) diff --git a/src/shared/child-process/windows-console-visibility.test.ts b/src/shared/child-process/windows-console-visibility.test.ts index cae64347875..e98a97cfa98 100644 --- a/src/shared/child-process/windows-console-visibility.test.ts +++ b/src/shared/child-process/windows-console-visibility.test.ts @@ -46,9 +46,6 @@ const SOURCE_ROOT = resolve(__dirname, '../..') */ const OWNER_FILE = 'shared/child-process/run-process.ts' - - - /** The call's argument text, brace-matched so a nested options literal stays whole. */ function readCallArguments(source: string, openParenIndex: number): string { let depth = 0 @@ -74,9 +71,34 @@ function findOffenders(): string[] { const decommented = stripComments(file.source) // Resolve `import { spawn as sp }` so a renamed binding is still a spawn. // The previous comment claimed this; only three names were hardcoded. - const aliases = [...decommented.matchAll(/\b(?:spawn|spawnSync|execFile|execFileSync|exec|execSync|fork)\s+as\s+(\w+)/g)].map( - (match) => match[1] - ) + const aliases = [ + ...decommented.matchAll( + /\b(?:spawn|spawnSync|execFile|execFileSync|exec|execSync|fork)\s+as\s+(\w+)/g + ) + ].map((match) => match[1]!) + // Names that resolve to `exec`/`execSync`, which imply shell: true. + const shellImplying = new Set(['exec', 'execSync']) + for (const match of decommented.matchAll(/\b(exec|execSync)\s+as\s+(\w+)/g)) { + shellImplying.add(match[2]!) + } + // `const run = promisify(execFile)` mints a third name, and it can wrap a + // renamed binding, so this has to run after the aliases are known. A + // planted `promisify(renamedExecFile)` spawn passed the guard without it. + for (const match of decommented.matchAll( + // `\w*[Pp]romisify` so `import { promisify as p }` is still seen. + /(?:const|let|var)\s+(\w+)\s*=\s*\w*[Pp]romisify\s*\(\s*(\w+)\s*\)/g + )) { + const wrapped = match[2]! + if ( + aliases.includes(wrapped) || + /^(?:spawn|spawnSync|execFile|execFileSync|exec|execSync|fork)$/.test(wrapped) + ) { + aliases.push(match[1]!) + if (shellImplying.has(wrapped)) { + shellImplying.add(match[1]!) + } + } + } // The import test needs the module name, which blanking would erase; the // call scan needs parens inside strings neutralised. Two views, one file. if (!CHILD_PROCESS_IMPORT.test(decommented)) { @@ -99,7 +121,24 @@ function findOffenders(): string[] { if (/^\(\s*\w+\s*\??\s*:\s*[A-Za-z{[(]/.test(args)) { continue } - if (!/windowsHide\s*:\s*true/.test(args)) { + // `shell: true` silently makes windowsHide a no-op (run-process.ts, + // #14543), and `exec`/`execSync` imply it. A site can therefore read as + // guarded while still flashing a conhost -- which is exactly how + // `execAsync('where gemini', { windowsHide: true })` got un-allowlisted. + const called = match[0].replace(/\s*\($/, '').trim() + // Any `shell:` that is not literally `false` counts. `shell: + // process.platform === 'win32'` IS shell: true on the platform this + // guard is about, and only the literal was being matched. + // Recorded gap, not an oversight: `shell: process.platform === 'win32'` + // IS shell: true on the platform this guard is about, but matching any + // non-`false` value also flags `shell: spawnConfig.shell` + // (claude-accounts/service.ts:1086), a pass-through that is false in + // every branch. A false positive there costs an allowlist entry, which + // disables the guard for that whole file -- worse than the gap. No + // computed `shell:` exists in the tree today; if one appears, resolve it + // rather than widening this regex. + const impliesShell = shellImplying.has(called) || /shell\s*:\s*true/.test(args) + if (!/windowsHide\s*:\s*true/.test(args) || impliesShell) { offenders.add(file.relativePath) } } diff --git a/src/shared/posix-version-manager-bin-dirs.ts b/src/shared/posix-version-manager-bin-dirs.ts new file mode 100644 index 00000000000..698b0f7f5b1 --- /dev/null +++ b/src/shared/posix-version-manager-bin-dirs.ts @@ -0,0 +1,46 @@ +/** + * Where a version manager puts binaries inside a POSIX guest. + * + * The guest-side twin of `detectCommandsInInstallDirs`, which the native + * preflight branch already consults for the same reason -- "PATH may still be + * unhydrated on a cold GUI launch". Without it, a WSL probe that cannot + * establish the login PATH reports an nvm-installed claude/codex as not + * installed, which is #9725. + * + * Kept in step with `getBaseVersionManagerDirectories` in + * node-cli-command-resolution.ts: a WSL user on asdf, mise, volta or fnm would + * otherwise still hit #9725 while the same user on native does not. + * + * Each entry is quoted so a `$HOME` containing a space cannot word-split into + * a relative path -- except the nvm glob, where only the prefix is quoted so + * the `*` still expands. + */ +const POSIX_VERSION_MANAGER_BIN_DIRS = [ + '"$HOME/.local/bin"', + '"$HOME/.local/share/pnpm"', + '"$HOME/.yarn/bin"', + '"$HOME/.bun/bin"', + '"$HOME/.volta/bin"', + '"$HOME/.asdf/shims"', + '"$HOME/.fnm/aliases/default/bin"', + '"$HOME/.local/share/mise/shims"', + '"/usr/local/bin"', + '"$HOME"/.nvm/versions/node/*/bin' +].join(' ') + +/** + * A prelude that APPENDS those directories to PATH. + * + * Append, never prepend: when the login PATH did resolve it is authoritative, + * and a prepended fallback could shadow the binary the user actually runs with + * an older one from a stale nvm version directory. + */ +export function buildPosixFallbackPathPrelude(): string { + return [ + `for _orca_dir in ${POSIX_VERSION_MANAGER_BIN_DIRS}; do`, + ' if [ -d "$_orca_dir" ]; then PATH="$PATH:$_orca_dir"; fi', + 'done', + 'export PATH', + 'unset _orca_dir' + ].join('\n') +} diff --git a/src/shared/source-scan/source-tree-scan.test.ts b/src/shared/source-scan/source-tree-scan.test.ts index 2297d91ddf4..be4b72e4ce9 100644 --- a/src/shared/source-scan/source-tree-scan.test.ts +++ b/src/shared/source-scan/source-tree-scan.test.ts @@ -36,12 +36,12 @@ describe('stripComments', () => { }) it('is not derailed by a quote inside a regex literal', () => { - const source = ["const re = /['\"]/", '/* spawn(bad) */', 'spawn(good)'].join('\n') + const source = ['const re = /[\'"]/', '/* spawn(bad) */', 'spawn(good)'].join('\n') expect(stripComments(source)).not.toContain('bad') }) it('handles an escaped quote inside a string', () => { - const source = ['const s = \'it\\\'s\'', '/* spawn(bad) */'].join('\n') + const source = ["const s = 'it\\'s'", '/* spawn(bad) */'].join('\n') expect(stripComments(source)).not.toContain('bad') }) }) @@ -77,3 +77,50 @@ describe('blankStringContents', () => { expect(blankStringContents(source).split('\n')).toHaveLength(source.split('\n').length) }) }) + +describe('regex literals', () => { + it('does not let an apostrophe in a pattern open a string', () => { + // The real shape, from a shell quoter: the `'` inside /'/g read as a string + // opener and desynced every later line, so a scan of the file silently + // found nothing and its guard reported clean. + const source = + "const q = (v: string): string => `'${v.replace(/'/g, \"x\")}'`\nspawn('wsl.exe')\n" + expect(blankStringContents(source, true)).not.toBe('desynced') + expect(blankStringContents(source)).toContain('spawn(') + }) + + it('treats a leading block comment as a comment, not a pattern', () => { + // `/*` at index 0 has no preceding token, so the prev-token test called it + // a pattern and swallowed to the next `/` -- 110k characters of a real + // file, in the direction that hides offenders. + const source = '/* banner with a renderer/Electron slash */\nspawn("wsl.exe")\n' + // Assert the swallowed span itself survives: `spawn(` sits after the bad + // region, so checking only for it passes even while the banner is eaten. + expect(blankStringContents(source)).toContain('banner with a renderer') + }) + + it.each([ + [ + 'postfix decrement', + "const half = n-- / 2; execFile(bin, args, { cwd: '/usr/bin' })", + 'execFile(bin, args' + ], + ['non-null assertion', 'const b = done! / total!\nexecFile(y)\n', 'execFile(y)'], + [ + 'JSX self-close', + 'const a = c ? : \nexecFile(x)\n', + 'execFile(x)' + ] + ])('reads %s as division, not a pattern', (_case, source, survives) => { + // Blanking live code is the dangerous direction: the swallowed span took a + // whole execFile call with it and left no desync behind, so the guard saw + // zero calls and called the file clean. + expect(blankStringContents(source)).toContain(survives) + }) + + it('still reads division as division', () => { + const source = 'const a = (x) / 2\nconst b = arr[i] / 2\nspawn("wsl.exe")\n' + expect(blankStringContents(source)).toContain('spawn(') + expect(blankStringContents(source, true)).not.toBe('desynced') + }) +}) diff --git a/src/shared/source-scan/source-tree-scan.ts b/src/shared/source-scan/source-tree-scan.ts index cb72122c81e..780354434c0 100644 --- a/src/shared/source-scan/source-tree-scan.ts +++ b/src/shared/source-scan/source-tree-scan.ts @@ -31,7 +31,10 @@ export type ScannedFile = { path: string; relativePath: string; source: string } * Dot-directories are skipped: they hold generated and vendored trees (the * cross-version e2e checkouts among them), which are not ours to fix. */ -export function scanSourceTree(root: string, options: { includeTests?: boolean } = {}): ScannedFile[] { +export function scanSourceTree( + root: string, + options: { includeTests?: boolean } = {} +): ScannedFile[] { const found: ScannedFile[] = [] const visit = (directory: string): void => { for (const entry of readdirSync(directory)) { @@ -151,6 +154,50 @@ export function blankStringContentsDesynced(source: string): boolean { return blankStringContents(source, true) !== '' } +/** + * After a value `/` is division; after an opener or a binary operator it opens + * a regex. + * + * The set is deliberately narrow, because the two errors are not symmetric. A + * false negative leaves a pattern unblanked, which at worst desyncs the lexer + * -- and every caller treats desync as an offender, so it fails closed. A + * false positive blanks live code, and a scan that cannot see a call reports + * it clean. A wider set cost 13 real JSX spans (` : ` `}` (JSX close). + */ +function startsRegexLiteral(emitted: string): boolean { + const prev = emitted.replace(/\s+$/, '').at(-1) + return prev === undefined || '(,=:[&|?;'.includes(prev) +} + +/** End index (exclusive) of the regex literal opening at `start`, or -1. */ +function findRegexLiteralEnd(source: string, start: number): number { + let inClass = false + for (let index = start + 1; index < source.length; index += 1) { + const char = source[index] + if (char === '\\') { + index += 1 + continue + } + // A `/` inside `[...]` is literal, so it must not close the pattern. + if (char === '[') { + inClass = true + } else if (char === ']') { + inClass = false + } else if (char === '\n') { + return -1 + } else if (char === '/' && !inClass) { + return index + 1 + } + } + return -1 +} + export function blankStringContents(source: string, reportDesync = false): string { let out = '' let index = 0 @@ -206,6 +253,22 @@ export function blankStringContents(source: string, reportDesync = false): strin index += 1 continue } + // A regex literal can carry a lone apostrophe (`/'/g` in a shell quoter), + // which reads as a string opener and desyncs the rest of the file. The + // classic prev-token test disambiguates it from division: after a value a + // `/` divides, after an operator or opener it starts a pattern. + // `/*` and `//` open comments, never patterns. Callers normally strip + // comments first, but this runs standalone too, and at index 0 a file + // starting with a banner comment read as one giant regex. + const next = source[index + 1] + if (char === '/' && next !== '/' && next !== '*' && startsRegexLiteral(out)) { + const end = findRegexLiteralEnd(source, index) + if (end !== -1) { + out += `/${' '.repeat(end - index - 1)}` + index = end + continue + } + } if (char === "'" || char === '"' || char === '`') { quote = char } @@ -215,8 +278,5 @@ export function blankStringContents(source: string, reportDesync = false): strin if (reportDesync) { return quote !== null || templates.length > 0 ? 'desynced' : '' } - if (reportDesync) { - return quote !== null || templates.length > 0 ? 'desynced' : '' - } return out } From 0bbc6c80e8155b1a20edc50383fe4e9d1b1232ff Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:12:23 -0700 Subject: [PATCH 07/27] refactor(host): route app paths and version through an AppEnvironment port (#16019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(host): route app paths and version through an AppEnvironment port `app.getPath('userData')` is the single largest Electron coupling in the main process — 37 call sites — and it is one of the things stopping the Orca runtime from booting on plain Node. Give it the same treatment as SecretStore. - `src/shared/app-environment.ts` — the port plus a settable registry, covering the members the runtime's module graph actually reads: paths, app path, version, packaged flag, shutdown hook, exit, and Chromium process metrics. `getAppEnvironment()` throws until installed, for the same reason the secret store does: a silent default resolves `userData` to the wrong directory and the caller writes real state there before anyone notices. No `node:` imports, because `src/shared/**` is in the web build graph. - `src/main/host/electron-app-environment.ts` — the desktop adapter, a pass-through to `electron.app`. - 9 modules migrated: telemetry, opencode/mimo/pi hook services, terminal-history-paths, terminal-scrollback-snapshots, cli-installer, clipboard-image-temp-file, memory/collector. Deliberately NOT migrated: `src/main/browser/**`. That cluster is Chromium- adjacent by nature — cookie jars, download destinations, offscreen pages — and a Node backend does not ship it at all, so porting it buys nothing and churns heavily-mocked suites. Also left alone for now: the call sites that additionally touch `app.asar` path literals or `app.setName`, which need more than a mechanical swap. `getAppMetrics` stays on the port rather than being injected because memory/collector.ts is its only caller and reads it from module scope; a Node host returns [], having no Chromium processes to measure. Test wiring: the secret-store setup file becomes `vitest-host-ports-setup.ts` and installs both ports, exporting `fakeAppEnvironment`/`installFakeAppEnvironment` so suites needing one specific member state only that instead of restating all seven — which is boilerplate, and had pushed one suite past the max-lines budget. Verified: 159 files / 1651 tests pass across every touched area; `tsc` clean on both the node and web projects; `oxlint` clean. * fix(typecheck): list the vitest host-ports setup in the node project Three suites import `installFakeAppEnvironment` from config/scripts, but that directory is outside tsconfig.node.json's include list, so composite typecheck failed with TS6307. Listing the one file matches how this config already pins individual files it needs. Local `tsc --composite false` does not reproduce this — only `pnpm typecheck` does, which is what CI runs. * refactor(host): drop two unused AppEnvironment exports hasAppEnvironment() and resetAppEnvironmentForTests() had zero callers. The secret-store equivalents are used, so these were mirror-symmetry rather than need; add them back when something actually needs them. * test(terminal-history): install the AppEnvironment fake instead of mocking electron These three suites mocked `electron.app.getPath` to point at a fixture dir. The production module now reads the port, so the mock was inert and the global test default's temp dir won — which broke the WSL path assertions and every deletion count. Found by a full-suite run, not by the targeted checks around the migrated modules, which is the argument for running the whole suite on a refactor this wide. * test(host-ports): remove the per-environment temp dir on teardown The setup allocated a mkdtemp directory at module scope, which vitest evaluates once per test *environment* — one per test file, not one per worker. Nothing removed them, so a full 6,000-file run left thousands behind. Proven: with an isolated TMPDIR, a three-file run previously added directories and now leaves zero. * fix(app-environment): anchor the installed environment to a realm global Same reason as the SecretStore: vi.resetModules() rebuilds the module registry, and an environment installed before the reset read back as uninstalled. --- config/scripts/vitest-host-ports-setup.ts | 67 +++++++++++++++ config/scripts/vitest-secret-store-setup.ts | 29 ------- config/tsconfig.node.json | 1 + config/vitest.config.ts | 2 +- src/main/cli/cli-installer.ts | 8 +- src/main/host/electron-app-environment.ts | 38 +++++++++ src/main/index.ts | 12 ++- src/main/memory/collector.test.ts | 22 +++-- src/main/memory/collector.ts | 10 +-- src/main/mimo/hook-service.test.ts | 16 ++-- src/main/mimo/hook-service.ts | 8 +- src/main/opencode/hook-service.test.ts | 19 +++-- src/main/opencode/hook-service.ts | 10 ++- .../titlebar-extension-overlay-path.test.ts | 25 +++--- .../pi/titlebar-extension-service.test.ts | 23 +++--- src/main/pi/titlebar-extension-service.ts | 9 +- src/main/telemetry/client.ts | 4 +- .../terminal-history-async-delete.test.ts | 10 +-- src/main/terminal-history-paths.ts | 8 +- .../terminal-history-tombstone-retry.test.ts | 8 +- src/main/terminal-history.test.ts | 8 +- src/main/terminal-scrollback-snapshots.ts | 4 +- src/main/window/clipboard-image-temp-file.ts | 4 +- .../window/clipboard-ipc-handlers.test.ts | 2 + src/shared/app-environment.ts | 82 +++++++++++++++++++ 25 files changed, 308 insertions(+), 121 deletions(-) create mode 100644 config/scripts/vitest-host-ports-setup.ts delete mode 100644 config/scripts/vitest-secret-store-setup.ts create mode 100644 src/main/host/electron-app-environment.ts create mode 100644 src/shared/app-environment.ts diff --git a/config/scripts/vitest-host-ports-setup.ts b/config/scripts/vitest-host-ports-setup.ts new file mode 100644 index 00000000000..c12e16b593f --- /dev/null +++ b/config/scripts/vitest-host-ports-setup.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeEach } from 'vitest' +import { setAppEnvironment, type AppEnvironment } from '../../src/shared/app-environment' +import { setSecretStore } from '../../src/shared/secret-store' + +/** + * Why: both host ports throw until an entrypoint installs them, which is the right + * production behaviour but would fail ~70 suites that only ever needed *some* host. + * Install benign defaults before every test so those suites stay unchanged; a suite + * that asserts on host behaviour installs its own and wins, because this runs first. + */ + +// One directory per test environment, not per test: suites that write a file in one +// step and read it back in the next would otherwise lose it between them. Removed on +// teardown — vitest builds one environment per test file, so without this a full run +// leaves thousands of directories behind. +const userData = mkdtempSync(join(tmpdir(), 'orca-vitest-userdata-')) + +afterAll(() => { + rmSync(userData, { recursive: true, force: true }) +}) + +// Deliberately not a plaintext passthrough: encryptString must return something a +// test can tell apart from its input, or a test that forgot to seal would pass. +const SEAL_PREFIX = 'vitest-sealed:' + +/** + * Build a fake environment from just the members a suite cares about. Exported so + * suites that need a specific path or metrics fixture state only that, instead of + * restating all seven members — which is boilerplate, and pushed one suite past + * the max-lines budget. + */ +export function fakeAppEnvironment(overrides: Partial = {}): AppEnvironment { + return { + getPath: (name) => (name === 'home' ? homedir() : name === 'temp' ? tmpdir() : userData), + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [], + ...overrides + } +} + +/** Install a fake in one call — the common shape in suites that need one specific member. */ +export function installFakeAppEnvironment(overrides: Partial = {}): void { + setAppEnvironment(fakeAppEnvironment(overrides)) +} + +beforeEach(() => { + setAppEnvironment(fakeAppEnvironment()) + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (plainText) => Buffer.from(`${SEAL_PREFIX}${plainText}`), + decryptString: (cipher) => { + const text = cipher.toString() + if (!text.startsWith(SEAL_PREFIX)) { + throw new Error('vitest secret store: ciphertext was not produced by this store') + } + return text.slice(SEAL_PREFIX.length) + }, + describeUnavailable: () => null + }) +}) diff --git a/config/scripts/vitest-secret-store-setup.ts b/config/scripts/vitest-secret-store-setup.ts deleted file mode 100644 index 6000e28c768..00000000000 --- a/config/scripts/vitest-secret-store-setup.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { beforeEach } from 'vitest' -import { setSecretStore } from '../../src/shared/secret-store' - -/** - * Why: `getSecretStore()` throws until an entrypoint installs a store, which is the - * right production behaviour but would fail ~67 suites that only ever cared that - * *some* store existed. Install a reversible in-memory one before every test so - * those suites stay unchanged; a suite that asserts on sealing behaviour calls - * `setSecretStore()` itself and wins, because this runs first. - * - * Deliberately not a plaintext passthrough: encryptString must return something a - * test can tell apart from the input, or a test that forgot to seal would pass. - */ -const SEAL_PREFIX = 'vitest-sealed:' - -beforeEach(() => { - setSecretStore({ - isEncryptionAvailable: () => true, - encryptString: (plainText) => Buffer.from(`${SEAL_PREFIX}${plainText}`), - decryptString: (cipher) => { - const text = cipher.toString() - if (!text.startsWith(SEAL_PREFIX)) { - throw new Error('vitest secret store: ciphertext was not produced by this store') - } - return text.slice(SEAL_PREFIX.length) - }, - describeUnavailable: () => null - }) -}) diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 948a9e88a1f..c42b937a467 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -3,6 +3,7 @@ "include": [ "../electron.vite.config.*", "./build-plugins/**/*", + "./scripts/vitest-host-ports-setup.ts", "../src/main/**/*", "../src/renderer/src/lib/skill-freshness-display-status.ts", "../src/preload/**/*", diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 745ff8c263d..92e0cf146a5 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ // Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does. setupFiles: [ resolve('config/scripts/happy-dom-mutation-observer-retention.ts'), - resolve('config/scripts/vitest-secret-store-setup.ts') + resolve('config/scripts/vitest-host-ports-setup.ts') ], include: [ 'src/**/*.test.ts', diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index a57fdf95924..4bf23fc4190 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: this file centralizes cross-platform CLI install state, launcher resolution, and PATH registration so the public shell command stays consistent across packaged and development builds. */ -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { execFile } from 'node:child_process' import { constants, existsSync } from 'node:fs' import { @@ -93,11 +93,11 @@ export class CliInstaller { constructor(options: CliInstallerOptions = {}) { this.platform = options.platform ?? process.platform - this.isPackaged = options.isPackaged ?? app.isPackaged - this.userDataPath = options.userDataPath ?? app.getPath('userData') + this.isPackaged = options.isPackaged ?? getAppEnvironment().isPackaged() + this.userDataPath = options.userDataPath ?? getAppEnvironment().getPath('userData') this.resourcesPath = options.resourcesPath ?? process.resourcesPath this.execPathValue = options.execPath ?? process.execPath - this.appPathValue = options.appPath ?? app.getAppPath() + this.appPathValue = options.appPath ?? getAppEnvironment().getAppPath() this.homePath = options.homePath ?? homedir() this.localAppDataPath = options.localAppDataPath ?? diff --git a/src/main/host/electron-app-environment.ts b/src/main/host/electron-app-environment.ts new file mode 100644 index 00000000000..1e8b8c6965c --- /dev/null +++ b/src/main/host/electron-app-environment.ts @@ -0,0 +1,38 @@ +import { app } from 'electron' +import type { AppEnvironment, AppPathName, AppProcessMetric } from '../../shared/app-environment' + +/** + * Electron-backed AppEnvironment for the desktop app: a pass-through to + * `electron.app`, so desktop path, version and lifecycle behaviour is unchanged. + */ +export class ElectronAppEnvironment implements AppEnvironment { + getPath(name: AppPathName): string { + return app.getPath(name) + } + + getAppPath(): string { + return app.getAppPath() + } + + getVersion(): string { + return app.getVersion() + } + + isPackaged(): boolean { + return app.isPackaged + } + + onWillQuit(handler: () => void): void { + app.on('will-quit', handler) + } + + exit(code = 0): void { + app.exit(code) + } + + getAppMetrics(): AppProcessMetric[] { + // Electron's ProcessMetric is structurally compatible with the loose mirror + // (pid plus optional cpu/memory); the nominal types differ, hence the cast. + return app.getAppMetrics() as unknown as AppProcessMetric[] + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 729a613e4ff..23ff503ab4a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,6 +11,8 @@ import { getCanonicalUserDataPath, migrateMobilePairingDataToCanonicalUserDataPath } from './persistence' +import { setAppEnvironment } from '../shared/app-environment' +import { ElectronAppEnvironment } from './host/electron-app-environment' import { setSecretStore } from '../shared/secret-store' import { ElectronSecretStore } from './host/electron-secret-store' import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' @@ -852,10 +854,12 @@ if (!hasSingleInstanceLock) { // Why: when another process holds the lock we've already exited; skip file-writing side effects so this transient process never touches userData. if (hasSingleInstanceLock) { - // Why: every secret read resolves through this port, and getSecretStore() throws - // until it is installed — so install before any other bootstrap step can touch a - // credential. Constructing it does not call safeStorage, so this stays clear of the - // pre-ready Keychain service-name resolution the setName block below depends on. + // Why first: both accessors throw until installed, and everything below this line + // may resolve a path or read a credential. Neither constructor touches `app` or + // `safeStorage` — they resolve lazily per call — so installing here changes no + // timing, in particular not the pre-ready Keychain service-name resolution and + // the app.setName ordering the userData captures below depend on. + setAppEnvironment(new ElectronAppEnvironment()) setSecretStore(new ElectronSecretStore()) // 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 diff --git a/src/main/memory/collector.test.ts b/src/main/memory/collector.test.ts index 38046431777..22e55825923 100644 --- a/src/main/memory/collector.test.ts +++ b/src/main/memory/collector.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import os from 'node:os' import type { MemorySnapshotStore } from './collector' +import { setAppEnvironment } from '../../shared/app-environment' type AppMetricFixture = { pid: number @@ -16,12 +17,6 @@ const { appMetricsMock, runProcessMock, execMock, listRegisteredPtysMock } = vi. listRegisteredPtysMock: vi.fn() })) -vi.mock('electron', () => ({ - app: { - getAppMetrics: appMetricsMock - } -})) - vi.mock('child_process', () => ({ exec: (cmd: string, opts: unknown, cb: (err: Error | null, out: { stdout: string }) => void) => execMock(cmd, opts, cb) @@ -38,8 +33,22 @@ vi.mock('./pty-registry', () => ({ listRegisteredPtys: listRegisteredPtysMock })) +function appEnvironment() { + return { + getPath: () => process.cwd(), + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: appMetricsMock + } +} + async function loadCollector() { vi.resetModules() + const { setAppEnvironment: setResetAppEnvironment } = await import('../../shared/app-environment') + setResetAppEnvironment(appEnvironment()) return await import('./collector') } @@ -240,6 +249,7 @@ describe('collectSubtree', () => { describe('collectMemorySnapshot', () => { beforeEach(() => { + setAppEnvironment(appEnvironment()) vi.restoreAllMocks() appMetricsMock.mockReset() appMetricsMock.mockReturnValue([]) diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index 1f000706c1e..b48a6bb75ed 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -5,7 +5,7 @@ * Memory dashboard collector. * * One snapshot covers two sources: - * - Orca's own Electron processes, via `app.getAppMetrics()`, bucketed + * - Orca's own Electron processes, via `getAppEnvironment().getAppMetrics()`, bucketed * into main / renderer / other. * - Each registered PTY's process subtree, enumerated once from a host- * wide process sweep (PowerShell CIM with a Typeperf fallback on Windows). @@ -26,7 +26,7 @@ import { getProcessOutputFields, iterateProcessOutputLines } from '../../shared/process-output-field-scanner' -import { app } from 'electron' +import { getAppEnvironment, type AppEnvironment } from '../../shared/app-environment' import type { AppMemory, MemorySnapshot, @@ -247,14 +247,14 @@ export function collectSubtree(index: ProcIndex, root: number): number[] { type AppBucketsRaw = Omit function electronMetricMemoryBytes( - proc: ReturnType[number], + proc: ReturnType[number], processIndex: ProcIndex ): number { const hostMemory = processIndex.byPid.get(proc.pid)?.memory if (typeof hostMemory === 'number' && Number.isFinite(hostMemory) && hostMemory > 0) { return hostMemory } - // Why: on macOS, app.getAppMetrics().workingSetSize can include large shared + // Why: on macOS, getAppEnvironment().getAppMetrics().workingSetSize can include large shared // Chromium/Electron mappings. Prefer the host RSS sweep used elsewhere, but // keep workingSetSize as a fallback when the process disappears mid-snapshot. return clampNumber(proc.memory?.workingSetSize) * 1024 @@ -265,7 +265,7 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw { const renderer = { cpu: 0, memory: 0 } const other = { cpu: 0, memory: 0 } - for (const proc of app.getAppMetrics()) { + for (const proc of getAppEnvironment().getAppMetrics()) { const cpu = clampNumber(proc.cpu?.percentCPUUsage) const memoryBytes = electronMetricMemoryBytes(proc, processIndex) diff --git a/src/main/mimo/hook-service.test.ts b/src/main/mimo/hook-service.test.ts index e30e415ca97..4f84d3ea12c 100644 --- a/src/main/mimo/hook-service.test.ts +++ b/src/main/mimo/hook-service.test.ts @@ -2,17 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' const { getPathMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>() })) -vi.mock('electron', () => ({ - app: { - getPath: getPathMock - } -})) - import { MimoCodeHookService } from './hook-service' describe('MimoCodeHookService buildPtyEnv', () => { @@ -27,6 +22,15 @@ describe('MimoCodeHookService buildPtyEnv', () => { } throw new Error(`unexpected getPath: ${name}`) }) + setAppEnvironment({ + getPath: getPathMock, + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) mimocodeHome = mkdtempSync(join(tmpdir(), 'orca-mimocode-home-')) const configDir = join(mimocodeHome, 'config') diff --git a/src/main/mimo/hook-service.ts b/src/main/mimo/hook-service.ts index 3f879d93063..2c78c41a454 100644 --- a/src/main/mimo/hook-service.ts +++ b/src/main/mimo/hook-service.ts @@ -1,4 +1,4 @@ -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' @@ -53,7 +53,11 @@ export class MimoCodeHookService { buildPtyEnv(_ptyId: string, existingMimocodeHome?: string): Record { // Why: MiMo currently uses a shared home; per-source subdirs can come // later if concurrent MiMo panes need isolated runtime state. - const home = join(app.getPath('userData'), MIMOCODE_HOOKS_DIR, MIMOCODE_SHARED_HOME) + const home = join( + getAppEnvironment().getPath('userData'), + MIMOCODE_HOOKS_DIR, + MIMOCODE_SHARED_HOME + ) try { for (const sub of ['config', 'data', 'cache', 'state'] as const) { mkdirSync(join(home, sub), { recursive: true }) diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 7d88e98e2cd..10355c8e6d0 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -12,19 +12,26 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' const { getPathMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>() })) -vi.mock('electron', () => ({ - app: { - getPath: getPathMock - } -})) - import { OpenCodeHookService, _internals } from './hook-service' +beforeEach(() => { + setAppEnvironment({ + getPath: getPathMock, + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) + const { isUsableId, toSafeDirName } = _internals describe('OpenCode hook plugin source', () => { diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 53d40f7a4de..60c4fbe9ead 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: holds an inline JS plugin source emitted as one file; splitting across TS modules would scatter tightly coupled string-template logic. */ -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { existsSync, @@ -1096,7 +1096,7 @@ export class OpenCodeHookService { } private getOverlayRoot(): string { - return join(app.getPath('userData'), OPENCODE_OVERLAY_DIR) + return join(getAppEnvironment().getPath('userData'), OPENCODE_OVERLAY_DIR) } private getSourceOverlayDir(sourceConfigDir: string): string { @@ -1104,7 +1104,11 @@ export class OpenCodeHookService { } private getSharedConfigDir(): string { - return join(app.getPath('userData'), OPENCODE_LEGACY_HOOKS_DIR, OPENCODE_SHARED_CONFIG_DIR) + return join( + getAppEnvironment().getPath('userData'), + OPENCODE_LEGACY_HOOKS_DIR, + OPENCODE_SHARED_CONFIG_DIR + ) } private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { diff --git a/src/main/pi/titlebar-extension-overlay-path.test.ts b/src/main/pi/titlebar-extension-overlay-path.test.ts index 78c8fae7723..db5bfeedbd9 100644 --- a/src/main/pi/titlebar-extension-overlay-path.test.ts +++ b/src/main/pi/titlebar-extension-overlay-path.test.ts @@ -1,21 +1,11 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, sep } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' const userDataDir = mkdtempSync(join(tmpdir(), 'orca-pi-overlay-path-userdata-')) -vi.mock('electron', () => ({ - app: { - getPath: (name: string) => { - if (name === 'userData') { - return userDataDir - } - throw new Error(`unexpected app.getPath(${name})`) - } - } -})) - import { PiTitlebarExtensionService } from './titlebar-extension-service' const PATH_SHAPED_PTY_ID = [ @@ -34,6 +24,17 @@ function legacyOverlayPath(kind: 'pi' | 'omp', ptyId: string): string { } describe('PiTitlebarExtensionService legacy overlay paths', () => { + beforeEach(() => { + installFakeAppEnvironment({ + getPath: (name) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + } + }) + }) + afterEach(() => { rmSync(join(userDataDir, 'pi-agent-overlays'), { recursive: true, force: true }) rmSync(join(userDataDir, 'omp-agent-overlays'), { recursive: true, force: true }) diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index e197a4a0c3a..3ad73bba6eb 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -17,6 +17,7 @@ import { tmpdir } from 'node:os' import type * as osModule from 'node:os' import type * as fsModule from 'node:fs' import { join } from 'node:path' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' // The service calls app.getPath('userData') for its overlay root. Point that // at a real tmp dir so we can exercise the filesystem behavior end-to-end. @@ -37,17 +38,6 @@ vi.mock('os', async (importOriginal) => { } }) -vi.mock('electron', () => ({ - app: { - getPath: (name: string) => { - if (name === 'userData') { - return userDataDir - } - throw new Error(`unexpected app.getPath(${name})`) - } - } -})) - import { PiTitlebarExtensionService, isSafeDescendCandidate } from './titlebar-extension-service' function legacyOverlayPath(kind: 'pi' | 'omp', ptyId: string): string { @@ -62,6 +52,17 @@ function legacySourceOverlayPath(kind: 'pi' | 'omp', sourceAgentDir: string): st } describe('PiTitlebarExtensionService', () => { + beforeEach(() => { + installFakeAppEnvironment({ + getPath: (name) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + } + }) + }) + let piHome: string beforeEach(() => { diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 5e728fa0274..3a43ce4ac38 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { createHash } from 'node:crypto' import { ORCA_PI_AGENT_STATUS_EXTENSION_FILE, @@ -74,7 +74,7 @@ function withOrcaManagedExtensionMarker(source: string): string { export class PiTitlebarExtensionService { private getOverlayRoot(kind: LegacyOverlayAgentKind): string { - return join(app.getPath('userData'), OVERLAY_ROOT_DIR_NAME[kind]) + return join(getAppEnvironment().getPath('userData'), OVERLAY_ROOT_DIR_NAME[kind]) } private getSourceOverlayDir(sourceAgentDir: string, kind: LegacyOverlayAgentKind): string { @@ -122,7 +122,10 @@ export class PiTitlebarExtensionService { } private writeOmpFallbackStatusExtension(source: string): string | undefined { - const fallbackDir = join(app.getPath('userData'), OMP_MANAGED_STATUS_EXTENSION_DIR) + const fallbackDir = join( + getAppEnvironment().getPath('userData'), + OMP_MANAGED_STATUS_EXTENSION_DIR + ) try { mkdirSync(fallbackDir, { recursive: true }) } catch { diff --git a/src/main/telemetry/client.ts b/src/main/telemetry/client.ts index 93bddaaab81..dc92cf1461d 100644 --- a/src/main/telemetry/client.ts +++ b/src/main/telemetry/client.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto' import { arch as osArch, platform as osPlatform, release as osRelease } from 'node:os' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { PostHog } from 'posthog-node' import type { CommonProps, EventName, EventProps, OptInVia } from '../../shared/telemetry-events' import type { Store } from '../persistence' @@ -53,7 +53,7 @@ let appOpenedTrackedThisSession = false function buildCommonProps(installId: string, sid: string, channel: 'stable' | 'rc'): CommonProps { // Don't truncate here; the validator's `.max(64)` is authoritative, so an over-long string drops rather than being silently masked. return { - app_version: app.getVersion(), + app_version: getAppEnvironment().getVersion(), platform: osPlatform(), arch: osArch(), os_release: osRelease(), diff --git a/src/main/terminal-history-async-delete.test.ts b/src/main/terminal-history-async-delete.test.ts index 15eab30156c..180ba23e73e 100644 --- a/src/main/terminal-history-async-delete.test.ts +++ b/src/main/terminal-history-async-delete.test.ts @@ -1,16 +1,11 @@ import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' let userDataDir: string -vi.mock('electron', () => ({ - app: { - getPath: () => userDataDir - } -})) - import { hashWorktreeId } from './terminal-history-paths' import { deleteWorktreeHistoryDir, @@ -24,6 +19,7 @@ import { describe('deleteWorktreeHistoryDir main-thread safety', () => { beforeEach(() => { userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-async-')) + installFakeAppEnvironment({ getPath: () => userDataDir }) }) afterEach(async () => { diff --git a/src/main/terminal-history-paths.ts b/src/main/terminal-history-paths.ts index 263f39c8c35..ea02c6aa0ee 100644 --- a/src/main/terminal-history-paths.ts +++ b/src/main/terminal-history-paths.ts @@ -1,6 +1,6 @@ import { existsSync, readdirSync } from 'node:fs' import { join } from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../shared/app-environment' const HISTORY_DIR_NAME = 'terminal-history' const HISTORY_DIR_NAME_WSL = 'terminal-history-wsl' @@ -10,16 +10,16 @@ export const PENDING_DELETE_DIR_NAME = '.pending-delete' export { hashWorktreeId } from './terminal-history-id' export function getHistoryRoot(): string { - return join(app.getPath('userData'), HISTORY_DIR_NAME) + return join(getAppEnvironment().getPath('userData'), HISTORY_DIR_NAME) } export function getHistoryRootWsl(distro: string): string { - return join(app.getPath('userData'), HISTORY_DIR_NAME_WSL, distro) + return join(getAppEnvironment().getPath('userData'), HISTORY_DIR_NAME_WSL, distro) } /** Every per-distro WSL history root that exists on disk; empty when WSL history was never written. */ export function listWslHistoryRoots(): string[] { - const wslRoot = join(app.getPath('userData'), HISTORY_DIR_NAME_WSL) + const wslRoot = join(getAppEnvironment().getPath('userData'), HISTORY_DIR_NAME_WSL) if (!existsSync(wslRoot)) { return [] } diff --git a/src/main/terminal-history-tombstone-retry.test.ts b/src/main/terminal-history-tombstone-retry.test.ts index 67cb9926322..1d7a606206a 100644 --- a/src/main/terminal-history-tombstone-retry.test.ts +++ b/src/main/terminal-history-tombstone-retry.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node 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' let userDataDir: string @@ -10,12 +11,6 @@ const { removeHostTreeMock, deleteWslFishHistoryFileMock } = vi.hoisted(() => ({ deleteWslFishHistoryFileMock: vi.fn<(distro: string, session: string) => Promise>() })) -vi.mock('electron', () => ({ - app: { - getPath: () => userDataDir - } -})) - vi.mock('./host-tree-removal', () => ({ removeHostTree: removeHostTreeMock })) @@ -40,6 +35,7 @@ import { describe('tombstoned history removal retries', () => { beforeEach(() => { userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-retry-')) + installFakeAppEnvironment({ getPath: () => userDataDir }) removeHostTreeMock.mockReset() deleteWslFishHistoryFileMock.mockReset() deleteWslFishHistoryFileMock.mockResolvedValue(undefined) diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts index ef8a1a60e14..1cbb5f83438 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -1,6 +1,7 @@ import type * as FsPromises from 'node:fs/promises' import { sep } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const { existsSyncMock, @@ -64,12 +65,6 @@ vi.mock('node:fs/promises', async () => ({ rm: rmAsyncMock })) -vi.mock('electron', () => ({ - app: { - getPath: getPathMock - } -})) - const { parseWslPathMock, toLinuxPathMock } = vi.hoisted(() => ({ parseWslPathMock: vi.fn((_path: string) => null as { distro: string; linuxPath: string } | null), toLinuxPathMock: vi.fn((p: string) => p) @@ -120,6 +115,7 @@ describe('terminal-history', () => { rmAsyncMock.mockReset() rmAsyncMock.mockResolvedValue(undefined) getPathMock.mockReturnValue('/fake/userData') + installFakeAppEnvironment({ getPath: getPathMock }) existsSyncMock.mockReturnValue(true) statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) openSyncMock.mockImplementation(() => 1) diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts index 296dd4a081b..70d4be18002 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -11,7 +11,7 @@ import { } from 'node:fs' import { mkdir, rename, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../shared/app-environment' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT, @@ -27,7 +27,7 @@ export type TerminalScrollbackSnapshotStorage = { } function getLegacySnapshotRoot(): string { - return join(app.getPath('userData'), SNAPSHOT_DIR_NAME) + return join(getAppEnvironment().getPath('userData'), SNAPSHOT_DIR_NAME) } export function getProfileTerminalScrollbackSnapshotRoot(dataFile: string): string { diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index 669605e81fd..cadbd677650 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -2,7 +2,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import { randomUUID } from 'node:crypto' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' @@ -39,7 +39,7 @@ export async function saveClipboardImageBufferAsTempFile( return remotePath } - const tempPath = path.join(app.getPath('temp'), fileName) + const tempPath = path.join(getAppEnvironment().getPath('temp'), fileName) await fs.writeFile(tempPath, buffer) return tempPath } diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index b443f18dc7c..ce84c68e327 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { join } from 'node:path' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS, CLIPBOARD_IMAGE_MAX_PIXELS, @@ -191,6 +192,7 @@ function shellIdListArray(childCount: number): Buffer { describe('registerClipboardHandlers', () => { beforeEach(() => { + installFakeAppEnvironment({ getPath: () => '/tmp' }) vi.spyOn(Date, 'now').mockReturnValue(1760000000000) removeHandlerMock.mockReset() handleMock.mockReset() diff --git a/src/shared/app-environment.ts b/src/shared/app-environment.ts new file mode 100644 index 00000000000..4c2492be678 --- /dev/null +++ b/src/shared/app-environment.ts @@ -0,0 +1,82 @@ +/** + * AppEnvironment abstracts the host-process facilities Orca's core reads from + * Electron's `app`: data paths, version, packaged flag, and shutdown. The + * desktop installs an Electron-backed implementation; a plain-Node host (the + * headless runtime server) installs a Node one, so core modules never import + * `electron`. + * + * Why a settable singleton rather than constructor injection: `getPath('userData')` + * is read at module scope and through deep call chains in ~17 modules. Threading + * an instance through all of them would be a rewrite, not a refactor. Ports that + * already have an injection point (the browser backend) stay injected instead. + * + * This module must stay free of `node:` imports — `src/shared/**` is in the web + * build graph (config/tsconfig.tc.web.json), so a require() here ships to the + * renderer. Callers that need a Node-flavoured default install one explicitly. + */ + +export type AppPathName = 'userData' | 'home' | 'appData' | 'temp' | 'downloads' | 'logs' | 'exe' + +export type AppEnvironment = { + /** Mirrors electron `app.getPath`. `userData` is the load-bearing one. */ + getPath(name: AppPathName): string + /** Mirrors electron `app.getAppPath()` — the install/bundle root. */ + getAppPath(): string + getVersion(): string + isPackaged(): boolean + /** Shutdown hook: electron `will-quit`, or SIGTERM/SIGINT on a Node host. */ + onWillQuit(handler: () => void): void + exit(code?: number): void + /** + * Per-process Chromium metrics (electron `app.getAppMetrics`). A Node host has + * no Chromium processes to measure and returns []. Kept on the port rather than + * injected because `src/main/memory/collector.ts` is its only caller and reads + * it from module scope. + */ + getAppMetrics(): AppProcessMetric[] +} + +/** Loose structural mirror of electron's ProcessMetric, so shared code stays Electron-free. */ +export type AppProcessMetric = { + pid: number + type?: string + cpu?: { percentCPUUsage?: number } + memory?: { workingSetSize?: number; privateBytes?: number } + [key: string]: unknown +} + +/** + * Why a global symbol and not a module-level `let`: `vi.resetModules()` gives the + * re-imported graph a fresh copy of this module, so an environment installed before the + * reset would silently read back as uninstalled. Anchoring to the realm keeps one + * instance per process however often the module registry is rebuilt. + */ +const SLOT = Symbol.for('orca.host.appEnvironment') + +type Slot = { [SLOT]?: AppEnvironment | null } + +function slot(): Slot { + return globalThis as unknown as Slot +} + +function read(): AppEnvironment | null { + return slot()[SLOT] ?? null +} + +/** Install the active environment. Each entrypoint calls this once, before any consumer resolves a path. */ +export function setAppEnvironment(environment: AppEnvironment): void { + slot()[SLOT] = environment +} + +export function getAppEnvironment(): AppEnvironment { + const current = read() + if (!current) { + // Why throw rather than fall back: a silent default answers `userData` with the + // wrong directory, and the caller writes real user state there before anyone + // notices. Failing at the first read makes a missing install() obvious. + throw new Error( + 'AppEnvironment not initialized — call setAppEnvironment() during startup before resolving app paths' + ) + } + return current +} From d3869cd0b407180771776f65d7454b7aa935229f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:15:18 -0700 Subject: [PATCH 08/27] test(runtime): use the platform submit delay in the cancellation-during-verification test (#15870) Co-authored-by: Tauri-EPO --- .../agent-prompt-submission-runtime.test.ts | 10 +- ...pt-submission-windows-submit-delay.test.ts | 100 ++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index ce19f22a3ca..711e887b5c5 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { AGENT_PROMPT_BRACKETED_PASTE_END } from '../../shared/agent-prompt-injection' +import { + AGENT_PROMPT_BRACKETED_PASTE_END, + AGENT_PROMPT_SUBMIT_DELAY_MS +} from '../../shared/agent-prompt-injection' import { OrcaRuntimeService } from './orca-runtime' import { makeStore } from './runtime-rpc-worktree-store-fixtures' @@ -673,7 +676,10 @@ describe('agent prompt submission runtime', () => { }) const rejected = expect(submission).rejects.toThrow('request_aborted') - await vi.advanceTimersByTimeAsync(500) + // Why: the submit delay is 1_500 on Windows (ConPTY); a hardcoded 500 aborts before the Enter there. + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_SUBMIT_DELAY_MS) + // Why: pin the phase boundary so drift fails here instead of as an empty post-abort array. + expect(writes.filter((data) => data === '\r')).toHaveLength(1) controller.abort() await vi.runAllTimersAsync() diff --git a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts new file mode 100644 index 00000000000..fdb90700de1 --- /dev/null +++ b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as AgentPromptInjection from '../../shared/agent-prompt-injection' +import { OrcaRuntimeService } from './orca-runtime' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' + +// Why: vi.mock factories are hoisted above module-scope consts, so the delay must be hoisted too. +const { WINDOWS_SUBMIT_DELAY_MS } = vi.hoisted(() => ({ WINDOWS_SUBMIT_DELAY_MS: 1_500 })) +const WORKTREE_PATH = '/tmp/worktree-a' + +// Why: AGENT_PROMPT_SUBMIT_DELAY_MS is frozen from process.platform at import, so the 1_500 ConPTY +// path is otherwise unreachable on the Linux/macOS lanes that run this suite. +vi.mock('../../shared/agent-prompt-injection', async (importOriginal) => ({ + ...(await importOriginal()), + AGENT_PROMPT_SUBMIT_DELAY_MS: WINDOWS_SUBMIT_DELAY_MS +})) + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/prompt-verification', + isBare: false, + isMainWorktree: false + } + ]), + listWorktreesStrict: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/prompt-verification', + isBare: false, + isMainWorktree: false + } + ]) +})) + +// Why: 'aider' is not a settlement agent, so submission takes the fixed-delay branch under test. +async function createPromptRuntime(): Promise<{ + runtime: OrcaRuntimeService + handle: string + writes: string[] +}> { + const runtime = new OrcaRuntimeService(makeStore() as never) + const writes: string[] = [] + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, { + launchAgent: 'aider' + }) + return { runtime, handle: terminal.handle, writes } +} + +function countSubmits(writes: readonly string[]): number { + return writes.filter((data) => data === '\r').length +} + +describe('agent prompt submission at the Windows submit delay', () => { + afterEach(() => vi.useRealTimers()) + + it('holds Enter until the full platform delay elapses', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime() + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') + + await vi.advanceTimersByTimeAsync(WINDOWS_SUBMIT_DELAY_MS - 1) + expect(countSubmits(writes)).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(countSubmits(writes)).toBe(1) + + await vi.runAllTimersAsync() + await stalled + }) + + it('does not send another Enter after cancellation during verification', async () => { + vi.useFakeTimers() + const controller = new AbortController() + const { runtime, handle, writes } = await createPromptRuntime() + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + signal: controller.signal + }) + const rejected = expect(submission).rejects.toThrow('request_aborted') + + await vi.advanceTimersByTimeAsync(WINDOWS_SUBMIT_DELAY_MS) + expect(countSubmits(writes)).toBe(1) + controller.abort() + await vi.runAllTimersAsync() + + await rejected + expect(countSubmits(writes)).toBe(1) + }) +}) From 8c1e6ad0cfb5522145fec21857152ad53c68bb1b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:15:22 -0700 Subject: [PATCH 09/27] test(orchestration): distinguish the heartbeat straggler guard from the row's initial null (#15869) Co-authored-by: Tauri-EPO --- .../db-heartbeat-straggler-guard.test.ts | 40 +++++++++++++++++++ src/main/runtime/orchestration/db.test.ts | 11 ----- 2 files changed, 40 insertions(+), 11 deletions(-) create mode 100644 src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts diff --git a/src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts b/src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts new file mode 100644 index 00000000000..7c2fd3e44db --- /dev/null +++ b/src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' + +let db: OrchestrationDb | undefined + +afterEach(() => { + db?.close() + db = undefined +}) + +// Why: seeding before the row settles keeps the expectation off the row's initial null — a settle that cleared the column would satisfy `toBeNull()` too. +function seedHeartbeatedDispatch(): { d: OrchestrationDb; dispatchId: string } { + const d = new OrchestrationDb(':memory:') + db = d + const task = d.createTask({ spec: 'work' }) + const dispatch = d.createDispatchContext(task.id, 'term_worker') + d.recordHeartbeat(dispatch.id, '2026-05-03T00:00:00.000Z') + return { d, dispatchId: dispatch.id } +} + +// Why: recordHeartbeat matches status='dispatched' exactly, so every settled status needs its own case — a widened `status != 'completed'` guard shows up only on the failed one. +describe('recordHeartbeat straggler guard', () => { + it('ignores a straggler heartbeat after the dispatch completed', () => { + const { d, dispatchId } = seedHeartbeatedDispatch() + d.completeDispatch(dispatchId) + + d.recordHeartbeat(dispatchId, '2026-05-04T00:00:00.000Z') + + expect(d.getDispatchContextById(dispatchId)?.last_heartbeat_at).toBe('2026-05-03T00:00:00.000Z') + }) + + it('ignores a straggler heartbeat after the dispatch failed', () => { + const { d, dispatchId } = seedHeartbeatedDispatch() + d.failDispatch(dispatchId, 'exit') + + d.recordHeartbeat(dispatchId, '2026-05-04T00:00:00.000Z') + + expect(d.getDispatchContextById(dispatchId)?.last_heartbeat_at).toBe('2026-05-03T00:00:00.000Z') + }) +}) diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 04985a9ee0e..0894ca7c6a0 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -654,17 +654,6 @@ describe('OrchestrationDb', () => { expect(after?.last_heartbeat_at).toBe('2026-05-04T00:00:00.000Z') }) - it('recordHeartbeat is a no-op for completed rows (straggler ignored)', () => { - const d = createDb() - const task = d.createTask({ spec: 'work' }) - const ctx = d.createDispatchContext(task.id, 'term_a') - d.completeDispatch(ctx.id) - - d.recordHeartbeat(ctx.id, '2026-05-04T00:00:00.000Z') - const after = d.getDispatchContext(task.id) - expect(after?.last_heartbeat_at).toBeNull() - }) - it('getStaleDispatches returns only dispatched rows past the grace window', () => { const d = createDb() // Fixture: four rows, SQL-backdated timestamps (no fake clock): From 5f2fbd862e422509dcf516d1d8b3a487084bc540 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:15:25 -0700 Subject: [PATCH 10/27] fix(pty): wrap codex without expanding or destroying a user alias (#15873) Co-authored-by: terry-li-hm <12233004+terry-li-hm@users.noreply.github.com> --- .github/workflows/pr.yml | 2 + .../daemon-bash-rcfile.txt | 14 ++- .../daemon-zsh-zshenv.txt | 14 ++- .../local-bash-rcfile.txt | 14 ++- .../local-zsh-zshenv.txt | 14 ++- .../relay-bash-rcfile.txt | 4 +- .../relay-zsh-zshenv.txt | 4 +- src/main/providers/windows-shell-args.test.ts | 2 +- ...dex-preflight-profile-path-rewrite.test.ts | 58 ++++++++- .../pty/codex-shell-launch-preflight.test.ts | 115 ++++++++++++++---- src/main/pty/codex-shell-launch-preflight.ts | 10 +- .../omp-shell-wrapper-alias-safety.test.ts | 70 +++++++++++ src/main/pty/omp-shell-wrapper.ts | 4 +- 13 files changed, 281 insertions(+), 44 deletions(-) create mode 100644 src/main/pty/omp-shell-wrapper-alias-safety.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 24d51006f6b..2539b251502 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -406,6 +406,8 @@ jobs: src/main/daemon/node-pty-fd-leak.test.ts \ src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ + src/main/pty/codex-shell-launch-preflight.test.ts \ + src/main/pty/omp-shell-wrapper-alias-safety.test.ts \ src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/shell-startup-feature-channel.test.ts \ src/main/terminal-history-fish-session.node-pty.test.ts \ diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index 03453cb33d3..079d23ec734 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -59,15 +59,23 @@ __orca_omp() { fi } if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } + # Why the function reserved word: it suppresses alias expansion of the name, which + # an `alias omp` otherwise rewrites at parse time, aborting the rest of the file. + function omp { __orca_omp "$@"; } fi # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" # Why: a typed alias expands inside the shell, after pane launch prep. -__orca_codex_binary="$(command -v codex 2>/dev/null || :)" +# Why unalias inside the substitution: an alias named codex makes command -v +# report the alias text, and the subshell leaves the user's own alias intact. +# Why || : twice — zsh alone aborts inside the substitution, but every shell's +# assignment adopts its exit status, so an absent codex trips set -e in bash too. +__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then - codex() { + # Why the function reserved word: it suppresses alias expansion of the name, + # which otherwise rewrites this header at parse time and aborts the whole file. + function codex { "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : command codex "$@" } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 1b89c740f2b..f0ec07cfcfd 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -97,15 +97,23 @@ __orca_deferred_init() { fi } if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } + # Why the function reserved word: it suppresses alias expansion of the name, which + # an `alias omp` otherwise rewrites at parse time, aborting the rest of the file. + function omp { __orca_omp "$@"; } fi # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" # Why: a typed alias expands inside the shell, after pane launch prep. - __orca_codex_binary="$(command -v codex 2>/dev/null || :)" + # Why unalias inside the substitution: an alias named codex makes command -v + # report the alias text, and the subshell leaves the user's own alias intact. + # Why || : twice — zsh alone aborts inside the substitution, but every shell's + # assignment adopts its exit status, so an absent codex trips set -e in bash too. + __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then - codex() { + # Why the function reserved word: it suppresses alias expansion of the name, + # which otherwise rewrites this header at parse time and aborts the whole file. + function codex { "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : command codex "$@" } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index 3041e85391d..895434cc688 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -62,15 +62,23 @@ __orca_omp() { fi } if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } + # Why the function reserved word: it suppresses alias expansion of the name, which + # an `alias omp` otherwise rewrites at parse time, aborting the rest of the file. + function omp { __orca_omp "$@"; } fi # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" # Why: a typed alias expands inside the shell, after pane launch prep. -__orca_codex_binary="$(command -v codex 2>/dev/null || :)" +# Why unalias inside the substitution: an alias named codex makes command -v +# report the alias text, and the subshell leaves the user's own alias intact. +# Why || : twice — zsh alone aborts inside the substitution, but every shell's +# assignment adopts its exit status, so an absent codex trips set -e in bash too. +__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then - codex() { + # Why the function reserved word: it suppresses alias expansion of the name, + # which otherwise rewrites this header at parse time and aborts the whole file. + function codex { "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : command codex "$@" } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index d73f2d4c4c9..92802c53dca 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -97,15 +97,23 @@ __orca_deferred_init() { fi } if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } + # Why the function reserved word: it suppresses alias expansion of the name, which + # an `alias omp` otherwise rewrites at parse time, aborting the rest of the file. + function omp { __orca_omp "$@"; } fi # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" # Why: a typed alias expands inside the shell, after pane launch prep. - __orca_codex_binary="$(command -v codex 2>/dev/null || :)" + # Why unalias inside the substitution: an alias named codex makes command -v + # report the alias text, and the subshell leaves the user's own alias intact. + # Why || : twice — zsh alone aborts inside the substitution, but every shell's + # assignment adopts its exit status, so an absent codex trips set -e in bash too. + __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then - codex() { + # Why the function reserved word: it suppresses alias expansion of the name, + # which otherwise rewrites this header at parse time and aborts the whole file. + function codex { "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : command codex "$@" } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt index 4c76e53acf6..6ac463f5770 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt @@ -51,7 +51,9 @@ __orca_omp() { fi } if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } + # Why the function reserved word: it suppresses alias expansion of the name, which + # an `alias omp` otherwise rewrites at parse time, aborting the rest of the file. + function omp { __orca_omp "$@"; } fi if [[ -n "${ORCA_HISTFILE:-}" ]]; then diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 40e10d17210..6e791812e07 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -71,7 +71,9 @@ __orca_deferred_init() { fi } if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } + # Why the function reserved word: it suppresses alias expansion of the name, which + # an `alias omp` otherwise rewrites at parse time, aborting the rest of the file. + function omp { __orca_omp "$@"; } fi fi if [[ -n "${_orca_histfile:-}" ]]; then diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 0ae94230829..c71b9b06722 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -390,7 +390,7 @@ describe('resolveWindowsShellLaunchArgs', () => { const zshEnv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') for (const wrapperFile of [bashRcfile, zshEnv]) { expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"') - expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }') + expect(wrapperFile).toContain('function omp { __orca_omp "$@"; }') expect(wrapperFile).not.toContain('prime-agent()') expect(wrapperFile).not.toContain('__orca_prime_agent') expect(wrapperFile).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') diff --git a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts index 058626741c9..abf189de356 100644 --- a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts +++ b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts @@ -1,5 +1,13 @@ import { execFileSync } from 'node:child_process' -import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -21,6 +29,7 @@ type Fixture = { intendedMarker: string hijackMarker: string codexMarker: string + postSnippetMarker: string homePath: string } @@ -30,7 +39,7 @@ function writeStub(path: string, markerPath: string): void { chmodSync(path, 0o755) } -function buildFixture(): Fixture { +function buildFixture(options: { aliasCodex?: boolean } = {}): Fixture { const root = mkdtempSync(join(tmpdir(), 'orca-codex-profile-path-')) roots.push(root) const resourcesPath = join(root, 'resources') @@ -40,6 +49,7 @@ function buildFixture(): Fixture { const intendedMarker = join(root, 'intended-ran') const hijackMarker = join(root, 'hijack-ran') const codexMarker = join(root, 'codex-ran') + const postSnippetMarker = join(root, 'rcfile-continued') mkdirSync(hijackDir, { recursive: true }) mkdirSync(codexDir, { recursive: true }) mkdirSync(homePath, { recursive: true }) @@ -54,17 +64,33 @@ function buildFixture(): Fixture { // Why: /etc/profile runs first and macOS path_helper rebuilds PATH from /etc/paths, // so the profile must re-prepend both dirs to model a real user's dotfile winning. + const aliasLine = options.aliasCodex + ? `alias codex='GIT_AUTHOR_NAME=Codex codex --alias-flag'\n` + : '' writeFileSync( join(homePath, '.bash_profile'), - `export PATH=${JSON.stringify(codexDir)}:"$PATH"\nexport PATH=${JSON.stringify(hijackDir)}:"$PATH"\n` + `export PATH=${JSON.stringify(codexDir)}:"$PATH"\nexport PATH=${JSON.stringify(hijackDir)}:"$PATH"\n${aliasLine}` ) - return { root, resourcesPath, intendedMarker, hijackMarker, codexMarker, homePath } + return { + root, + resourcesPath, + intendedMarker, + hijackMarker, + codexMarker, + postSnippetMarker, + homePath + } } function launchCodexThroughRcfile(fixture: Fixture, preflightValue: string): void { const rcfilePath = join(fixture.root, 'rcfile') writeFileSync(rcfilePath, getDaemonBashShellReadyRcfileContent(), 'utf8') - execFileSync('/bin/bash', ['--rcfile', rcfilePath, '-i', '-c', 'codex --version'], { + // Why the second command: the rcfile defines __orca_osc133_preexec *below* the + // codex wrapper, so its presence proves bash parsed past the snippet. + // Why the trailing `:` — the markers are this file's oracle, so a failed probe + // must surface as a missing marker, not an opaque non-zero exit from bash. + const command = `codex --version; declare -F __orca_osc133_preexec >/dev/null && printf ran > ${JSON.stringify(fixture.postSnippetMarker)}; :` + execFileSync('/bin/bash', ['--rcfile', rcfilePath, '-i', '-c', command], { stdio: 'ignore', env: { HOME: fixture.homePath, @@ -114,6 +140,28 @@ describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH' expect(existsSync(fixture.codexMarker)).toBe(true) }) + // Why: a user alias named codex used to expand into the wrapper's own `codex()` + // header, and bash abandons the rest of the rcfile at that syntax error — taking + // the OSC 133 hooks and the shell-ready marker defined below it with it. + it('finishes the rcfile and preserves the alias when the user aliased the name codex', () => { + const fixture = buildFixture({ aliasCodex: true }) + const preflightCommand = resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + managedHomePath: join(fixture.root, 'codex-home'), + userDataPath: join(fixture.root, 'user-data'), + resourcesPath: fixture.resourcesPath + }) + + launchCodexThroughRcfile(fixture, preflightCommand as string) + + expect(existsSync(fixture.postSnippetMarker)).toBe(true) + expect(existsSync(fixture.intendedMarker)).toBe(true) + expect(existsSync(fixture.hijackMarker)).toBe(false) + // The user's own alias must survive and still reach the binary. + expect(readFileSync(fixture.codexMarker, 'utf-8').trim()).toBe('--alias-flag --version') + }) + // Why: pins the defect itself, so a regression back to an unqualified name fails here. it('would run the impostor if the preflight carried an unqualified command name', () => { const fixture = buildFixture() diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index e642c03dd77..2aff785446b 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -1,4 +1,12 @@ -import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, isAbsolute, join } from 'node:path' import { execFileSync, spawnSync } from 'node:child_process' @@ -11,6 +19,8 @@ import { } from './codex-shell-launch-preflight' const roots: string[] = [] +const zshAvailable = existsSync('/bin/zsh') +const bashAvailable = existsSync('/bin/bash') const fishAvailable = spawnSync('fish', ['--version']).status === 0 const pwshAvailable = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-Command', 'exit 0']).status === 0 @@ -69,6 +79,57 @@ function runAliasLaunch( ).trim() } +/** Launches a startup file that aliases the very name Orca wraps, then asserts the + * wrapper installed, the preflight ran, and the user's alias still applies. */ +function expectNamedAliasSurvives(shell: string, enableAliases: string): void { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-named-alias-')) + roots.push(root) + const bin = join(root, 'bin') + mkdirSync(bin) + const preflightMarker = join(root, 'preflight-ran') + writeExecutable( + join(bin, 'codex'), + '#!/bin/sh\nprintf "launched args=[%s] author=[%s]\\n" "$*" "$GIT_AUTHOR_NAME"\n' + ) + writeExecutable( + join(bin, 'orca-test'), + `#!/bin/sh\nprintf '%s' "$*" > ${JSON.stringify(preflightMarker)}\n` + ) + // Why nested in `if true`: the shell parses a whole compound command before + // running any of it, so the alias expands into the wrapper's own header. + const startup = join(root, 'startup.sh') + writeFileSync( + startup, + [ + enableAliases, + "alias codex='GIT_AUTHOR_NAME=Codex codex --alias-flag'", + 'if true; then', + getPosixCodexShellLaunchPreflight(), + 'fi', + 'printf "parsed\\n"', + 'codex' + ].join('\n') + ) + + const result = spawnSync( + shell, + shell.endsWith('/zsh') ? ['-f', startup] : ['--noprofile', '--norc', startup], + { + encoding: 'utf-8', + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, + ORCA_CODEX_LAUNCH_PREFLIGHT: join(bin, 'orca-test') + } + } + ) + + expect(result.status, result.stderr).toBe(0) + // The alias's own flags and env prefix must still reach the binary. + expect(result.stdout).toBe('parsed\nlaunched args=[--alias-flag] author=[Codex]\n') + expect(readFileSync(preflightMarker, 'utf-8')).toBe('agent hooks prepare-codex') +} + afterEach(() => { for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) @@ -92,6 +153,14 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', () expect(runAliasLaunch(root, getPosixCodexShellLaunchPreflight(), '/bin/zsh')).toBe('normal') }) + it.skipIf(!zshAvailable)('keeps a user alias named codex working in zsh', () => { + expectNamedAliasSurvives('/bin/zsh', 'setopt aliases') + }) + + it.skipIf(!bashAvailable)('keeps a user alias named codex working in bash', () => { + expectNamedAliasSurvives('/bin/bash', 'shopt -s expand_aliases') + }) + it('still launches Codex when the best-effort preflight fails', () => { const root = mkdtempSync(join(tmpdir(), 'orca-codex-preflight-failure-')) roots.push(root) @@ -101,31 +170,35 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', () ) }) - it.each([ + // Why a loop over it.each: an early `return` reported green on hosts without the + // shell, so the zsh half silently never ran on Linux. skipIf reports it as a skip. + for (const [shell, strict] of [ ['/bin/bash', 'set -e'], ['/bin/zsh', 'setopt ERR_EXIT'] - ])('keeps %s startup alive under strict error handling when Codex is absent', (shell, strict) => { - if (!existsSync(shell)) { - return - } - const root = mkdtempSync(join(tmpdir(), 'orca-codex-strict-startup-')) - roots.push(root) + ]) { + it.skipIf(!existsSync(shell))( + `keeps ${shell} startup alive under strict error handling when Codex is absent`, + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-strict-startup-')) + roots.push(root) - const output = execFileSync( - shell, - [ - shell.endsWith('/zsh') ? '-f' : '--noprofile', - '-c', - `${strict}\n${getPosixCodexShellLaunchPreflight()}\nprintf alive` - ], - { - encoding: 'utf-8', - env: { ...process.env, PATH: root, ORCA_CODEX_LAUNCH_PREFLIGHT: 'orca-test' } + const output = execFileSync( + shell, + [ + shell.endsWith('/zsh') ? '-f' : '--noprofile', + '-c', + `${strict}\n${getPosixCodexShellLaunchPreflight()}\nprintf alive` + ], + { + encoding: 'utf-8', + env: { ...process.env, PATH: root, ORCA_CODEX_LAUNCH_PREFLIGHT: 'orca-test' } + } + ) + + expect(output).toBe('alive') } ) - - expect(output).toBe('alive') - }) + } it.skipIf(!fishAvailable)('preserves a user-defined fish function', () => { const output = execFileSync( diff --git a/src/main/pty/codex-shell-launch-preflight.ts b/src/main/pty/codex-shell-launch-preflight.ts index 9242eba0559..4b2429f50f1 100644 --- a/src/main/pty/codex-shell-launch-preflight.ts +++ b/src/main/pty/codex-shell-launch-preflight.ts @@ -60,9 +60,15 @@ function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolea export function getPosixCodexShellLaunchPreflight(): string { return `# Why: a typed alias expands inside the shell, after pane launch prep. -__orca_codex_binary="$(command -v codex 2>/dev/null || :)" +# Why unalias inside the substitution: an alias named codex makes command -v +# report the alias text, and the subshell leaves the user's own alias intact. +# Why || : twice — zsh alone aborts inside the substitution, but every shell's +# assignment adopts its exit status, so an absent codex trips set -e in bash too. +__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then - codex() { + # Why the function reserved word: it suppresses alias expansion of the name, + # which otherwise rewrites this header at parse time and aborts the whole file. + function codex { "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : command codex "$@" } diff --git a/src/main/pty/omp-shell-wrapper-alias-safety.test.ts b/src/main/pty/omp-shell-wrapper-alias-safety.test.ts new file mode 100644 index 00000000000..3863b3aacaf --- /dev/null +++ b/src/main/pty/omp-shell-wrapper-alias-safety.test.ts @@ -0,0 +1,70 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getPosixOmpShellWrapper } from './omp-shell-wrapper' + +const roots: string[] = [] +const zshAvailable = existsSync('/bin/zsh') +const bashAvailable = existsSync('/bin/bash') + +/** Sources the omp wrapper from a startup file that already aliased `omp`, then + * asserts the file parsed to its end and the alias still reaches the binary. */ +function expectAliasedOmpNameSurvives(shell: string, enableAliases: string): void { + const root = mkdtempSync(join(tmpdir(), 'orca-omp-alias-')) + roots.push(root) + const bin = join(root, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'omp'), '#!/bin/sh\nprintf "omp args=[%s]\\n" "$*"\n', { mode: 0o755 }) + const extension = join(root, 'status-extension') + writeFileSync(extension, '') + + const startup = join(root, 'startup.sh') + writeFileSync( + startup, + [ + enableAliases, + "alias omp='omp config --alias-flag'", + getPosixOmpShellWrapper(), + 'printf "parsed\\n"', + 'omp' + ].join('\n') + ) + + const result = spawnSync( + shell, + shell.endsWith('/zsh') ? ['-f', startup] : ['--noprofile', '--norc', startup], + { + encoding: 'utf-8', + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, + ORCA_OMP_STATUS_EXTENSION: extension + } + } + ) + + expect(result.status, result.stderr).toBe(0) + // `parsed` proves the shell got past the wrapper instead of abandoning the file. + expect(result.stdout).toBe('parsed\nomp args=[config --alias-flag]\n') +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +// Why: a user alias named omp used to expand into the wrapper's own `omp()` +// header, and the shell abandons the rest of the startup file at that syntax +// error — taking every hook Orca defines below the wrapper with it. +describe.skipIf(process.platform === 'win32')('omp wrapper under a user alias named omp', () => { + it.skipIf(!bashAvailable)('keeps a user alias named omp working in bash', () => { + expectAliasedOmpNameSurvives('/bin/bash', 'shopt -s expand_aliases') + }) + + it.skipIf(!zshAvailable)('keeps a user alias named omp working in zsh', () => { + expectAliasedOmpNameSurvives('/bin/zsh', 'setopt aliases') + }) +}) diff --git a/src/main/pty/omp-shell-wrapper.ts b/src/main/pty/omp-shell-wrapper.ts index 48f50aa5585..be94a9c088f 100644 --- a/src/main/pty/omp-shell-wrapper.ts +++ b/src/main/pty/omp-shell-wrapper.ts @@ -66,7 +66,9 @@ __orca_omp() { fi } if [[ -n "\${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } + # Why the function reserved word: it suppresses alias expansion of the name, which + # an \`alias omp\` otherwise rewrites at parse time, aborting the rest of the file. + function omp { __orca_omp "$@"; } fi ` } From ac103f5d902c94e7c0124d5d8d96164c122c40a6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:15:31 -0700 Subject: [PATCH 11/27] fix(project-groups): route rename and delete to the group's owning host (#15889) --- .../sidebar/project-group-header-dom.test.ts | 5 +- .../worktree-list/drag/use-header-drag.ts | 17 +- .../worktree-list/rows/SectionHeader.tsx | 12 +- .../rows/project-group-header-actions.tsx | 12 +- ...-project-group-dialogs-owner-host.test.tsx | 136 ++++++++ .../rows/use-project-group-dialogs.ts | 54 ++- .../worktree-list/viewport/viewport-props.ts | 4 +- src/renderer/src/i18n/locales/en.json | 2 + .../slices/project-group-owner-routing.ts | 94 +++++ .../slices/project-group-removal-targets.ts | 21 +- ...repos-project-groups-owner-routing.test.ts | 324 ++++++++++++++++++ src/renderer/src/store/slices/repos.ts | 128 ++++--- 12 files changed, 740 insertions(+), 69 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx create mode 100644 src/renderer/src/store/slices/project-group-owner-routing.ts create mode 100644 src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts diff --git a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts index bb3b6e06c85..2e7523e5ecb 100644 --- a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts +++ b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts @@ -30,7 +30,10 @@ describe('Project Group header drag DOM source', () => { const source = readHeaderDragSource() expect(source).toContain('const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)') - expect(source).toContain('void updateProjectGroup(groupId, { tabOrder })') + // Why: the commit must carry the group's owner host so a non-focused host still persists the order. + expect(source).toContain( + 'void updateProjectGroup(groupId, { tabOrder }, { hostId: ownerHostId ?? undefined })' + ) }) it('keeps grab cursor on the title surface and dual handle attrs on row + surface', () => { diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts index 80f608f8665..0127660c712 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo } from 'react' import type React from 'react' import { useAppStore } from '@/store' +import { getProjectGroupHostId } from '@/store/slices/project-group-owner-routing' import type { ProjectGroup } from '../../../../../../shared/project-group-types' import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types' import type { Repo } from '../../../../../../shared/repo-types' @@ -83,6 +84,16 @@ export function useWorktreeSidebarHeaderDrag(args: { () => new Map(projectGroups.map((group) => [group.id, group])), [projectGroups] ) + // Why: an id shared by two hosts has no single owner, so leave it unrouted rather than guess. + const projectGroupOwnerHostIdByGroupId = useMemo(() => { + const byGroupId = new Map() + for (const group of projectGroups) { + const hostId = getProjectGroupHostId(group) + const existing = byGroupId.get(group.id) + byGroupId.set(group.id, existing === undefined || existing === hostId ? hostId : null) + } + return byGroupId + }, [projectGroups]) // Why: reorder keeps scrollTop stable; flag direct scroll input so anchor-restore won't chase the moved row (jumpy drop). const suppressScrollCorrectionForHeaderCommit = useCallback(() => { @@ -157,9 +168,11 @@ export function useWorktreeSidebarHeaderDrag(args: { return } suppressScrollCorrectionForHeaderCommit() - void updateProjectGroup(groupId, { tabOrder }) + // Why: manual order persists on the group's own host; the focused host may not hold this row. + const ownerHostId = projectGroupOwnerHostIdByGroupId.get(groupId) + void updateProjectGroup(groupId, { tabOrder }, { hostId: ownerHostId ?? undefined }) }, - [suppressScrollCorrectionForHeaderCommit, updateProjectGroup] + [projectGroupOwnerHostIdByGroupId, suppressScrollCorrectionForHeaderCommit, updateProjectGroup] ) // Drag applies only in manual order; still construct the controller inert for stable hook order. const repoDrag = useRepoHeaderDrag({ diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx index 727438f997a..b4ed149712a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx @@ -8,6 +8,8 @@ import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator' import type { FolderWorkspacePathStatus } from '../../../../../../shared/folder-workspace-path-status' import { isConfirmedStaleFolderPathStatus } from '../../../../../../shared/folder-workspace-path-status' import type { ProjectGroup } from '../../../../../../shared/project-group-types' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { getProjectGroupHostId } from '@/store/slices/project-group-owner-routing' import type { WorkspaceStatus, WorkspaceStatusDefinition @@ -56,8 +58,8 @@ export type SectionHeaderRowContext = { }) => FolderWorkspacePathStatus | null toggleGroupWithScrollAnchor: (groupKey: string) => void projectActions: RepoHeaderProjectActions - onRenameProjectGroup: (groupId: string, currentName: string) => void - onDeleteProjectGroup: (groupId: string, groupName: string) => void + onRenameProjectGroup: (groupId: string, currentName: string, hostId?: ExecutionHostId) => void + onDeleteProjectGroup: (groupId: string, groupName: string, hostId?: ExecutionHostId) => void onCreateFolderWorkspace: (projectGroup: ProjectGroup) => void onWorkspaceStatusDragOver: (event: React.DragEvent, status: WorkspaceStatus) => void onWorkspaceStatusDragLeave: (event: React.DragEvent) => void @@ -92,6 +94,11 @@ export function renderWorktreeSectionHeaderRow(args: { isProjectGroupHeader && !row.repo && typeof row.projectGroup?.id === 'string' ? row.projectGroup.id : undefined + // Why: rename/delete must route to the host that owns this row, not to whichever host has focus. + const projectGroupHostIdForHeader = + row.projectGroup && 'createdFrom' in row.projectGroup + ? getProjectGroupHostId(row.projectGroup) + : undefined const repoHeaderIndex = projectIdForHeader !== undefined ? headerDrag.repoHeaderIndexByRepoId.get(projectIdForHeader) @@ -353,6 +360,7 @@ export function renderWorktreeSectionHeaderRow(args: { {isProjectGroupHeader && !row.repo && projectGroupIdForHeader ? ( void - onDelete: (groupId: string, groupName: string) => void + onRename: (groupId: string, currentName: string, hostId?: ExecutionHostId) => void + onDelete: (groupId: string, groupName: string, hostId?: ExecutionHostId) => void }): React.JSX.Element { return ( @@ -64,10 +68,10 @@ export function ProjectGroupHeaderMenu({ onClick={stopRepoHeaderMenuEvent} onKeyDown={stopRepoHeaderMenuEvent} > - onRename(groupId, label)}> + onRename(groupId, label, hostId)}> {translate('auto.components.sidebar.WorktreeList.4d7b73658c', 'Rename group')} - onDelete(groupId, label)}> + onDelete(groupId, label, hostId)}> {translate('auto.components.sidebar.WorktreeList.902115cdbe', 'Delete group')} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx new file mode 100644 index 00000000000..dd2346ecfce --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useProjectGroupDialogs, type ProjectGroupDialogs } from './use-project-group-dialogs' +import type { ProjectGroup } from '../../../../../../shared/project-group-types' + +const mocks = vi.hoisted(() => ({ + moveProjectToGroup: vi.fn(), + createProjectGroup: vi.fn(), + updateProjectGroup: vi.fn(), + deleteProjectGroupWithContainedProjects: vi.fn(), + toastError: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + moveProjectToGroup: mocks.moveProjectToGroup, + createProjectGroup: mocks.createProjectGroup, + updateProjectGroup: mocks.updateProjectGroup, + deleteProjectGroupWithContainedProjects: mocks.deleteProjectGroupWithContainedProjects + }) +})) + +vi.mock('sonner', () => ({ + toast: { error: mocks.toastError } +})) + +const remoteGroup: ProjectGroup = { + id: 'group-1', + name: 'Projects', + parentPath: '/srv/projects', + parentGroupId: null, + createdFrom: 'folder-scan', + executionHostId: 'runtime:env-1', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +let latest: ProjectGroupDialogs | null = null +const roots: Root[] = [] + +function HookProbe(): null { + latest = useProjectGroupDialogs({ + repos: [], + repoMap: new Map(), + projectGroups: [remoteGroup] + }) + return null +} + +async function renderHookProbe(): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render() + }) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.updateProjectGroup.mockResolvedValue(true) + mocks.deleteProjectGroupWithContainedProjects.mockResolvedValue({ + status: 'deleted-group', + groupId: remoteGroup.id, + requestedProjectIds: [], + removedProjectIds: [], + failedProjectRemovals: [] + }) +}) + +afterEach(async () => { + await act(async () => { + for (const root of roots.splice(0)) { + root.unmount() + } + }) + latest = null +}) + +describe('project group dialogs carry the owner host', () => { + it('renames through the host that owns the group row', async () => { + await renderHookProbe() + await act(async () => { + latest!.handleRenameProjectGroup(remoteGroup.id, remoteGroup.name, 'runtime:env-1') + }) + await act(async () => { + await latest!.handleSubmitProjectGroupName('Renamed') + }) + + expect(mocks.updateProjectGroup).toHaveBeenCalledWith( + remoteGroup.id, + { name: 'Renamed' }, + { hostId: 'runtime:env-1' } + ) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('surfaces an unconfirmed-rename toast when the owner host does not answer', async () => { + mocks.updateProjectGroup.mockResolvedValue(false) + await renderHookProbe() + await act(async () => { + latest!.handleRenameProjectGroup(remoteGroup.id, remoteGroup.name, 'runtime:env-1') + }) + await act(async () => { + await latest!.handleSubmitProjectGroupName('Renamed') + }) + + expect(mocks.toastError).toHaveBeenCalledWith('Failed to rename group', { + description: + "Orca could not confirm the new name with the group's host. Recheck the group after reconnecting." + }) + }) + + it('deletes through the host that owns the group row', async () => { + await renderHookProbe() + await act(async () => { + latest!.handleDeleteProjectGroup(remoteGroup.id, remoteGroup.name, 'runtime:env-1') + }) + await act(async () => { + await latest!.handleConfirmDeleteProjectGroup() + }) + + expect(mocks.deleteProjectGroupWithContainedProjects).toHaveBeenCalledWith(remoteGroup.id, { + removeContainedProjects: false, + hostId: 'runtime:env-1' + }) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts index b66eb737af5..fb027095064 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts @@ -5,15 +5,18 @@ import { translate } from '@/i18n/i18n' import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets' import type { ProjectGroup } from '../../../../../../shared/project-group-types' import type { Repo } from '../../../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' export type ProjectGroupNameDialogState = | { type: 'create-from-repo'; repo: Repo } - | { type: 'rename'; groupId: string; currentName: string } + // hostId is the group row's owner host, so the mutation is not routed to whichever host has focus. + | { type: 'rename'; groupId: string; currentName: string; hostId?: ExecutionHostId } export type ProjectGroupDeleteDialogState = { groupId: string groupName: string removeContainedProjects: boolean + hostId?: ExecutionHostId } export type ProjectGroupDialogs = ReturnType @@ -95,9 +98,12 @@ export function useProjectGroupDialogs(args: { [moveProjectToGroup] ) - const handleRenameProjectGroup = useCallback((groupId: string, currentName: string) => { - setNameDialog({ type: 'rename', groupId, currentName }) - }, []) + const handleRenameProjectGroup = useCallback( + (groupId: string, currentName: string, hostId?: ExecutionHostId) => { + setNameDialog({ type: 'rename', groupId, currentName, hostId }) + }, + [] + ) const handleSubmitProjectGroupName = useCallback( async (name: string) => { @@ -111,7 +117,26 @@ export function useProjectGroupDialogs(args: { } return } - await updateProjectGroup(nameDialog.groupId, { name }) + const renamed = await updateProjectGroup( + nameDialog.groupId, + { name }, + { hostId: nameDialog.hostId } + ) + if (!renamed) { + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.groupRenameFailed', + 'Failed to rename group' + ), + { + description: translate( + 'auto.components.sidebar.WorktreeList.groupRenameFailedDesc', + // Why: a falsy result also covers RPC timeout/disconnect, so the copy must not assert the host refused. + "Orca could not confirm the new name with the group's host. Recheck the group after reconnecting." + ) + } + ) + } }, [createProjectGroup, moveProjectToGroup, nameDialog, updateProjectGroup] ) @@ -120,7 +145,12 @@ export function useProjectGroupDialogs(args: { if (!deleteDialog) { return null } - return selectProjectGroupRemovalTargets(projectGroups, repos, deleteDialog.groupId) + return selectProjectGroupRemovalTargets( + projectGroups, + repos, + deleteDialog.groupId, + deleteDialog.hostId + ) }, [deleteDialog, projectGroups, repos]) const deleteProjectCount = deleteTargets?.projectIds.length ?? 0 const deleteProjectNames = useMemo( @@ -133,9 +163,12 @@ export function useProjectGroupDialogs(args: { const removeContainedProjects = deleteProjectCount > 0 && deleteDialog?.removeContainedProjects === true - const handleDeleteProjectGroup = useCallback((groupId: string, groupName: string) => { - setDeleteDialog({ groupId, groupName, removeContainedProjects: false }) - }, []) + const handleDeleteProjectGroup = useCallback( + (groupId: string, groupName: string, hostId?: ExecutionHostId) => { + setDeleteDialog({ groupId, groupName, removeContainedProjects: false, hostId }) + }, + [] + ) const handleConfirmDeleteProjectGroup = useCallback(async () => { if (!deleteDialog) { @@ -144,7 +177,8 @@ export function useProjectGroupDialogs(args: { try { reportProjectGroupDeleteFailures( await deleteProjectGroupWithContainedProjects(deleteDialog.groupId, { - removeContainedProjects + removeContainedProjects, + hostId: deleteDialog.hostId }) ) } finally { diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts index 570077a1c26..e341f77695b 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts @@ -47,8 +47,8 @@ export type VirtualizedWorktreeViewportProps = { handleCreateGroupFromRepo: (repo: Repo) => void handleMoveProjectToGroup: (repo: Repo, groupId: string) => void handleRemoveProjectFromGroup: (repo: Repo) => void - handleRenameProjectGroup: (groupId: string, currentName: string) => void - handleDeleteProjectGroup: (groupId: string, groupName: string) => void + handleRenameProjectGroup: (groupId: string, currentName: string, hostId?: ExecutionHostId) => void + handleDeleteProjectGroup: (groupId: string, groupName: string, hostId?: ExecutionHostId) => void handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void activeModal: string pendingRevealWorktree: PendingSidebarWorktreeReveal | null diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4c9fb2d9a5d..59ba7e3237c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5333,6 +5333,8 @@ "f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.", "groupDeleteFailed": "Failed to delete group", "groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed.", + "groupRenameFailed": "Failed to rename group", + "groupRenameFailedDesc": "Orca could not confirm the new name with the group's host. Recheck the group after reconnecting.", "failedNestWorkspace": "Failed to nest workspace", "sidebarRowMissing": "Target no longer exists", "failedUnnestWorkspace": "Failed to unnest workspace" diff --git a/src/renderer/src/store/slices/project-group-owner-routing.ts b/src/renderer/src/store/slices/project-group-owner-routing.ts new file mode 100644 index 00000000000..78da4385a53 --- /dev/null +++ b/src/renderer/src/store/slices/project-group-owner-routing.ts @@ -0,0 +1,94 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { findIndexedProjectGroupOwner } from '@/lib/worktree-runtime-owner-index' + +type ProjectGroupHostParts = Pick +type ProjectGroupOwnerRecord = Pick +type RoutingSettings = Pick | null | undefined + +type ProjectGroupOwnerRoutingState = { + projectGroups: readonly ProjectGroupOwnerRecord[] + settings: RoutingSettings +} + +// Why: persisted rows predate host stamping and may carry padded/unparseable ids; normalize so +// routing and catalog identity agree on the same owner host. +export function getProjectGroupHostId(group: ProjectGroupHostParts): ExecutionHostId { + const executionHostId = normalizeExecutionHostId(group.executionHostId) + if (executionHostId) { + return executionHostId + } + const connectionId = group.connectionId?.trim() + return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID +} + +export function catalogOwnsHost(catalogHostId: string, rowHostId: string): boolean { + if (catalogHostId !== LOCAL_EXECUTION_HOST_ID) { + return catalogHostId === rowHostId + } + return parseExecutionHostId(rowHostId)?.kind !== 'runtime' +} + +export function projectGroupMatchesOwnerHost( + group: ProjectGroupOwnerRecord, + groupId: string, + ownerHostId: ExecutionHostId | null +): boolean { + if (group.id !== groupId) { + return false + } + return ownerHostId ? catalogOwnsHost(ownerHostId, getProjectGroupHostId(group)) : true +} + +export function resolveProjectGroupOwnerHostId( + state: ProjectGroupOwnerRoutingState, + groupId: string, + hostId?: ExecutionHostId +): ExecutionHostId | null { + const owner = findIndexedProjectGroupOwner(state.projectGroups, groupId, hostId) + if (!owner) { + return null + } + if (hostId) { + return hostId + } + // Why: an unstamped row carries no owner, so keep the focused-host behavior instead of assuming local. + if (!owner.executionHostId && !owner.connectionId) { + return null + } + return getProjectGroupHostId(owner) +} + +// Why: the sidebar lists groups from every host, so mutations must route to the row's owner +// instead of whichever host currently has focus. Mirrors settingsForRepoOwner. +export function settingsForProjectGroupOwner( + state: ProjectGroupOwnerRoutingState, + groupId: string, + hostId?: ExecutionHostId +): RoutingSettings { + const ownerHostId = resolveProjectGroupOwnerHostId(state, groupId, hostId) + if (!ownerHostId) { + return state.settings + } + const parsed = parseExecutionHostId(ownerHostId) + if (parsed?.kind === 'runtime') { + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } + : { activeRuntimeEnvironmentId: parsed.environmentId } + } + // Why: direct-SSH groups live in the local main process catalog, so they route through window.api. + if ( + (parsed?.kind === 'local' || parsed?.kind === 'ssh') && + state.settings?.activeRuntimeEnvironmentId + ) { + return { ...state.settings, activeRuntimeEnvironmentId: null } + } + return state.settings +} diff --git a/src/renderer/src/store/slices/project-group-removal-targets.ts b/src/renderer/src/store/slices/project-group-removal-targets.ts index ee0c11c3c4b..667d74ceb1f 100644 --- a/src/renderer/src/store/slices/project-group-removal-targets.ts +++ b/src/renderer/src/store/slices/project-group-removal-targets.ts @@ -1,6 +1,8 @@ import type { ProjectGroup } from '../../../../shared/project-group-types' import type { Repo } from '../../../../shared/repo-types' import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { catalogOwnsHost, getProjectGroupHostId } from './project-group-owner-routing' export type ProjectGroupRemovalTargets = { groupExists: boolean @@ -11,9 +13,16 @@ export type ProjectGroupRemovalTargets = { export function selectProjectGroupRemovalTargets( projectGroups: readonly ProjectGroup[], repos: readonly Repo[], - groupId: string + groupId: string, + // Why: ids repeat across hosts; without an owner host the caller would target another host's rows. + hostId?: ExecutionHostId | null ): ProjectGroupRemovalTargets { - const groupExists = projectGroups.some((group) => group.id === groupId) + const ownsRowHost = (rowHostId: string): boolean => + hostId ? catalogOwnsHost(hostId, rowHostId) : true + const ownerGroups = hostId + ? projectGroups.filter((group) => ownsRowHost(getProjectGroupHostId(group))) + : projectGroups + const groupExists = ownerGroups.some((group) => group.id === groupId) if (!groupExists) { return { groupExists: false, @@ -22,10 +31,14 @@ export function selectProjectGroupRemovalTargets( } } - const deletedGroupIds = getProjectGroupSubtreeIds(projectGroups, groupId) + const deletedGroupIds = getProjectGroupSubtreeIds(ownerGroups, groupId) const projectIds: string[] = [] for (const repo of repos) { - if (repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)) { + if ( + repo.projectGroupId && + deletedGroupIds.has(repo.projectGroupId) && + ownsRowHost(getRepoExecutionHostId(repo)) + ) { projectIds.push(repo.id) } } diff --git a/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts b/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts new file mode 100644 index 00000000000..c936001cc3d --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts @@ -0,0 +1,324 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { createTestStore } from './store-test-helpers' + +const folderScanGroup: ProjectGroup = { + id: 'group-1', + name: 'Projects', + parentPath: '/srv/projects', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +const baseRepo: Repo = { + id: 'repo', + path: '/repo', + displayName: 'Repo', + badgeColor: '#111', + addedAt: 1 +} + +const baseFolderWorkspace: FolderWorkspace = { + id: 'folder-workspace-1', + projectGroupId: folderScanGroup.id, + name: 'Notes', + folderPath: '/srv/projects/notes', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 +} + +const projectGroupsUpdate = vi.fn() +const projectGroupsDelete = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.clearAllMocks() + projectGroupsUpdate.mockResolvedValue(null) + projectGroupsDelete.mockResolvedValue(false) + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { remove: vi.fn() }, + projectGroups: { update: projectGroupsUpdate, delete: projectGroupsDelete }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +function runtimeRpcResponse(result: unknown) { + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-remote' } } +} + +describe('project group mutations route to the owning host', () => { + it('renames a runtime-owned folder-scan group while the local host is focused', async () => { + const runtimeGroup: ProjectGroup = { ...folderScanGroup, executionHostId: 'runtime:env-1' } + runtimeEnvironmentCall.mockResolvedValue( + runtimeRpcResponse({ group: { ...runtimeGroup, name: 'Renamed' } }) + ) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [runtimeGroup] + }) + + await expect( + store.getState().updateProjectGroup(runtimeGroup.id, { name: 'Renamed' }) + ).resolves.toBe(true) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectGroup.update', + params: { groupId: runtimeGroup.id, updates: { name: 'Renamed' } }, + timeoutMs: 15_000 + }) + expect(projectGroupsUpdate).not.toHaveBeenCalled() + expect(store.getState().projectGroups[0]).toMatchObject({ + name: 'Renamed', + executionHostId: 'runtime:env-1' + }) + }) + + it('deletes a local folder-scan group while a runtime host is focused', async () => { + projectGroupsDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + projectGroups: [{ ...folderScanGroup, executionHostId: 'local' }] + }) + + await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true) + + expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(store.getState().projectGroups).toEqual([]) + }) + + it('routes a direct-SSH group through the local catalog, not the focused runtime', async () => { + projectGroupsDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + projectGroups: [{ ...folderScanGroup, connectionId: 'conn-1' }] + }) + + await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true) + + expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('prefers an explicit hostId over both the focused host and a colliding row', async () => { + runtimeEnvironmentCall.mockResolvedValue(runtimeRpcResponse({ deleted: true })) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, executionHostId: 'runtime:env-1' } + ] + }) + + await expect( + store.getState().deleteProjectGroup(folderScanGroup.id, { hostId: 'runtime:env-1' }) + ).resolves.toBe(true) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectGroup.delete', + params: { groupId: folderScanGroup.id }, + timeoutMs: 15_000 + }) + expect(projectGroupsDelete).not.toHaveBeenCalled() + expect(store.getState().projectGroups).toMatchObject([{ executionHostId: 'local' }]) + }) + + it('keeps the focused host when a colliding id has no unambiguous owner', async () => { + projectGroupsDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, executionHostId: 'runtime:env-1' } + ] + }) + + await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true) + + expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + // Why: a lost connection is unverifiable, not a refusal - the failure toast copy must stay non-asserting. + it('reports an unreachable owner as failure without touching local state', async () => { + const runtimeGroup: ProjectGroup = { ...folderScanGroup, executionHostId: 'runtime:env-1' } + runtimeEnvironmentCall.mockRejectedValue(new Error('runtime rpc timed out')) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [runtimeGroup], + folderWorkspaces: [baseFolderWorkspace] + }) + + await expect( + store.getState().updateProjectGroup(runtimeGroup.id, { name: 'Renamed' }) + ).resolves.toBe(false) + await expect(store.getState().deleteProjectGroup(runtimeGroup.id)).resolves.toBe(false) + + expect(projectGroupsUpdate).not.toHaveBeenCalled() + expect(store.getState().projectGroups).toEqual([runtimeGroup]) + expect(store.getState().folderWorkspaces).toEqual([baseFolderWorkspace]) + }) +}) + +describe('project group state cascades stay scoped to the owner host', () => { + it('leaves another host rows intact when deleting a colliding local group', async () => { + projectGroupsDelete.mockResolvedValue(true) + const runtimeChild: ProjectGroup = { + ...folderScanGroup, + id: 'child', + parentGroupId: folderScanGroup.id, + executionHostId: 'runtime:env-1' + } + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, name: 'Remote projects', executionHostId: 'runtime:env-1' }, + runtimeChild + ], + folderWorkspaces: [ + { ...baseFolderWorkspace, executionHostId: 'local' }, + { + ...baseFolderWorkspace, + id: 'folder-workspace-remote', + executionHostId: 'runtime:env-1' + } + ], + repos: [ + { ...baseRepo, id: 'local-repo', projectGroupId: folderScanGroup.id }, + { + ...baseRepo, + id: 'remote-repo', + projectGroupId: folderScanGroup.id, + executionHostId: 'runtime:env-1' + } + ] + }) + + await expect( + store.getState().deleteProjectGroup(folderScanGroup.id, { hostId: 'local' }) + ).resolves.toBe(true) + + expect(store.getState().projectGroups).toMatchObject([ + { id: folderScanGroup.id, executionHostId: 'runtime:env-1' }, + { id: 'child', executionHostId: 'runtime:env-1' } + ]) + expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([ + 'folder-workspace-remote' + ]) + expect(store.getState().repos).toMatchObject([ + { id: 'local-repo', projectGroupId: null }, + { id: 'remote-repo', projectGroupId: folderScanGroup.id } + ]) + }) + + it('renames only the owner host row when the id exists on two hosts', async () => { + projectGroupsUpdate.mockResolvedValue({ + ...folderScanGroup, + executionHostId: 'local', + name: 'Renamed' + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, name: 'Remote projects', executionHostId: 'runtime:env-1' } + ] + }) + + await expect( + store.getState().updateProjectGroup( + folderScanGroup.id, + { name: 'Renamed' }, + { + hostId: 'local' + } + ) + ).resolves.toBe(true) + + expect(store.getState().projectGroups).toMatchObject([ + { name: 'Renamed', executionHostId: 'local' }, + { name: 'Remote projects', executionHostId: 'runtime:env-1' } + ]) + }) + + it('removes contained projects only from the owner host', async () => { + projectGroupsDelete.mockResolvedValue(true) + const reposRemove = vi.fn().mockResolvedValue(undefined) + const reposRemoveForHost = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { + api: { + repos: { remove: reposRemove, removeForHost: reposRemoveForHost }, + projectGroups: { update: projectGroupsUpdate, delete: projectGroupsDelete }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, executionHostId: 'runtime:env-1' } + ], + repos: [ + { ...baseRepo, id: 'local-repo', projectGroupId: folderScanGroup.id }, + { + ...baseRepo, + id: 'remote-repo', + projectGroupId: folderScanGroup.id, + executionHostId: 'runtime:env-1' + } + ] + }) + + const result = await store + .getState() + .deleteProjectGroupWithContainedProjects(folderScanGroup.id, { + removeContainedProjects: true, + hostId: 'local' + }) + + expect(result).toMatchObject({ + status: 'deleted-group', + requestedProjectIds: ['local-repo'], + removedProjectIds: ['local-repo'] + }) + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['remote-repo']) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 2a802bc57b2..f0fd6fcbae8 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -52,6 +52,13 @@ import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { structuralValuesEqual } from '../../../../shared/structural-value-equality' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' +import { + catalogOwnsHost, + getProjectGroupHostId, + projectGroupMatchesOwnerHost, + resolveProjectGroupOwnerHostId, + settingsForProjectGroupOwner +} from './project-group-owner-routing' import { reconcileCatalogRows, reconcileFetchedRepos } from './repo-identity-reconcile' import { retainValidFilterRepoIds } from './repo-filter-selection' import { readRuntimeWorktreeVisibilitySnapshot } from './worktree-visibility-owner-settings' @@ -218,6 +225,8 @@ export type FolderWorkspacePathStatusCacheEntry = { export type DeleteProjectGroupWithContainedProjectsOptions = { removeContainedProjects: boolean + // hostId disambiguates which host's group row to delete when the id exists on multiple hosts. + hostId?: ExecutionHostId } type AllHostCatalogFetchOptions = { @@ -1120,24 +1129,10 @@ function mergeProjectCompatibilityForHostRepoChange({ }) } -function getProjectGroupHostId(group: Pick) { - if (group.executionHostId) { - return group.executionHostId - } - return group.connectionId ? toSshExecutionHostId(group.connectionId) : LOCAL_EXECUTION_HOST_ID -} - function getProjectGroupHostIdentity(group: ProjectGroup): string { return JSON.stringify([getProjectGroupHostId(group), group.id]) } -function catalogOwnsHost(catalogHostId: string, rowHostId: string): boolean { - if (catalogHostId !== LOCAL_EXECUTION_HOST_ID) { - return catalogHostId === rowHostId - } - return parseExecutionHostId(rowHostId)?.kind !== 'runtime' -} - function mergeFetchedProjectGroupsForHost( previous: readonly ProjectGroup[], fetched: ProjectGroup[], @@ -1186,6 +1181,37 @@ function getFolderWorkspaceHostIdentity( return JSON.stringify([getFolderWorkspaceHostId(workspace, projectGroups), workspace.id]) } +// Why: a group id can exist on several hosts; only the deleted owner's rows may be cascaded away. +function applyProjectGroupDeleteCascade( + s: Pick, + groupId: string, + ownerHostId: ExecutionHostId | null +): Pick { + const ownsRowHost = (rowHostId: string): boolean => + ownerHostId ? catalogOwnsHost(ownerHostId, rowHostId) : true + const ownerGroups = s.projectGroups.filter((group) => ownsRowHost(getProjectGroupHostId(group))) + const deletedGroupIds = getProjectGroupSubtreeIds(ownerGroups, groupId) + const isDeletedGroup = (group: ProjectGroup): boolean => + deletedGroupIds.has(group.id) && ownsRowHost(getProjectGroupHostId(group)) + return { + projectGroups: s.projectGroups.filter((group) => !isDeletedGroup(group)), + folderWorkspaces: s.folderWorkspaces.filter( + (workspace) => + !deletedGroupIds.has(workspace.projectGroupId) || + // Why: resolve the workspace's host against the pre-delete group list, which still holds its owner row. + !ownsRowHost(getFolderWorkspaceHostId(workspace, s.projectGroups)) + ), + repos: s.repos.map((repo) => + repo.projectGroupId && + deletedGroupIds.has(repo.projectGroupId) && + ownsRowHost(getRepoExecutionHostId(repo)) + ? { ...repo, projectGroupId: null } + : repo + ), + folderWorkspacePathStatuses: {} + } +} + function getFolderWorkspaceUpdateIdentity( hostId: ExecutionHostId, folderWorkspaceId: string @@ -1883,11 +1909,13 @@ export type RepoSlice = { options?: { executionHostId?: ExecutionHostId } ) => Promise deleteFolderWorkspace: (folderWorkspaceId: string) => Promise + // options.hostId targets a specific host's row + RPC target when the id exists on multiple hosts; else the group's own host owns the call. updateProjectGroup: ( groupId: string, - updates: Partial> + updates: Partial>, + options?: { hostId?: ExecutionHostId } ) => Promise - deleteProjectGroup: (groupId: string) => Promise + deleteProjectGroup: (groupId: string, options?: { hostId?: ExecutionHostId }) => Promise deleteProjectGroupWithContainedProjects: ( groupId: string, options: DeleteProjectGroupWithContainedProjectsOptions @@ -2990,10 +3018,13 @@ export const createRepoSlice: StateCreator = (set, } }, - updateProjectGroup: async (groupId, updates) => { + updateProjectGroup: async (groupId, updates, options) => { try { - // Why: project groups are focused-host-scoped by design; all CRUD routes by the focused host and the list is replaced, not merged. - const target = getActiveRuntimeTarget(get().settings) + // Why: the sidebar lists groups from every host, so the mutation follows the group's owner, not the focused host. + const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId) + const target = getActiveRuntimeTarget( + settingsForProjectGroupOwner(get(), groupId, options?.hostId) + ) const updated = target.kind === 'local' ? await window.api.projectGroups.update({ groupId, updates }) @@ -3010,7 +3041,9 @@ export const createRepoSlice: StateCreator = (set, } const ownedGroup = projectGroupWithFetchedOwner(updated, target) set((s) => ({ - projectGroups: s.projectGroups.map((group) => (group.id === groupId ? ownedGroup : group)), + projectGroups: s.projectGroups.map((group) => + projectGroupMatchesOwnerHost(group, groupId, ownerHostId) ? ownedGroup : group + ), folderWorkspacePathStatuses: {} })) return true @@ -3020,10 +3053,13 @@ export const createRepoSlice: StateCreator = (set, } }, - deleteProjectGroup: async (groupId) => { + deleteProjectGroup: async (groupId, options) => { try { - // Why: project groups are focused-host-scoped by design (see updateProjectGroup). - const target = getActiveRuntimeTarget(get().settings) + // Why: deletion targets the group's owner host (see updateProjectGroup); focus may be elsewhere. + const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId) + const target = getActiveRuntimeTarget( + settingsForProjectGroupOwner(get(), groupId, options?.hostId) + ) const deleted = target.kind === 'local' ? await window.api.projectGroups.delete({ groupId }) @@ -3038,21 +3074,7 @@ export const createRepoSlice: StateCreator = (set, if (!deleted) { return false } - set((s) => { - const deletedGroupIds = getProjectGroupSubtreeIds(s.projectGroups, groupId) - return { - projectGroups: s.projectGroups.filter((group) => !deletedGroupIds.has(group.id)), - folderWorkspaces: s.folderWorkspaces.filter( - (workspace) => !deletedGroupIds.has(workspace.projectGroupId) - ), - repos: s.repos.map((repo) => - repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId) - ? { ...repo, projectGroupId: null } - : repo - ), - folderWorkspacePathStatuses: {} - } - }) + set((s) => applyProjectGroupDeleteCascade(s, groupId, ownerHostId)) return true } catch (err) { console.error('Failed to delete project group:', err) @@ -3061,7 +3083,13 @@ export const createRepoSlice: StateCreator = (set, }, deleteProjectGroupWithContainedProjects: async (groupId, options) => { - const targets = selectProjectGroupRemovalTargets(get().projectGroups, get().repos, groupId) + const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options.hostId) + const targets = selectProjectGroupRemovalTargets( + get().projectGroups, + get().repos, + groupId, + ownerHostId + ) const requestedProjectIds = options.removeContainedProjects ? targets.projectIds : [] if (!targets.groupExists) { return { @@ -3073,7 +3101,9 @@ export const createRepoSlice: StateCreator = (set, } } - const deleted = await get().deleteProjectGroup(groupId) + const deleted = await get().deleteProjectGroup(groupId, { + hostId: ownerHostId ?? undefined + }) if (!deleted) { return { status: 'group-delete-failed', @@ -3096,16 +3126,26 @@ export const createRepoSlice: StateCreator = (set, const removedProjectIds: string[] = [] const failedProjectRemovals: ProjectRemovalFailure[] = [] + // Why: the group's catalog can hold rows from several hosts (a local catalog also owns SSH rows), + // so each project is removed on its own host rather than on the group's. + const findOwnedProjects = (projectId: string): Repo[] => + get().repos.filter( + (repo) => + repo.id === projectId && + (!ownerHostId || catalogOwnsHost(ownerHostId, getRepoExecutionHostId(repo))) + ) for (const projectId of targets.projectIds) { - const existedBeforeRemoval = get().repos.some((repo) => repo.id === projectId) + const ownedProjects = findOwnedProjects(projectId) + const projectHostId = + ownedProjects.length === 1 ? getRepoExecutionHostId(ownedProjects[0]) : undefined try { - if (existedBeforeRemoval) { - await get().removeProject(projectId) + if (ownedProjects.length > 0) { + await get().removeProject(projectId, { hostId: projectHostId }) } } catch (err) { console.error('Failed to remove contained project:', err) } - const stillExists = get().repos.some((repo) => repo.id === projectId) + const stillExists = findOwnedProjects(projectId).length > 0 if (stillExists) { failedProjectRemovals.push({ projectId, From cbea7530b47c540f3920a658944509b7918230f0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:22:07 -0700 Subject: [PATCH 12/27] build(runtime): gate new Electron imports reachable from the Orca runtime (#15919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build(runtime): gate new Electron imports reachable from the Orca runtime The runtime is meant to become host-agnostic so it can also run on plain Node, but nothing enforced that. `orca-runtime.ts` reaches dozens of modules that import `electron`, and the count grows silently: the import that breaks portability is usually several hops away, so no reviewer sees the edge. Add a reachability ratchet, modelled on the existing max-lines one. It bundles the runtime and its RPC server with esbuild, reads the metafile for every module importing `electron`, and diffs that against a checked-in baseline. A new module fails; a removed one forces the baseline to tighten. The list may only shrink. A per-file lint rule cannot do this — the point is precisely the transitive edges — so this runs as a build gate in `pnpm lint`. Baseline starts at 36, down from 50 before the SecretStore and AppEnvironment ports landed, which is the migration made measurable. Verified: gate passes clean, fails with an actionable message when an `electron` import is added to a runtime module, and passes again when reverted. * fix(runtime-ratchet): resolve paths from the script, not the caller's cwd Run from anywhere but the repo root, the gate died with an unhandled ENOENT stack instead of a usable message. It failed closed, so it was never unsafe — just undebuggable. Anchor ROOT to import.meta.dirname and pass absWorkingDir to esbuild so metafile keys stay repo-relative. * ci(runtime-ratchet): actually run the gate in CI The ratchet was wired into the `lint` npm script, but CI's static-analysis job runs the individual checks rather than `pnpm lint`, so the gate would never have fired on a PR — it would have looked enforced while enforcing nothing. Runs on ubuntu-latest alongside the max-lines ratchet, so the checked-in baseline is only ever produced by one platform. * fix(runtime-ratchet): mark native addons external so CI can run the gate ssh2's optional cpu-features dep points at a prebuilt .node that only exists where a build toolchain has run. Loading it made the gate pass locally and hard-fail on CI with 'Could not resolve ../build/Release/cpufeatures.node'. The gate only reads the import graph, never the addon, so resolve every .node to an external stub instead. Verified by hiding the local prebuild — which is CI's state — and re-running: still 36 entries, exit 0. * fix(runtime-ratchet): stop the gate failing open on Windows The entry guard compared import.meta.url against a `file://${process.argv[1]}` template. On Windows argv[1] is a native path (C:\repo\...) while import.meta.url is file:///C:/repo/..., so they never match: main() never ran and `pnpm lint` exited 0 on Windows without bundling, reading the baseline, or enforcing anything. Use pathToFileURL, which is the idiom check-max-lines-ratchet.mjs:225 already uses. CI runs this on ubuntu so enforcement was never actually lost, but a Windows developer got a green gate that checked nothing. --- .github/workflows/pr.yml | 3 + config/runtime-electron-baseline.txt | 41 +++++ .../check-runtime-electron-ratchet.mjs | 169 ++++++++++++++++++ .../check-runtime-electron-ratchet.test.mjs | 53 ++++++ package.json | 3 +- 5 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 config/runtime-electron-baseline.txt create mode 100644 config/scripts/check-runtime-electron-ratchet.mjs create mode 100644 config/scripts/check-runtime-electron-ratchet.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2539b251502..636aeed1b92 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -116,6 +116,9 @@ jobs: - name: Enforce max-lines ratchet run: pnpm run check:max-lines-ratchet + - name: Enforce runtime Electron-import ratchet + run: pnpm run check:runtime-electron-ratchet + - name: Verify bundled skill guides run: pnpm run verify:bundled-skill-guides diff --git a/config/runtime-electron-baseline.txt b/config/runtime-electron-baseline.txt new file mode 100644 index 00000000000..7d1341c7e5b --- /dev/null +++ b/config/runtime-electron-baseline.txt @@ -0,0 +1,41 @@ +# Modules reachable from the Orca runtime that import `electron`. +# Generated by config/scripts/check-runtime-electron-ratchet.mjs. +# 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/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs new file mode 100644 index 00000000000..5fe3fb18c1a --- /dev/null +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * Ratchet gate for Electron imports reachable from the Orca runtime. + * + * The runtime is meant to become host-agnostic so it can also run on plain Node + * (see docs/design/node-only-runtime-backend.html). Nothing enforces that today: + * `orca-runtime.ts` reaches ~50 modules that import `electron`, and the number + * silently grows whenever someone adds an import several hops away, because no + * single reviewer sees the transitive edge. + * + * This bundles the runtime with esbuild, reads the metafile for every module that + * imports `electron`, and compares that set to a checked-in baseline. A NEW module + * fails the build; a removed one must be dropped from the baseline. The baseline + * may only shrink, so the migration is measurable and cannot regress. + * + * This is a reachability check, not a lint rule: the point is precisely the edges + * that no per-file rule can see. + * + * Usage: node config/scripts/check-runtime-electron-ratchet.mjs [--write] + */ +import { build } from 'esbuild' +import { readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import process from 'node:process' + +// Why absolute, not cwd-relative: `pnpm lint` runs from the repo root but CI steps and +// editors do not always, and a cwd-relative miss surfaced as an unhandled ENOENT stack +// instead of a usable message. +const ROOT = path.join(import.meta.dirname, '..', '..') +const BASELINE_PATH = path.join(ROOT, 'config', 'runtime-electron-baseline.txt') + +// The two module graphs a Node backend would have to boot: the runtime service +// itself and the RPC server that fronts it. +const ENTRY_POINTS = [ + path.join(ROOT, 'src', 'main', 'runtime', 'orca-runtime.ts'), + path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts') +] + +// Native addons and electron cannot be bundled; externalising them is what the +// relay build already does (config/scripts/build-relay.mjs). +const EXTERNAL = [ + 'electron', + 'node-pty', + '@parcel/watcher', + 'better-sqlite3', + 'keytar', + 'fsevents', + 'cpu-features' +] + +/** + * Why: some optional native deps (ssh2's cpu-features) reference a prebuilt `.node` + * that only exists where a build toolchain has run. Resolving them made this gate + * pass on a developer machine and hard-fail on CI. Nothing here needs the addon — + * only the import graph — so mark every `.node` external instead. + */ +const externalNativeAddons = { + name: 'external-native-addons', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true })) + } +} + +export async function collectElectronImporters(entryPoints = ENTRY_POINTS) { + const result = await build({ + entryPoints, + bundle: true, + write: false, + // Why outdir with write:false: esbuild refuses multiple entry points without one, + // even though nothing is emitted — the metafile is all this reads. + outdir: path.join(ROOT, 'runtime-electron-ratchet-metafile-only'), + platform: 'node', + target: 'node20', + format: 'cjs', + external: EXTERNAL, + metafile: true, + absWorkingDir: ROOT, + logLevel: 'silent', + plugins: [externalNativeAddons] + }) + const importers = new Set() + for (const [file, info] of Object.entries(result.metafile.inputs)) { + for (const imported of info.imports ?? []) { + // Subpaths (electron/main) are as unavailable under plain Node as the bare module. + if (imported.path === 'electron' || imported.path.startsWith('electron/')) { + importers.add(path.relative(ROOT, path.resolve(ROOT, file)).split(path.sep).join('/')) + } + } + } + return [...importers].sort() +} + +export function readBaseline(text) { + return text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .sort() +} + +export function diffAgainstBaseline(current, baseline) { + const baselineSet = new Set(baseline) + const currentSet = new Set(current) + return { + added: current.filter((file) => !baselineSet.has(file)), + removed: baseline.filter((file) => !currentSet.has(file)) + } +} + +function renderBaseline(files) { + return [ + '# Modules reachable from the Orca runtime that import `electron`.', + '# Generated by config/scripts/check-runtime-electron-ratchet.mjs.', + '# 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/).', + '', + ...files + ].join('\n') +} + +async function main() { + const write = process.argv.includes('--write') + const current = await collectElectronImporters() + + if (write) { + writeFileSync(BASELINE_PATH, `${renderBaseline(current)}\n`) + console.log(`[runtime-electron-ratchet] wrote ${current.length} entries to ${BASELINE_PATH}`) + return + } + + const baseline = readBaseline(readFileSync(BASELINE_PATH, 'utf8')) + const { added, removed } = diffAgainstBaseline(current, baseline) + + if (added.length > 0) { + console.error( + `[runtime-electron-ratchet] ${added.length} new module(s) reachable from the Orca runtime now import electron: +${added.map((file) => ` + ${file}`).join('\n')} + +The runtime must stay bootable on plain Node. Put the Electron facility behind a port in +src/main/host/ and depend on the port, or move the code out of the runtime's import graph. +See docs/design/node-only-runtime-backend.html.` + ) + process.exitCode = 1 + return + } + + if (removed.length > 0) { + console.error( + `[runtime-electron-ratchet] ${removed.length} module(s) no longer import electron — nice. +Refresh the baseline so the gate keeps its new, tighter floor: +${removed.map((file) => ` - ${file}`).join('\n')} + + node config/scripts/check-runtime-electron-ratchet.mjs --write` + ) + process.exitCode = 1 + return + } + + console.log(`[runtime-electron-ratchet] ok — ${current.length} entries, unchanged.`) +} + +// Why pathToFileURL and not a `file://` template: on Windows process.argv[1] is a +// native path (C:\repo\...) while import.meta.url is file:///C:/repo/..., so the +// template never matches and the gate would exit 0 without checking anything — a +// lint gate that fails open. Same idiom as check-max-lines-ratchet.mjs:225. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main() +} diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs new file mode 100644 index 00000000000..ba75875c20f --- /dev/null +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { + collectElectronImporters, + diffAgainstBaseline, + readBaseline +} from './check-runtime-electron-ratchet.mjs' + +describe('readBaseline', () => { + it('drops comments and blank lines and sorts, so baseline formatting cannot cause a false diff', () => { + expect(readBaseline('# header\n\n b/second.ts \na/first.ts\n')).toEqual([ + 'a/first.ts', + 'b/second.ts' + ]) + }) +}) + +describe('diffAgainstBaseline', () => { + it('reports a module that started importing electron', () => { + expect(diffAgainstBaseline(['a.ts', 'b.ts'], ['a.ts'])).toEqual({ + added: ['b.ts'], + removed: [] + }) + }) + + it('reports a module that stopped, so the baseline is forced to tighten rather than drift', () => { + expect(diffAgainstBaseline(['a.ts'], ['a.ts', 'b.ts'])).toEqual({ + added: [], + removed: ['b.ts'] + }) + }) + + it('is quiet when the set is unchanged', () => { + expect(diffAgainstBaseline(['a.ts'], ['a.ts'])).toEqual({ added: [], removed: [] }) + }) +}) + +describe('the checked-in baseline', () => { + // Why real: the value of this gate is the transitive edges, which a fixture cannot model. + // If this is slow enough to hurt, it is still cheaper than shipping a runtime that + // cannot boot on Node. + it('matches what the runtime actually reaches today', async () => { + const current = await collectElectronImporters() + const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8')) + expect(diffAgainstBaseline(current, baseline)).toEqual({ added: [], removed: [] }) + }, 120_000) + + it('only lists modules under src/, so a node_modules path cannot pad the count', () => { + const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8')) + expect(baseline.length).toBeGreaterThan(0) + expect(baseline.filter((entry) => !entry.startsWith('src/'))).toEqual([]) + }) +}) diff --git a/package.json b/package.json index ee488485b7f..e89a56481f4 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", "audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings", "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings", @@ -29,6 +29,7 @@ "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", "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", "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", From 636f428c25c13cbad7c1844ad7049133f8ee43c9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:23:51 -0700 Subject: [PATCH 13/27] fix(rate-limits): stop showing Gemini failures as Antigravity "Refresh failed" (#15876) --- .../antigravity-usage-mirror.test.ts | 70 ++++++++++++ .../rate-limits/antigravity-usage-mirror.ts | 29 +++++ .../service-antigravity-usage.test.ts | 100 ++++++++++++++++++ src/main/rate-limits/service.ts | 8 +- 4 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 src/main/rate-limits/antigravity-usage-mirror.test.ts create mode 100644 src/main/rate-limits/antigravity-usage-mirror.ts create mode 100644 src/main/rate-limits/service-antigravity-usage.test.ts diff --git a/src/main/rate-limits/antigravity-usage-mirror.test.ts b/src/main/rate-limits/antigravity-usage-mirror.test.ts new file mode 100644 index 00000000000..84c92e1ad22 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-mirror.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { ProviderRateLimits, ProviderRateLimitStatus } from '../../shared/rate-limit-types' +import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' + +function geminiSnapshot( + status: ProviderRateLimitStatus, + error: string | null, + usedPercent: number | null = null +): ProviderRateLimits { + return { + provider: 'gemini', + session: + usedPercent === null + ? null + : { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + updatedAt: 1_700_000_000_000, + error, + status + } +} + +describe('deriveAntigravityRateLimits', () => { + it('mirrors a successful Gemini read as shared Code Assist quota', () => { + const antigravity = deriveAntigravityRateLimits(geminiSnapshot('ok', null, 42)) + + expect(antigravity.provider).toBe('antigravity') + expect(antigravity.status).toBe('ok') + expect(antigravity.session?.usedPercent).toBe(42) + expect(antigravity.error).toBeNull() + }) + + it('reports unavailable without quoting the Gemini failure', () => { + const antigravity = deriveAntigravityRateLimits( + geminiSnapshot('error', 'Gemini project ID not found') + ) + + expect(antigravity.provider).toBe('antigravity') + expect(antigravity.status).toBe('unavailable') + expect(antigravity.error).not.toContain('Gemini project ID not found') + expect(antigravity.error).toContain('Antigravity usage is not available') + expect(antigravity.session).toBeNull() + expect(antigravity.weekly).toBeNull() + }) + + it('does not blame a missing sign-in when the quota read itself failed', () => { + const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) + + // Why: the reported symptom is a connected sign-in whose Code Assist read failed. + expect(antigravity.error).toContain('could not be read right now') + expect(antigravity.error).not.toContain('sign-in is connected') + }) + + it('keeps the Gemini timestamp so activation freshness checks are not forced to refetch', () => { + const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) + + expect(antigravity.updatedAt).toBe(1_700_000_000_000) + }) + + it('points at the missing sign-in when the Gemini opt-in is off', () => { + const antigravity = deriveAntigravityRateLimits( + geminiSnapshot('unavailable', 'Gemini CLI OAuth is disabled in settings') + ) + + expect(antigravity.status).toBe('unavailable') + expect(antigravity.error).not.toContain('Gemini CLI OAuth is disabled in settings') + expect(antigravity.error).toContain('Antigravity usage is not available') + expect(antigravity.error).toContain('Gemini CLI sign-in is connected') + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-mirror.ts b/src/main/rate-limits/antigravity-usage-mirror.ts new file mode 100644 index 00000000000..3cea6decc11 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-mirror.ts @@ -0,0 +1,29 @@ +import type { ProviderRateLimits } from '../../shared/rate-limit-types' + +// Why: the Antigravity CLI keeps its token in the OS keyring, not in the files the Gemini +// fetcher reads, so Orca never actually queries Antigravity. Only a *successful* Gemini read +// describes shared Google Code Assist quota; republishing a Gemini failure under the +// Antigravity provider id surfaced "Refresh failed" for a request that was never attempted. +const ANTIGRAVITY_NO_SIGN_IN_REASON = + 'Antigravity usage is not available. Orca can only show shared Google Code Assist quota while a Gemini CLI sign-in is connected.' +// Why: a Gemini `error` means the sign-in exists and the quota read failed, so blaming a missing sign-in would misdirect the user. +const ANTIGRAVITY_QUOTA_UNREADABLE_REASON = + 'Antigravity usage is not available. Orca reads it from the shared Google Code Assist quota, which could not be read right now.' + +export function deriveAntigravityRateLimits(gemini: ProviderRateLimits): ProviderRateLimits { + if (gemini.status === 'ok') { + return { ...gemini, provider: 'antigravity' } + } + return { + provider: 'antigravity', + session: null, + weekly: null, + // Why: reuse the Gemini timestamp so activation freshness checks don't force a refetch every cycle. + updatedAt: gemini.updatedAt, + error: + gemini.status === 'unavailable' + ? ANTIGRAVITY_NO_SIGN_IN_REASON + : ANTIGRAVITY_QUOTA_UNREADABLE_REASON, + status: 'unavailable' + } +} diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts new file mode 100644 index 00000000000..b617d909944 --- /dev/null +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RateLimitService } from './service' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { fetchCodexRateLimits } from './codex-fetcher' +import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { + errorProvider, + okProvider, + resetRateLimitProviderMocks +} from './rate-limit-service-test-harness' + +vi.mock('./claude-fetcher', () => ({ + fetchClaudeRateLimits: vi.fn(), + fetchManagedAccountUsage: vi.fn() +})) + +vi.mock('./codex-fetcher', () => ({ + consumeCodexRateLimitResetCredit: vi.fn(), + fetchCodexRateLimits: vi.fn() +})) + +vi.mock('./gemini-usage-fetcher', () => ({ + fetchGeminiRateLimits: vi.fn() +})) + +vi.mock('./kimi-fetcher', () => ({ + fetchKimiRateLimits: vi.fn() +})) + +vi.mock('./opencode-go-usage-fetcher', () => ({ + fetchOpenCodeGoRateLimits: vi.fn() +})) + +vi.mock('./minimax-fetcher', () => ({ + fetchMiniMaxRateLimits: vi.fn() +})) + +vi.mock('./grok-fetcher', () => ({ + fetchGrokRateLimits: vi.fn() +})) + +vi.mock('./grok-auth', () => ({ + readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + +vi.mock('../minimax/minimax-cookie-store', () => ({ + hasMiniMaxSessionCookie: vi.fn(() => false) +})) + +describe('RateLimitService Antigravity usage', () => { + beforeEach(() => { + resetRateLimitProviderMocks() + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) + }) + + it('does not republish a Gemini failure as an Antigravity refresh failure', async () => { + vi.mocked(fetchGeminiRateLimits).mockResolvedValue( + errorProvider('gemini', 'Gemini project ID not found') + ) + const service = new RateLimitService() + + await service.refresh() + + const state = service.getState() + expect(state.antigravity?.status).toBe('unavailable') + expect(state.antigravity?.error).not.toContain('Gemini project ID not found') + expect(state.antigravity?.session).toBeNull() + // Why: the real Gemini failure must still surface under its own provider. + expect(state.gemini?.status).toBe('error') + expect(state.gemini?.error).toBe('Gemini project ID not found') + }) + + it('keeps mirroring a successful Gemini read under the Antigravity provider', async () => { + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + const service = new RateLimitService() + + await service.refresh() + + const state = service.getState() + expect(state.antigravity?.status).toBe('ok') + expect(state.antigravity?.provider).toBe('antigravity') + expect(state.antigravity?.session?.usedPercent).toBe(42) + }) + + it('never leaves a cached Antigravity snapshot in the error retry lane', async () => { + vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 42, Date.now())) + const service = new RateLimitService() + await service.refresh() + + vi.mocked(fetchGeminiRateLimits).mockResolvedValue( + errorProvider('gemini', 'Token refresh failed') + ) + await service.refresh() + + // Why: stale-retention would otherwise show Gemini numbers as "Refresh failed" Antigravity usage. + expect(service.getState().antigravity?.status).toBe('unavailable') + expect(service.getState().antigravity?.session).toBeNull() + }) +}) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 02bd1930bb6..d6f291d6b38 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -20,6 +20,7 @@ import { type NormalizedClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' import { fetchKimiRateLimits } from './kimi-fetcher' import type { KimiHomeResolution } from '../kimi/kimi-runtime-home' import { fetchGrokRateLimits } from './grok-fetcher' @@ -1766,11 +1767,8 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) - // Why: Antigravity shares Gemini credentials today; mirror the Gemini snapshot so its status-bar UI gets a real lifecycle instead of null. - const antigravity: ProviderRateLimits = { - ...gemini, - provider: 'antigravity' - } + // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. + const antigravity = deriveAntigravityRateLimits(gemini) const opencodeGo = opencodeGoResult.status === 'fulfilled' From 15fd723bc474909d192cccff30ff5192a95c85e0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:24:18 -0700 Subject: [PATCH 14/27] fix(terminal): drop conda's orphaned CONDA_SHLVL sentinel (#15885) --- .../pty-subprocess-env-inheritance.test.ts | 35 +++++ src/main/daemon/pty-subprocess.ts | 3 + src/main/hooks.test.ts | 43 ++++++ src/main/hooks.ts | 9 +- .../local-pty-provider-spawn-env.test.ts | 65 +++++++++ src/main/providers/local-pty-provider.ts | 3 + src/main/pty/conda-activation-env.test.ts | 124 ++++++++++++++++++ src/main/pty/conda-activation-env.ts | 71 ++++++++++ .../pty-handler-spawn-environment.test.ts | 78 +++++++++++ src/relay/pty-handler.ts | 4 + 10 files changed, 431 insertions(+), 4 deletions(-) create mode 100644 src/main/pty/conda-activation-env.test.ts create mode 100644 src/main/pty/conda-activation-env.ts diff --git a/src/main/daemon/pty-subprocess-env-inheritance.test.ts b/src/main/daemon/pty-subprocess-env-inheritance.test.ts index 8e2c43f62b8..53846b5071b 100644 --- a/src/main/daemon/pty-subprocess-env-inheritance.test.ts +++ b/src/main/daemon/pty-subprocess-env-inheritance.test.ts @@ -312,6 +312,41 @@ describe('createPtySubprocess', () => { expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined() }) + it('does not forward a half-activated conda env from the daemon process env', async () => { + // Why here as well as the main process: the daemon fork composes its own + // inherited env, which main never sees, so it is the default terminal's + // only chance to drop a CONDA_SHLVL sentinel left without a prefix (#14195). + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const saved = { + CONDA_SHLVL: process.env.CONDA_SHLVL, + CONDA_PREFIX: process.env.CONDA_PREFIX, + CONDA_DEFAULT_ENV: process.env.CONDA_DEFAULT_ENV, + CONDA_EXE: process.env.CONDA_EXE + } + delete process.env.CONDA_PREFIX + process.env.CONDA_SHLVL = '1' + process.env.CONDA_DEFAULT_ENV = 'base' + process.env.CONDA_EXE = '/opt/miniconda3/bin/conda' + + try { + await createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.CONDA_SHLVL).toBeUndefined() + expect(env.CONDA_DEFAULT_ENV).toBeUndefined() + expect(env.CONDA_EXE).toBe('/opt/miniconda3/bin/conda') + }) + it('does not inherit legacy attribution state from a pre-upgrade daemon', async () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 47161208721..2f983e4f7c5 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -43,6 +43,7 @@ import { dropInheritedOrcaHistFile } from '../worktree-history-file-path' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir' +import { dropIncoherentCondaActivationEnv } from '../pty/conda-activation-env' import { resolvePathEnvKey } from '../pty/windows-environment-path' import { parseWslPath } from '../wsl' import { addWslEnvKeys } from '../wsl-env' @@ -902,6 +903,8 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise { } }) + it('does not run setup scripts with a half-activated conda env', async () => { + // Why: setup scripts source conda exactly like a shell rc does, so the + // orphaned CONDA_SHLVL sentinel surfaces as an opaque hook failure (#14195). + let capturedEnv: Record | undefined + execMock.mockImplementation((_script, options, callback) => { + capturedEnv = (options as { env: Record }).env + callback?.(null, '', '') + return {} as never + }) + + const fs = await import('node:fs') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo hello\n') + + const saved = { + CONDA_SHLVL: process.env.CONDA_SHLVL, + CONDA_PREFIX: process.env.CONDA_PREFIX, + CONDA_DEFAULT_ENV: process.env.CONDA_DEFAULT_ENV, + CONDA_EXE: process.env.CONDA_EXE + } + delete process.env.CONDA_PREFIX + process.env.CONDA_SHLVL = '1' + process.env.CONDA_DEFAULT_ENV = 'base' + process.env.CONDA_EXE = '/opt/miniconda3/bin/conda' + + try { + const { runHook } = await import('./hooks') + await runHook('setup', '/repo/worktree', makeRepo()) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + expect(capturedEnv?.CONDA_SHLVL).toBeUndefined() + expect(capturedEnv?.CONDA_DEFAULT_ENV).toBeUndefined() + expect(capturedEnv?.CONDA_EXE).toBe('/opt/miniconda3/bin/conda') + }) + it('keeps bash as the hook shell on non-Windows platforms', async () => { execMock.mockImplementation((_script, _options, callback) => { callback?.(null, '', '') diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 4d7a611d227..7778132e910 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -8,6 +8,7 @@ import { getHookRuntimeTarget, getHookWslContext } from './hook-runtime-target' import { getSetupEnvVars } from './setup-hook-env-vars' import { iterateLfScriptLines } from './setup-runner-script-text' import { promptGuardShellEnv } from './git/runner' +import { dropIncoherentCondaActivationEnv } from './pty/conda-activation-env' import { toLinuxPath } from './wsl' import { runWslProcess } from './wsl/wsl-runner' import type { HookRuntimeTarget } from './hook-runtime-target' @@ -186,6 +187,9 @@ export function runHook( }) } + const shellHookEnv: NodeJS.ProcessEnv = { ...process.env, ...getSetupEnvVars(repo, cwd) } + dropIncoherentCondaActivationEnv(shellHookEnv) + return new Promise((resolve) => { exec( script, @@ -194,10 +198,7 @@ export function runHook( timeout: HOOK_TIMEOUT, shell: getHookShell(), // Why: hooks run unattended; block Git Credential Manager's interactive prompt while keeping cached auth (issue #7652). - env: promptGuardShellEnv({ - ...process.env, - ...getSetupEnvVars(repo, cwd) - }) + env: promptGuardShellEnv(shellHookEnv) }, (error, stdout, stderr) => { if (error) { diff --git a/src/main/providers/local-pty-provider-spawn-env.test.ts b/src/main/providers/local-pty-provider-spawn-env.test.ts index 3b504a39242..f12de81360f 100644 --- a/src/main/providers/local-pty-provider-spawn-env.test.ts +++ b/src/main/providers/local-pty-provider-spawn-env.test.ts @@ -435,6 +435,71 @@ describe('LocalPtyProvider', () => { expect(env.LD_LIBRARY_PATH).toBe('/opt/audio/lib') }) + it('does not forward a half-activated conda env into the shell', async () => { + // Why: CONDA_SHLVL asserts CONDA_PREFIX exists; forwarding the sentinel + // alone makes the user's rc-file conda hook raise a TypeError (#14195). + const saved = { + CONDA_SHLVL: process.env.CONDA_SHLVL, + CONDA_PREFIX: process.env.CONDA_PREFIX, + CONDA_DEFAULT_ENV: process.env.CONDA_DEFAULT_ENV, + CONDA_PROMPT_MODIFIER: process.env.CONDA_PROMPT_MODIFIER, + CONDA_EXE: process.env.CONDA_EXE + } + delete process.env.CONDA_PREFIX + process.env.CONDA_SHLVL = '1' + process.env.CONDA_DEFAULT_ENV = 'base' + process.env.CONDA_PROMPT_MODIFIER = '(base) ' + process.env.CONDA_EXE = '/opt/miniconda3/bin/conda' + + try { + await provider.spawn({ cols: 80, rows: 24 }) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.CONDA_SHLVL).toBeUndefined() + expect(env.CONDA_DEFAULT_ENV).toBeUndefined() + expect(env.CONDA_PROMPT_MODIFIER).toBeUndefined() + expect(env.CONDA_EXE).toBe('/opt/miniconda3/bin/conda') + }) + + it('drops the conda sentinel when the client asks to delete CONDA_PREFIX', async () => { + // Why: envToDelete runs after the inherited-env scrub, so the coherence + // pass must run last or it re-creates the exact broken pair it prevents. + const saved = { + CONDA_SHLVL: process.env.CONDA_SHLVL, + CONDA_PREFIX: process.env.CONDA_PREFIX, + CONDA_DEFAULT_ENV: process.env.CONDA_DEFAULT_ENV + } + process.env.CONDA_SHLVL = '1' + process.env.CONDA_PREFIX = '/opt/miniconda3' + process.env.CONDA_DEFAULT_ENV = 'base' + + try { + await provider.spawn({ cols: 80, rows: 24, envToDelete: ['CONDA_PREFIX'] }) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.CONDA_PREFIX).toBeUndefined() + expect(env.CONDA_SHLVL).toBeUndefined() + expect(env.CONDA_DEFAULT_ENV).toBeUndefined() + }) + it('uses shell wrapper when MiMo home must survive shell startup', async () => { provider.configure({ buildSpawnEnv: (_id, env) => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index f0954fa123a..6678e75c288 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -44,6 +44,7 @@ import { removeInheritedNoColor } from '../pty/terminal-color-env' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir' +import { dropIncoherentCondaActivationEnv } from '../pty/conda-activation-env' import { SessionNotFoundError } from '../daemon/daemon-errors' import { resolvePathEnvKey } from '../pty/windows-environment-path' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' @@ -835,6 +836,8 @@ export class LocalPtyProvider implements IPtyProvider { ) // Why: raw requested PATH promotion runs after the host-env scrub. stripLegacyTerminalShimEnv(finalEnv, process.platform) + // Why after every deletion pass: an envToDelete of CONDA_PREFIX must not leave the sentinel behind. + dropIncoherentCondaActivationEnv(finalEnv, process.platform) // Why: worktree-scoped HISTFILE — without it worktrees share one global history (terminal-history-scope-design §7–§10). const worktreeId = args.worktreeId diff --git a/src/main/pty/conda-activation-env.test.ts b/src/main/pty/conda-activation-env.test.ts new file mode 100644 index 00000000000..a7ba9d72b2d --- /dev/null +++ b/src/main/pty/conda-activation-env.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { dropIncoherentCondaActivationEnv } from './conda-activation-env' + +describe('dropIncoherentCondaActivationEnv', () => { + it('drops the activation group when the sentinel outlived CONDA_PREFIX', () => { + // Why: this exact shape is what makes `conda activate` raise + // "TypeError: expected str, bytes or os.PathLike object, not NoneType" (#14195). + const env: Record = { + CONDA_SHLVL: '1', + CONDA_DEFAULT_ENV: 'base', + CONDA_PROMPT_MODIFIER: '(base) ', + CONDA_PREFIX_1: '/opt/miniconda3', + CONDA_STACKED_2: 'true', + CONDA_EXE: '/opt/miniconda3/bin/conda', + CONDA_ROOT: '/opt/miniconda3', + CONDA_PYTHON_EXE: '/opt/miniconda3/bin/python', + CONDA_BAT: 'C:\\miniconda3\\condabin\\conda.bat', + _CE_CONDA: '', + _CE_M: '', + PATH: '/usr/bin' + } + + dropIncoherentCondaActivationEnv(env, 'linux') + + expect(env.CONDA_SHLVL).toBeUndefined() + expect(env.CONDA_DEFAULT_ENV).toBeUndefined() + expect(env.CONDA_PROMPT_MODIFIER).toBeUndefined() + expect(env.CONDA_PREFIX_1).toBeUndefined() + expect(env.CONDA_STACKED_2).toBeUndefined() + // Discovery vars are installation state, not activation state: keeping them + // is what lets the shell's conda hook re-activate from a clean slate. + expect(env.CONDA_EXE).toBe('/opt/miniconda3/bin/conda') + expect(env.CONDA_ROOT).toBe('/opt/miniconda3') + expect(env.CONDA_PYTHON_EXE).toBe('/opt/miniconda3/bin/python') + expect(env.CONDA_BAT).toBe('C:\\miniconda3\\condabin\\conda.bat') + expect(env._CE_CONDA).toBe('') + expect(env._CE_M).toBe('') + expect(env.PATH).toBe('/usr/bin') + }) + + it('leaves a genuinely activated environment untouched', () => { + const env: Record = { + CONDA_SHLVL: '2', + CONDA_PREFIX: '/opt/miniconda3/envs/ml', + CONDA_PREFIX_1: '/opt/miniconda3', + CONDA_DEFAULT_ENV: 'ml', + CONDA_PROMPT_MODIFIER: '(ml) ' + } + + dropIncoherentCondaActivationEnv(env, 'darwin') + + expect(env).toEqual({ + CONDA_SHLVL: '2', + CONDA_PREFIX: '/opt/miniconda3/envs/ml', + CONDA_PREFIX_1: '/opt/miniconda3', + CONDA_DEFAULT_ENV: 'ml', + CONDA_PROMPT_MODIFIER: '(ml) ' + }) + }) + + it("leaves conda's own CONDA_SHLVL=0 hook-ran-nothing state alone", () => { + const env: Record = { CONDA_SHLVL: '0', CONDA_EXE: '/opt/conda/bin/conda' } + + dropIncoherentCondaActivationEnv(env, 'linux') + + expect(env).toEqual({ CONDA_SHLVL: '0', CONDA_EXE: '/opt/conda/bin/conda' }) + }) + + it('treats an empty CONDA_PREFIX as missing', () => { + const env: Record = { + CONDA_SHLVL: '1', + CONDA_PREFIX: '', + CONDA_DEFAULT_ENV: 'base' + } + + dropIncoherentCondaActivationEnv(env, 'linux') + + expect(env).toEqual({}) + }) + + it('matches case-variant keys on win32 only', () => { + const windowsEnv: Record = { + Conda_Shlvl: '1', + CONDA_Default_Env: 'base', + Conda_Prefix_1: 'C:\\miniconda3', + CONDA_EXE: 'C:\\miniconda3\\Scripts\\conda.exe' + } + + dropIncoherentCondaActivationEnv(windowsEnv, 'win32') + + expect(windowsEnv).toEqual({ CONDA_EXE: 'C:\\miniconda3\\Scripts\\conda.exe' }) + + // Why the same input is untouched on POSIX: a lowercase name is a different variable there. + const posixEnv: Record = { + Conda_Shlvl: '1', + CONDA_Default_Env: 'base', + Conda_Prefix_1: '/opt/miniconda3' + } + + dropIncoherentCondaActivationEnv(posixEnv, 'linux') + + expect(posixEnv).toEqual({ + Conda_Shlvl: '1', + CONDA_Default_Env: 'base', + Conda_Prefix_1: '/opt/miniconda3' + }) + }) + + it('leaves an environment with no conda state alone', () => { + const env: Record = { PATH: '/usr/bin', HOME: '/home/dev' } + + dropIncoherentCondaActivationEnv(env, 'linux') + + expect(env).toEqual({ PATH: '/usr/bin', HOME: '/home/dev' }) + }) + + it('ignores a non-numeric sentinel rather than guessing', () => { + const env: Record = { CONDA_SHLVL: 'weird', CONDA_DEFAULT_ENV: 'base' } + + dropIncoherentCondaActivationEnv(env, 'linux') + + expect(env).toEqual({ CONDA_SHLVL: 'weird', CONDA_DEFAULT_ENV: 'base' }) + }) +}) diff --git a/src/main/pty/conda-activation-env.ts b/src/main/pty/conda-activation-env.ts new file mode 100644 index 00000000000..f66b6ef7637 --- /dev/null +++ b/src/main/pty/conda-activation-env.ts @@ -0,0 +1,71 @@ +// Conda's activation state is a coupled group, not independent keys: CONDA_SHLVL +// is a sentinel asserting CONDA_PREFIX exists. Every other PTY env scrubber here +// treats keys one at a time, so a half-activated host env (a client envToDelete, +// the wsl.exe/WSLENV boundary, a relay launched from a half-activated login +// shell, a launchd/PAM env reshuffle) is forwarded verbatim and `conda activate` +// dies in `_get_deactivate_scripts(None)` with a TypeError (#14195). + +/** Activation state: meaningful only while CONDA_PREFIX names an active env. */ +const ACTIVATION_STATE_KEYS = [ + 'CONDA_SHLVL', + 'CONDA_PREFIX', + 'CONDA_DEFAULT_ENV', + 'CONDA_PROMPT_MODIFIER' +] as const + +/** Stack depth is unbounded (`CONDA_PREFIX_1`, `CONDA_STACKED_2`, ...). */ +const POSIX_STACK_KEY = /^(?:CONDA_PREFIX|CONDA_STACKED)_\d+$/ +const WINDOWS_STACK_KEY = /^(?:CONDA_PREFIX|CONDA_STACKED)_\d+$/i + +/** + * Drops conda's activation-state group when the sentinel survived but the prefix + * did not, so the shell's conda hook starts from a clean slate instead of + * crashing. Installation/discovery vars (CONDA_EXE, CONDA_ROOT, CONDA_BAT, + * CONDA_PYTHON_EXE, _CE_CONDA, _CE_M) are preserved so `conda` still resolves. + * + * Why delete-only, never synthesize: the reverse shape (prefix without sentinel) + * does not crash — conda just reads it as inactive — and Orca cannot verify the + * prefix directory exists on the executing host (it routinely does not for WSL + * and SSH), so inventing activation state would be worse than leaving it. + */ +export function dropIncoherentCondaActivationEnv( + env: Record, + platform: NodeJS.Platform = process.platform +): void { + const windows = platform === 'win32' + // Why: PTY spawn is a hot path; one exact-key miss covers every non-conda posix env. + if (!windows && env.CONDA_SHLVL === undefined) { + return + } + const keys = Object.keys(env) + // Why case-insensitive on win32 only: Windows env names are case-insensitive, so a + // conda install or PowerShell profile may spell them `Conda_Shlvl` (same reasoning as + // stripLegacyTerminalShimEnv). On POSIX a lowercase name is a different variable. + const findKey = (name: string): string | undefined => { + if (!windows) { + return env[name] === undefined ? undefined : name + } + const lowered = name.toLowerCase() + return keys.find((key) => key.toLowerCase() === lowered) + } + + const shlvlKey = findKey('CONDA_SHLVL') + // CONDA_SHLVL=0 (or unparsable) is conda's own hook-ran-nothing-active state. + if (shlvlKey === undefined || !(Number.parseInt(env[shlvlKey] ?? '', 10) > 0)) { + return + } + const prefixKey = findKey('CONDA_PREFIX') + if (prefixKey !== undefined && env[prefixKey] !== '') { + return + } + + const stateKeys = new Set( + windows ? ACTIVATION_STATE_KEYS.map((key) => key.toLowerCase()) : ACTIVATION_STATE_KEYS + ) + const stackKey = windows ? WINDOWS_STACK_KEY : POSIX_STACK_KEY + for (const key of keys) { + if (stateKeys.has(windows ? key.toLowerCase() : key) || stackKey.test(key)) { + delete env[key] + } + } +} diff --git a/src/relay/pty-handler-spawn-environment.test.ts b/src/relay/pty-handler-spawn-environment.test.ts index 0a1b492a1ea..39c5a207a48 100644 --- a/src/relay/pty-handler-spawn-environment.test.ts +++ b/src/relay/pty-handler-spawn-environment.test.ts @@ -89,6 +89,84 @@ describe('PtyHandler', () => { expect(spawnOptions.env.PATH).toBe(expectedEnv.PATH) }) + describe('half-activated conda env (#14195)', () => { + const CONDA_KEYS = [ + 'CONDA_SHLVL', + 'CONDA_PREFIX', + 'CONDA_DEFAULT_ENV', + 'CONDA_PROMPT_MODIFIER', + 'CONDA_EXE' + ] as const + + const withRelayCondaEnv = async ( + relayEnv: Partial>, + spawnParams: Record + ): Promise> => { + const saved = Object.fromEntries(CONDA_KEYS.map((key) => [key, process.env[key]])) + try { + for (const key of CONDA_KEYS) { + delete process.env[key] + } + Object.assign(process.env, relayEnv) + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24, ...spawnParams }) + } finally { + for (const key of CONDA_KEYS) { + const value = saved[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + return (mockPtySpawn.mock.calls.at(-1)![2] as { env: Record }).env + } + + it('drops the sentinel the remote relay inherited without a prefix', async () => { + // Why the relay owns this: the execution host composes its own env, so a + // remote relay launched from a half-activated login shell would otherwise + // hand the broken pair to every SSH terminal. + const env = await withRelayCondaEnv( + { + CONDA_SHLVL: '1', + CONDA_DEFAULT_ENV: 'base', + CONDA_PROMPT_MODIFIER: '(base) ', + CONDA_EXE: '/opt/miniconda3/bin/conda' + }, + {} + ) + + expect(env.CONDA_SHLVL).toBeUndefined() + expect(env.CONDA_DEFAULT_ENV).toBeUndefined() + expect(env.CONDA_PROMPT_MODIFIER).toBeUndefined() + expect(env.CONDA_EXE).toBe('/opt/miniconda3/bin/conda') + }) + + it('keeps activation state a client explicitly repaired', async () => { + const env = await withRelayCondaEnv( + { CONDA_SHLVL: '1', CONDA_DEFAULT_ENV: 'base' }, + { env: { CONDA_PREFIX: '/opt/miniconda3' } } + ) + + expect(env.CONDA_PREFIX).toBe('/opt/miniconda3') + expect(env.CONDA_SHLVL).toBe('1') + expect(env.CONDA_DEFAULT_ENV).toBe('base') + }) + + it('drops the sentinel when the client deletes CONDA_PREFIX', async () => { + // Why: the relay's other inherited-env scrubbers run BEFORE envToDelete, + // so anchoring the coherence pass beside them would re-create the crash. + const env = await withRelayCondaEnv( + { CONDA_SHLVL: '1', CONDA_PREFIX: '/opt/miniconda3', CONDA_DEFAULT_ENV: 'base' }, + { envToDelete: ['CONDA_PREFIX'] } + ) + + expect(env.CONDA_PREFIX).toBeUndefined() + expect(env.CONDA_SHLVL).toBeUndefined() + expect(env.CONDA_DEFAULT_ENV).toBeUndefined() + }) + }) + it('does not inherit legacy attribution state from the relay process', async () => { const keys = ['ORCA_ENABLE_GIT_ATTRIBUTION', 'ORCA_ATTRIBUTION_SHIM_DIR', 'PATH'] as const const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]])) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index f9392ad74d3..a9fd21a4fe2 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -50,6 +50,7 @@ import type { TuiAgent } from '../shared/tui-agent' import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups' import { stripInheritedBuildModeEnv } from '../main/pty/build-mode-env' import { stripLegacyTerminalShimEnv } from '../main/pty/legacy-terminal-shim-dir' +import { dropIncoherentCondaActivationEnv } from '../main/pty/conda-activation-env' import { dropInheritedOrcaFishHistory } from '../main/fish-history-session' import { dropInheritedOrcaHistFile } from '../main/worktree-history-file-path' import { @@ -694,6 +695,9 @@ export class PtyHandler { if (!result.TERM) { result.TERM = 'xterm-256color' } + // Why last, not beside the scrubbers above: the relay runs those BEFORE envToDelete, + // so an envToDelete of CONDA_PREFIX would otherwise re-create the broken pair. + dropIncoherentCondaActivationEnv(result, process.platform) expandWindowsPathEnvironmentVariables(result) return result } From 7ce11dcf558ee3463c2881db0c7183061cd6f329 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:24:44 -0700 Subject: [PATCH 15/27] fix(agent-resume): restore Copilot sessions after restart (#15879) Co-authored-by: Melih --- .../server-copilot-normalization.test.ts | 27 ++++++++++++ .../src/lib/agent-hibernation-planner.test.ts | 10 +++++ .../src/lib/ai-vault-resume-command.test.ts | 38 ++++++++++++++++ src/shared/agent-resume-argv-drop.test.ts | 21 +++++++++ src/shared/agent-session-resume.test.ts | 15 ++++++- src/shared/agent-session-resume.ts | 15 ++++++- .../tui-agent-startup-copilot-resume.test.ts | 43 +++++++++++++++++++ 7 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 src/shared/tui-agent-startup-copilot-resume.test.ts diff --git a/src/main/agent-hooks/server-copilot-normalization.test.ts b/src/main/agent-hooks/server-copilot-normalization.test.ts index ccf7de07056..af8107debd6 100644 --- a/src/main/agent-hooks/server-copilot-normalization.test.ts +++ b/src/main/agent-hooks/server-copilot-normalization.test.ts @@ -51,6 +51,33 @@ describe('Copilot hook normalization', () => { expect(result?.payload.prompt).toBe('camel event') }) + it('captures the Copilot provider session from SessionStart and Stop', () => { + const sessionId = '940237d9-c712-48e8-bca1-fd75fc4a8d4b' + const started = _internals.normalizeHookPayload( + 'copilot', + buildBody({ hook_event_name: 'SessionStart', session_id: sessionId }), + 'production' + ) + expect(started?.providerSession).toEqual({ key: 'session_id', id: sessionId }) + + const stopped = _internals.normalizeHookPayload( + 'copilot', + buildBody({ hook_event_name: 'Stop', session_id: sessionId }), + 'production' + ) + expect(stopped?.payload.state).toBe('done') + expect(stopped?.providerSession).toEqual({ key: 'session_id', id: sessionId }) + }) + + it('captures the Copilot provider session from a camelCase sessionId', () => { + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ hookEventName: 'agentStop', sessionId: 'copilot-camel' }), + 'production' + ) + expect(result?.providerSession).toEqual({ key: 'session_id', id: 'copilot-camel' }) + }) + it('infers Copilot user prompt payloads that omit hook_event_name', () => { const result = _internals.normalizeHookPayload( 'copilot', diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index f825fc9758d..632332f3995 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -134,6 +134,16 @@ describe('agent sleep planner', () => { ).toEqual([]) }) + it('treats an idle done copilot pane as a hibernation candidate', () => { + const copilot = entry({ + agentType: 'copilot', + providerSession: { key: 'session_id', id: '940237d9-c712-48e8-bca1-fd75fc4a8d4b' } + }) + expect( + plannedPaneKeys(snapshot({ agentStatusByPaneKey: { [copilot.paneKey]: copilot } })) + ).toEqual([copilot.paneKey]) + }) + it('blocks done panes until their live subagent roster clears', () => { const withIdleTeammate = entry({ subagents: [ diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 57dd3499bc5..e23968adac0 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -650,6 +650,44 @@ describe('ai vault resume command runtime', () => { ).toBe("$env:CODEX_HOME='C:/Users/alice/.codex'; my-codex 'resume' 'session one'") }) + it('applies the user default args to local Copilot AI Vault resumes', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.settings = { ...state.settings, agentDefaultArgs: { copilot: '--yolo' } } as never + + expect( + buildQueuedAiVaultResumeCommand({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'copilot', + sessionId: '940237d9-c712-48e8-bca1-fd75fc4a8d4b', + cwd: '/home/alice/repo', + codexHome: null + } + }) + ).toBe("copilot '--yolo' '--resume=940237d9-c712-48e8-bca1-fd75fc4a8d4b'") + }) + + it('keeps the scanner resume command for remote Copilot sessions', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildQueuedAiVaultResumeCommand({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'copilot', + sessionId: '940237d9-c712-48e8-bca1-fd75fc4a8d4b', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'ssh:dev-box', + resumeCommand: "copilot --resume='940237d9-c712-48e8-bca1-fd75fc4a8d4b'" + } + }) + ).toBe("copilot --resume='940237d9-c712-48e8-bca1-fd75fc4a8d4b'") + }) + it('ignores a stored resume command for local-host sessions', () => { const state = makeState({ worktreePath: '/home/alice/repo' }) state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never diff --git a/src/shared/agent-resume-argv-drop.test.ts b/src/shared/agent-resume-argv-drop.test.ts index aec709e411d..a1703274d9a 100644 --- a/src/shared/agent-resume-argv-drop.test.ts +++ b/src/shared/agent-resume-argv-drop.test.ts @@ -56,6 +56,27 @@ describe('dropAgentResumeArgvFromCommand', () => { ).toEqual({ status: 'dropped', command: 'codex' }) }) + it('drops the joined Copilot resume token and keeps the user args', () => { + const providerSession = { + key: 'session_id' as const, + id: '940237d9-c712-48e8-bca1-fd75fc4a8d4b' + } + const plan = buildAgentResumeStartupPlan({ + agent: 'copilot', + providerSession, + cmdOverrides: {}, + agentArgs: '--yolo', + platform: 'darwin' + }) + expect( + dropAgentResumeArgvFromCommand({ + command: plan?.launchCommand ?? '', + agent: 'copilot', + providerSession + }) + ).toEqual({ status: 'dropped', command: "copilot '--yolo'" }) + }) + it('reports absent when the command never carried the resume locator', () => { expect( dropAgentResumeArgvFromCommand({ diff --git a/src/shared/agent-session-resume.test.ts b/src/shared/agent-session-resume.test.ts index 393d97eeeee..0fffb9728a9 100644 --- a/src/shared/agent-session-resume.test.ts +++ b/src/shared/agent-session-resume.test.ts @@ -20,6 +20,10 @@ describe('agent session resume metadata', () => { expect(isResumableTuiAgent('prime-agent')).toBe(true) }) + it('treats copilot as a resumable TUI agent', () => { + expect(isResumableTuiAgent('copilot')).toBe(true) + }) + it.each([ ['claude', { session_id: 'claude-session' }, { key: 'session_id', id: 'claude-session' }], ['codex', { session_id: 'codex-session' }, { key: 'session_id', id: 'codex-session' }], @@ -44,7 +48,13 @@ describe('agent session resume metadata', () => { 'prime-agent', { session_id: 'prime-session', session_file: '/tmp/prime-session.jsonl' }, { key: 'session_id', id: 'prime-session', transcriptPath: '/tmp/prime-session.jsonl' } - ] + ], + [ + 'copilot', + { session_id: '940237d9-c712-48e8-bca1-fd75fc4a8d4b' }, + { key: 'session_id', id: '940237d9-c712-48e8-bca1-fd75fc4a8d4b' } + ], + ['copilot', { sessionId: 'copilot-camel' }, { key: 'session_id', id: 'copilot-camel' }] ] as const)('extracts %s provider session ids', (source, payload, expected) => { expect(extractAgentProviderSession(source, payload)).toEqual(expected) }) @@ -69,7 +79,8 @@ describe('agent session resume metadata', () => { 'prime-agent', { key: 'session_id', id: 's1', transcriptPath: '/tmp/prime-session.jsonl' }, ['prime-agent', '--resume', '/tmp/prime-session.jsonl'] - ] + ], + ['copilot', { key: 'session_id', id: 's1' }, ['copilot', '--resume=s1']] ] as const)('builds %s resume argv', (agent, providerSession, expected) => { expect(getAgentResumeArgv(agent, providerSession)).toEqual(expected) }) diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 66f7168e8ba..5cf14c8dd57 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -14,7 +14,8 @@ export const RESUMABLE_TUI_AGENTS = [ 'grok', 'devin', 'omp', - 'prime-agent' + 'prime-agent', + 'copilot' ] as const satisfies readonly TuiAgent[] export type ResumableTuiAgent = (typeof RESUMABLE_TUI_AGENTS)[number] @@ -230,10 +231,15 @@ export function extractAgentProviderSession( const id = readSessionId(payload, ['session_id']) return id ? { key: 'session_id', id } : null } + // Why: Copilot's hook `session_id` is also its `~/.copilot/session-state//` + // directory name, so the same id is the CLI's resume locator. + case 'copilot': { + const id = readSessionId(payload, ['session_id', 'sessionId']) + return id ? { key: 'session_id', id } : null + } case 'amp': case 'cursor': case 'command-code': - case 'copilot': case 'hermes': return null } @@ -276,5 +282,10 @@ export function getAgentResumeArgv( return providerSession.key === 'session_id' ? ['omp', '--resume', ompResumeFilePath?.trim() || id] : null + // Why: the joined form is the only one Copilot documents, and it matches the + // flag spelling buildAgentResumeInvocation bakes into persisted AI Vault + // resume commands, so local and remote resumes agree on one spelling. + case 'copilot': + return providerSession.key === 'session_id' ? ['copilot', `--resume=${id}`] : null } } diff --git a/src/shared/tui-agent-startup-copilot-resume.test.ts b/src/shared/tui-agent-startup-copilot-resume.test.ts new file mode 100644 index 00000000000..d62bdb9293c --- /dev/null +++ b/src/shared/tui-agent-startup-copilot-resume.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentResumeStartupPlan } from './tui-agent-startup' + +const SESSION = { key: 'session_id' as const, id: '940237d9-c712-48e8-bca1-fd75fc4a8d4b' } + +describe('Copilot resume startup plan', () => { + it('uses the documented joined --resume form', () => { + const plan = buildAgentResumeStartupPlan({ + agent: 'copilot', + providerSession: SESSION, + cmdOverrides: {}, + agentArgs: '--yolo', + platform: 'darwin' + }) + + expect(plan?.launchCommand).toBe(`copilot '--yolo' '--resume=${SESSION.id}'`) + }) + + it('quotes the resume argv for cmd.exe', () => { + const plan = buildAgentResumeStartupPlan({ + agent: 'copilot', + providerSession: SESSION, + cmdOverrides: {}, + agentArgs: '--yolo', + platform: 'win32', + shell: 'cmd' + }) + + // Why: cmd.exe treats single quotes as literal characters. + expect(plan?.launchCommand).toBe(`copilot "--yolo" "--resume=${SESSION.id}"`) + }) + + it('honors a command override', () => { + const plan = buildAgentResumeStartupPlan({ + agent: 'copilot', + providerSession: SESSION, + cmdOverrides: { copilot: 'copilot --banner' }, + platform: 'linux' + }) + + expect(plan?.launchCommand).toBe(`copilot --banner '--resume=${SESSION.id}'`) + }) +}) From 6785dc092d08d88c226d9c211d7c3c10d57c5faa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:33:54 -0700 Subject: [PATCH 16/27] fix(composer): close the Create Workspace dialog on the first Escape (#16027) * fix(composer): close the Create Workspace dialog on the first Escape The modal copied the page-level "Esc blurs the focused field, then closes" rule from TaskPage/Automations. On a page that rule protects a focus the user chose; this dialog auto-focuses the name input on open, so its capture-phase handler preventDefault'd every first Escape (which also suppressed Radix's dismissal, since DismissableLayer skips a defaultPrevented event) and the dialog could only be closed with two presses. Drop the Escape branch and let the dialog's dismissable layer own it. Radix dismisses only the topmost layer, so nested popovers, selects and dialogs still consume their own Escape first. * test(e2e): pin the composer's auto-focus as the reason one Escape must close it --- .../components/NewWorkspaceComposerModal.tsx | 43 ++++++------------- .../e2e/worktree-jump-palette-filter.spec.ts | 4 ++ 2 files changed, 16 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 2309ef54b50..903e85aaa92 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -97,7 +97,6 @@ function ComposerModalBody({ @@ -109,13 +108,11 @@ function ComposerModalBody({ function QuickTabBody({ modalData, onClose, - onDismiss, isSubmissionCancelled, active }: { modalData: ComposerModalData onClose: () => void - onDismiss: () => void isSubmissionCancelled: () => boolean active: boolean }): React.JSX.Element { @@ -238,45 +235,29 @@ function QuickTabBody({ ? translate('auto.components.NewWorkspaceComposerModal.createWorktree', 'Create worktree') : translate('auto.components.NewWorkspaceComposerModal.createWorkspace', 'Create workspace') - // Cmd/Ctrl+Enter submits, Esc first blurs the focused input (like the full page). + // Cmd/Ctrl+Enter submits. Escape belongs to the dialog's dismissable layer: + // the page-style "blur the focused field first" rule assumes the user chose + // that field, but this dialog auto-focuses the name input on open, so handling + // Escape here swallowed every first press and left the composer stuck open. + // Radix also closes only the topmost layer, so nested popovers/selects/dialogs + // keep their own Escape without needing a guard here. const nestedDialogOpen = agentSettingsOpen || addProjectOpen || setLocationOpen useEffect(() => { if (!active || nestedDialogOpen) { - // Why: while a nested dialog (Add Project / Agents / Set location) is layered on top, - // this capture-phase handler must not steal its Escape (which should - // close only the nested dialog) or fire composer submit underneath it. + // Why: while a nested dialog (Add Project / Agents / Set location) is layered + // on top, this capture-phase handler must not fire composer submit underneath it. return } const onKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Enter' && event.key !== 'Escape') { + // Why: workspace creation is screen-local submit behavior, not a + // user-configurable app command. + if (!isScreenSubmitShortcut(event)) { return } const target = event.target if (!(target instanceof HTMLElement)) { return } - - if (event.key === 'Escape') { - if ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement || - target.isContentEditable - ) { - event.preventDefault() - target.blur() - return - } - event.preventDefault() - onDismiss() - return - } - - // Why: workspace creation is screen-local submit behavior, not a - // user-configurable app command. - if (!isScreenSubmitShortcut(event)) { - return - } if (!shouldAllowComposerEnterSubmitTarget(target, composerRef.current)) { return } @@ -288,7 +269,7 @@ function QuickTabBody({ } window.addEventListener('keydown', onKeyDown, { capture: true }) return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [active, composerRef, createDisabled, handleCreate, nestedDialogOpen, onDismiss]) + }, [active, composerRef, createDisabled, handleCreate, nestedDialogOpen]) return ( <> diff --git a/tests/e2e/worktree-jump-palette-filter.spec.ts b/tests/e2e/worktree-jump-palette-filter.spec.ts index 2253bc2d344..b4841cf059e 100644 --- a/tests/e2e/worktree-jump-palette-filter.spec.ts +++ b/tests/e2e/worktree-jump-palette-filter.spec.ts @@ -193,6 +193,10 @@ test.describe('Worktree jump-palette filters', () => { const createDialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) await expect(createDialog).toBeVisible() + // Why assert focus first: the composer auto-focuses the name field, so Escape + // always lands on an input the user never chose. A page-style "blur the field + // first" handler here would silently cost a second press. + await expect(createDialog.locator('[data-workspace-name-input="true"]')).toBeFocused() await orcaPage.keyboard.press('Escape') await expect(createDialog).toBeHidden() }) From f97503580966560342db8cdc7fa5054efa9e1b39 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:34:39 -0700 Subject: [PATCH 17/27] refactor(ipc): split preflight and SSH registry out of the ipcMain modules (#15927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(preflight): split agent detection out of the ipcMain registration First of the IPC extractions the revised design requires. `src/main/ipc/preflight.ts` mixed 285 lines of agent/tool detection with 35 lines of `ipcMain.handle` registration, and the runtime calls that detection during normal operation (`orca-runtime.ts:573`, plus the preflight RPC methods). So the runtime dragged `ipcMain` into its graph to reach pure logic. Detection moves to `src/main/preflight/agent-detection.ts` — named for what it contains, per AGENTS.md. `ipc/preflight.ts` keeps only the handler registration and re-exports the domain module so existing importers are unaffected. The runtime and its RPC methods now import the domain module directly. Ratchet baseline 36 → 35: `src/main/ipc/preflight.ts` is no longer reachable from the runtime. The gate detected the improvement and refused to pass until the baseline tightened, which is the behaviour it was built for. Verified: 2 files / 1,187 tests pass across every suite touching preflight; `pnpm typecheck` clean; `oxlint` clean. * refactor(ssh): split the SSH target registry out of the ipcMain module Second IPC extraction, and by far the biggest win: this removes **eight** modules from the runtime's Electron graph, taking the ratchet baseline 35 → 27. The runtime needed five thin accessors from `src/main/ipc/ssh.ts` — `connectRegisteredSshTarget`, `getRegisteredSshState`, `listRegisteredSshTargets`, `listRegisteredRemovedSshTargetLabels`, `getActiveMultiplexer`. Each is a one-line read over module-level state. Importing them dragged in `ipcMain`, `powerMonitor` and a `BrowserWindow` accessor — and, transitively, `ipc/pty.ts` (8,031 lines), `ssh-browse`, `ssh-passphrase`, `ssh-relay-deploy`, `ssh-remote-cli-host-passthrough`, `wsl-hook-relay-launch` and `user-data-path`. `src/main/ssh/ssh-target-registry.ts` now holds that state plus its accessors. `registerSshHandlers` populates it; the runtime reads it. The indirection is kept deliberately: SSH providers register after construction and may reconnect, so callers must resolve the current generation rather than freeze one. `ipc/ssh.ts` re-exports all five, so non-test importers are unaffected. `connectRegisteredSshTarget` still throws `ssh_handlers_not_registered` when no handler layer registered — a headless host must fail loudly rather than report a target as unreachable, which would read as `exited` (see ssh-execution-boundary.md). Verified: 9 files / 59 tests across the ssh, automations and trust-preset suites; orca-runtime.test.ts 1,183 pass; `pnpm typecheck` clean; `oxlint` clean. * refactor(host): resolve the app root through the port in fork-reachable modules `parcel-watcher-entry-path.ts` and `session-scanner-service-entry-path.ts` read the app root via `require('electron').app` inside a try/catch that already returns null when Electron is absent. They were therefore correct under plain Node at runtime and only failed the *static* text check — which is real, not pedantic: the comment in `ports/port-scan-command-client.ts:19` records that the plain-node-entry-guard fails on that literal text, try/catch or not. `hasAppEnvironment() ? getAppEnvironment() : null` gives the identical "no app root here" answer without the text. That restores `hasAppEnvironment`, which an earlier commit in this stack deleted as unused — it now has the caller it was waiting for. Ratchet baseline 27 → 25. Verified: 74 files / 458 tests; `pnpm typecheck` clean; `oxlint` clean. * test(ssh): mock the SSH target registry alongside the ipc/ssh mock Thirty-eight suites mocked `vi.mock('./ssh')` for `getActiveMultiplexer`. That factory went inert when production started importing the accessor from `../ssh/ssh-target-registry`, so the real module loaded and the assertions drifted. Adds a companion registry mock returning the same stub, plus a `sshTargetRegistryModuleMock` builder beside the existing `sshModuleMock` so the shared harness stays one place. No assertion changed. Found by a full-suite run: the targeted ssh/runtime suites were green while 30 tests in ipc/worktrees and ipc/repos were not. * refactor(runtime): read app paths and the packaged flag through the port `orca-runtime.ts` is the last module in its own graph that imports `electron` directly. Nineteen of its uses were `app.getPath` (12) and `app.isPackaged` (7) — exactly what the AppEnvironment port already covers. Also removes a dead `const { app } = require('electron')` inside `getOrchestrationDb`. It was left unused once the path came from the port, and it is precisely the dynamic-require pattern `plain-node-entry-guard.ts` exists to catch, sitting in the runtime's own constructor path. What still binds `orca-runtime.ts` to Electron is now three sites, not nineteen: `new Notification(...)` (one), `BrowserWindow.fromId` (one), and the `ipcMain.on('terminal:tabCreateReply')` renderer round-trip — which is the browser tab path, and the same one that would hang a headless host for ten seconds. Two suites drove `electronMocks.app.isPackaged` directly; they now install a fake AppEnvironment reading the same mutable field, so their per-test toggles work unchanged and no assertion moved. Verified: 376 files / 4,717 tests across src/main/runtime; typecheck and oxlint clean. * test(serve): add the built-artifact terminal round-trip acceptance smoke "The server started" proves almost nothing. Terminal creation dispatches 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 — which is exactly the gap the design doc's own boot proof was retracted for. This boots the BUILT `out/main/index.js --serve`, parses its ready payload, pairs a real client over the advertised endpoint, lists worktrees, creates a terminal, runs a command through the PTY, asserts the output comes back, and asserts clean shutdown. It drives nothing but the public pairing + RPC surface, so the same script is the acceptance gate a future Node-only backend must pass unchanged. The sentinel invokes `process.execPath` rather than `echo`, because the shell differs per platform and node does not. Verified both directions: passes against the real server, and fails with an actionable message when the command produces no output — a smoke that cannot fail is worthless. * fix(ssh): fail loudly when the multiplexer resolver was never installed `getActiveMultiplexer` resolves through a resolver that `ipc/ssh.ts` installs at module scope. A process that never loads the SSH layer — which is the whole point of the Node-only backend — would get `undefined` from every call. `undefined` already means something specific here: "not connected". So a missing resolver and a disconnected target were indistinguishable, and a host with no SSH layer would quietly report every target as not connected. That is the unverifiable-reported-as-exited conflation `docs/reference/ssh-execution-boundary.md` exists to prevent — the doc is explicit that absence of contact is never evidence of absence of the thing. A missing resolver is a wiring error, not a connection state, so it throws, matching what `connectRegisteredSshTarget` already does for unregistered handlers. Verified: 432 files / 4,759 tests across ipc, ssh, preflight, automations and trust presets; typecheck and oxlint clean. * refactor(pty): stop faking a BrowserWindow for the headless PTY path `registerHeadlessPtyRuntime` passed `registerPtyHandlers` a stub object cast to `BrowserWindow` whose `isDestroyed()` returned true and whose `webContents.send` was a no-op — a window-shaped thing that lied about being a window, purely to satisfy the type. Adversarial review named it as the same "looks fine, silently returns a lie" pattern this codebase rejects elsewhere, and it is the shape that keeps `electron` on a path that otherwise needs none. `registerPtyHandlers` now takes `BrowserWindow | null`. An absent renderer is semantically identical to a destroyed one — all 42 call sites already guarded on `isDestroyed()` and skipped — so `src/main/ipc/pty-renderer-surface.ts` states that directly: `isRendererGone`, `sendToRenderer`, `rendererWebContents`. The compound `isDestroyed() || webContents.isDestroyed()` guards collapse into one predicate. `isPtyWriteEventFromMainWindow` becomes null-tolerant and fails closed: with no renderer no sender can legitimately match, so every write is rejected. Those handlers cannot fire headless today, but failing closed is the right answer if that ever changes. This is the precondition for installing a PTY controller without Electron, which is what a Node-only backend needs and what `terminal.create` actually calls. Verified: 129 files / 2,473 tests across ipc/pty, providers and orca-runtime; the built-artifact acceptance smoke still passes end-to-end (boot → pair → terminal.create → sentinel → close), which is the check that matters most here since this changes the headless PTY path itself; typecheck and oxlint clean. * refactor(pty): read app paths and the packaged flag through the port Follows the fake-window removal. `ipc/pty.ts` had nine `app.*` reads — all `getPath`, `getVersion` or `isPackaged` — which the AppEnvironment port already covers. The `BrowserWindow` import was also dead after the null-window change. What still binds this file to Electron is now `ipcMain` (75 uses, all handler registration) and `powerMonitor` (2). That is a clean statement of the remaining job: split logic from registration, the same shape already applied to preflight and the SSH registry. Test wiring: the shared `pty-ipc-suite-environment` beforeEach installs a fake AppEnvironment that reads through the existing `vi.mock('electron')` app object rather than freezing values — suites toggle `app.isPackaged` mid-test to exercise dev-mode spawn paths, so the port has to observe the same mutable field. One edit in the shared harness covers every pty suite. Verified: 128 files / 1,290 tests across ipc/pty and providers; the built-artifact acceptance smoke passes; typecheck and oxlint clean; ratchet unchanged at 25. * refactor(pty): inject the ipcMain surface so the PTY module loads without Electron This closes the round-3 blocker: "the doc never says how orcad installs setPtyController without Electron." `registerPtyHandlers` owns the `RuntimePtyController` that `terminal.create` actually spawns through — the thing a Node backend needs and cannot get from the provider thunks. The module was otherwise host-agnostic already; the only thing pinning 8,031 lines to Electron was a static `ipcMain` / `powerMonitor` import used purely to register renderer handlers that no headless host will ever receive. `src/main/ipc/pty-host-bindings.ts` makes those surfaces settable, defaulting to no-ops. Unlike AppEnvironment and SecretStore, the default does NOT throw: a host with no renderer legitimately has nothing to register against, so not registering handlers nobody can call is correct rather than a hidden downgrade. The desktop installs the real objects in `attach-main-window-services` before its handlers run. Also converts the remaining electron import to a top-level `import type`. oxlint's `no-import-type-side-effects` caught that inline `type` specifiers still leave a side-effect import — precisely the "type-only is not enough if esbuild still emits require('electron')" trap a reviewer flagged. **`src/main/ipc/pty.ts` now bundles with zero `require("electron")`.** A Node entry can call `registerPtyHandlers(null, runtime, …)` and get a working PTY controller. Verified: 128 files / 1,290 tests across ipc/pty and providers; the built-artifact acceptance smoke passes end-to-end — which is the check that matters, since this changes how every PTY handler registers; typecheck and oxlint clean. * fix(pty-bindings): drop two unused eslint-disable directives CI runs oxlint with unused-disable reporting; the two `@typescript-eslint/no-explicit-any` suppressions I added were never triggered by any enabled rule, so they failed static analysis as dead directives. The `any[]` rest args stay — they mirror electron's own IpcMain signature, and narrowing them would reject the real object at the desktop call site. Verified with the exact CI invocation: `oxlint --format github` reports 0 warnings, 0 errors across the repo. * fix(pty): install the host bindings per process, not per window A real regression my own change introduced, caught by the SSH docker E2E (`paired-startup-exec-readiness` — "recovers startup exec through a headed paired desktop owner"). It reproduced on rerun, so it was not a flake. `setPtyHostBindings` was called inside `attachMainWindowServices`, i.e. when a window attaches. But `registerHeadlessPtyRuntime` (index.ts:3163) calls `registerPtyHandlers` on the serve path *before* any window exists — so those handlers registered against the no-op default and never reached the real `ipcMain`. A paired desktop owner then attached to a runtime whose PTY handlers were wired to nothing. The bindings describe the *host*, not the *window*: an Electron main process always has `ipcMain`, whether or not a window is open. Installing them beside `setAppEnvironment`/`setSecretStore` at the top of bootstrap fixes both paths. Verified: 128 files / 1,290 tests; the built-artifact acceptance smoke passes; typecheck clean; `oxlint --format github` (the exact CI invocation) reports 0/0. * feat(orcad): de-electron the runtime core and add the Node entry + build gate **`src/main/runtime/orca-runtime.ts` — 41,048 lines — no longer imports electron.** Its last three sites go through `runtime-desktop-surface.ts`: a native notification, the authoritative-window lookup, and the one `ipcMain` channel used by the renderer-backed tab-create fallback. All three are unreachable without a renderer — `createTerminal` already takes the background branch when no window exists (#10333) — so a Node host installs none and the runtime relays notifications to paired clients, which is the better destination anyway. Ratchet 25 → 24. Adds `src/main/orcad/orcad-entry.ts`: Node host adapters plus a `startOrcad` that constructs the runtime, installs the PTY controller via `registerPtyHandlers(null, …)`, and serves RPC. It sets two defaults the constructor gets wrong for a headless host — `canRecoverPersistentLocalPtys: false` (no daemon here) and `getDesktopWindowStatus: 'blocked'` (a Node host can never be promoted to a desktop window, which is what `'openable'` claims). Adds `config/scripts/build-orcad.mjs`, which **currently fails, on purpose**: 25 modules still import electron (browser and speech clusters, plugins, jira/proxy, filesystem-watcher, and four `require('electron').app` one-liners). It names them. Two bugs found while building it, both worth recording: - The first bundle looked clean and was not. `electron` was bundleable, so esbuild rewrote the metafile `path` to the resolved file under node_modules and a check for `path === 'electron'` passed while the package was in the bundle — it failed at runtime with electron's own installer message. The check now reads `original`, and electron is marked external so a residual import fails loudly instead. - `jsonc-parser`'s UMD build breaks the bundle at load; aliased to its ESM entry, the same fix `build-relay.mjs` already carries. Verified: desktop unchanged — the built-artifact acceptance smoke passes, runtime/pty/ provider suites green, typecheck clean, `oxlint --format github` 0/0. * refactor(host): drop the last two require('electron') app lookups `computer/sidecar-client.ts` and `ports/port-scan-command-client.ts` read the app root through `require('electron').app` inside a try/catch. Both were already correct under plain Node at runtime — they return null when it throws — but the literal text fails the plain-Node entry guard regardless, which is why port-scan carried a comment warning it must never become reachable from a fork entry. Reading the AppEnvironment port gives the identical "no app root here" answer without the text, so that warning is now obsolete and the comment says so. Ratchet 24 → 22. Every remaining entry is a real coupling: the browser cluster (15, which variant B does not ship), speech (2), plugins (2), and jira/proxy-settings (2, needing an HttpClient port for Chromium session partitions). Verified: 25 files / 209 tests; acceptance smoke passes; typecheck and `oxlint --format github` clean. * docs(orcad): record that the ratchet under-counts orcad's graph The ratchet reports 22 electron importers; the orcad build reports 23. The extra is agent-hooks/wsl-hook-relay-launch.ts, and the cause is a gap in the gate rather than a rounding error: the ratchet measures what orca-runtime + runtime-rpc reach, while orcad's entry also imports ipc/pty directly to install the PTY controller. Once orcad ships it must become a ratchet entry point, or the two numbers drift and the gate quietly stops covering the artifact it exists for. * refactor(runtime): inject the browser commands factory Drops 14 modules from the runtime's Electron graph in one change — the whole Chromium browser cluster. Ratchet 22 → 8. `OrcaRuntimeService` constructed `RuntimeBrowserCommands` as a field initializer, and that construction is what pulled in `BrowserWindow`, `session`, `webContents` and the cookie jars. Importing the class for its *type* is free; only building it costs. So the class import becomes `import type`, and the instance comes from `runtime-browser-commands-factory.ts`. The desktop installs the real factory at the Electron entry. **All ~80 existing `this.browserCommands.*.bind(...)` delegations are untouched** — a review round specifically warned that rewriting those was the expensive, risky part, and this avoids it entirely. With no factory installed, browser commands reject per call with `browser_unavailable` rather than resolving to a stub that silently succeeds. The runtime already filters browser capabilities out of `getStatus()` when no backend exists, so clients do not offer the affordance in the first place. Also corrects a stale comment in `pty-renderer-surface.ts` that still described the fake window as present tense; it was deleted two commits ago. Verified: 451 files / 5,513 tests across `src/main/browser` and `src/main/runtime` — the entire browser automation suite; the built-artifact acceptance smoke passes; `pnpm typecheck` and `oxlint --format github` clean. * refactor(host): extract the plugin client list and port two app lookups Ratchet 8 → 5. - `listPluginsForClients` moves to `src/main/plugins/plugin-client-list.ts`. It needed only three `plugins/*` helpers, none of them Electron — it was colocated with `ipcMain.handle` registrations, so the runtime's `plugins.list` RPC dragged all of Electron in to call a function that reads a lockfile. Same shape as preflight. Dropping it also releases `ipc/plugin-marketplaces.ts`. - `agent-hooks/wsl-hook-relay-launch.ts` and `speech/stt-service.ts` read `getAppPath` and `isPackaged` through the AppEnvironment port. The five that remain are all genuinely Chromium and need the HttpClient port or a watcher split, not another mechanical swap: `browser/cdp-bridge` (webContents), `ipc/filesystem-watcher` (ipcMain), `jira/authenticated-request` and `network/proxy-settings` (net + session partitions), `speech/model-manager` (`net.request`, which honors app proxy settings that Node https does not — replacing it is a behaviour change, not a rename). Verified: 219 files / 1,922 tests across plugins, speech, agent-hooks and the runtime RPC methods; the built-artifact acceptance smoke passes; typecheck and `oxlint --format github` clean. * refactor(network): resolve the default proxy session lazily Ratchet 5 → 4. `proxy-settings.ts` needed exactly one Electron value: `session.defaultSession`, as the fallback when a caller does not pass `options.proxySession`. Callers could already inject a session; only the default was hard-wired. It now comes from a settable resolver, so the module loads under plain Node. **A resolver rather than a Session, because a Session eagerly throws.** The first attempt installed `session.defaultSession` directly in pre-ready bootstrap and broke startup outright — `TypeError: Session can only be received when app is ready`. The acceptance smoke caught it before commit. Deferring to first use is always after ready. Behaviour with no session is not a degradation: there is no Chromium proxy config to discover, so `resolveProxy` is skipped and the environment variables become the whole answer rather than a fallback. Applying rules to a session that does not exist is likewise skipped; settings are still honoured because outbound requests read the env. This reaches past Jira — a review round noted `ensureElectronProxyFromEnvironment` is also on the Claude HTTP path via `oauth-refresh.ts` and `rate-limits/claude-fetcher.ts`. Verified: 48 files / 526 tests across network, jira and rate-limits; the built-artifact acceptance smoke passes; typecheck and `oxlint --format github` clean. * fix(index): merge the duplicate proxy-settings import CI's code-quality lint (`oxlint --config config/oxlint-code-quality-native-plugins.json --deny-warnings`) flags a module imported twice in one file. My earlier insertion added a second `./network/proxy-settings` import beside the existing one. Verified with CI's exact invocation: exit 0. * refactor(network): add the HttpClient port and lift BrowserError out of cdp-bridge Ratchet 4 → 2. Two unrelated couplings, both of the same shape — a small thing living inside a Chromium-heavy file. `BrowserError` is a seven-line error class with no dependencies, but it lived in `browser/cdp-bridge.ts`, which imports `webContents`. The runtime catches that type on paths with nothing to do with CDP, so one import kept a Node host from loading the runtime at all. Moved to `browser/browser-error.ts`; cdp-bridge re-exports it. `jira/authenticated-request.ts` fetches through `net.fetch` and reads `session.defaultSession`. `network/http-client.ts` makes both settable. This one is a **named port rather than a silent fallback, because the fallback is not transparent**: Electron's net follows Chromium session/proxy state, avoids undici's stale keep-alive sockets after a VPN path change, and sends a Chrome user agent that Jira's XSRF check depends on. A Node host gets `globalThis.fetch`, reads proxy config from the environment, and sends Node's user agent. That difference is documented at the port. `session.defaultSession` is read per call, not captured at install — it throws before the app is ready, which is the mistake the previous commit made and the acceptance smoke caught. Test wiring: `jira/client.test.ts` installs the port *inside* `loadClientModule`, after its `vi.resetModules()`, since the reset gives the module a fresh singleton. Verified: 461 files / 5,616 tests across jira, browser, network and runtime; the built-artifact acceptance smoke passes; typecheck, `oxlint --format github` and the code-quality lint with `--deny-warnings` all clean. * fix(http-client): register the Node fetch fallback with the call-site audit `global-fetch-call-site-audit.test.ts` guards every global-fetch use, because the global runs on undici where an unread response body can crash the whole process (orca#8695). The HttpClient port's Node fallback is a new such call site and was unregistered — the guard caught it in a full-suite run. Registered with the reasoning, and the port's doc comment now states the body-safety contract explicitly: it 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. Two comments elsewhere mentioned the global by name and tripped the line scan as false positives; reworded to describe the behaviour rather than name the API. Verified: audit passes; typecheck and `oxlint --format github` clean. * fix(app-environment): read hasAppEnvironment through the realm slot --- config/runtime-electron-baseline.txt | 34 -- config/scripts/build-orcad.mjs | 98 ++++++ .../scripts/runtime-serve-terminal-smoke.mjs | 216 ++++++++++++ package.json | 1 + src/main/agent-hooks/wsl-hook-relay-launch.ts | 4 +- .../session-scanner-service-entry-path.ts | 14 +- src/main/automations/external-manager.test.ts | 4 +- src/main/automations/external-manager.ts | 2 +- src/main/browser/browser-error.ts | 16 + .../browser/cdp-bridge-integration.test.ts | 6 + src/main/browser/cdp-bridge.ts | 12 +- src/main/computer/sidecar-client.ts | 10 +- src/main/global-fetch-call-site-audit.test.ts | 5 + src/main/host/electron-browser-commands.ts | 6 + src/main/host/electron-http-client.ts | 14 + .../host/electron-runtime-desktop-surface.ts | 20 ++ src/main/index.ts | 45 ++- src/main/ipc/parcel-watcher-entry-path.ts | 14 +- src/main/ipc/plugins.ts | 12 +- src/main/ipc/preflight-remote-ssh.test.ts | 3 + ...ht-remote-windows-terminal-capabilities.ts | 2 +- src/main/ipc/preflight.ts | 300 +--------------- src/main/ipc/pty-host-bindings.ts | 68 ++++ src/main/ipc/pty-ipc-suite-environment.ts | 23 ++ src/main/ipc/pty-ipc-test-surface.ts | 21 ++ src/main/ipc/pty-renderer-surface.ts | 37 ++ .../ipc/pty-startup-barrier-ordering.test.ts | 6 +- src/main/ipc/pty.ts | 323 +++++++++--------- .../ipc/repos-execution-host-catalog.test.ts | 1 + .../repos-local-add-and-project-setup.test.ts | 1 + .../ipc/repos-local-clone-lifecycle.test.ts | 1 + src/main/ipc/repos-nested-import.test.ts | 1 + src/main/ipc/repos-nested-scan.test.ts | 1 + .../ipc/repos-remote-base-ref-queries.test.ts | 1 + .../ipc/repos-remote-git-username.test.ts | 1 + src/main/ipc/repos-remote.test.ts | 1 + .../ssh-worktree-create-root-registration.ts | 2 +- src/main/ipc/ssh.ts | 122 +++---- ...ktrees-create-metadata-persistence.test.ts | 3 + .../ipc/worktrees-delete-pty-teardown.test.ts | 3 + .../ipc/worktrees-detected-scan-cache.test.ts | 3 + ...ktrees-discovery-metadata-backfill.test.ts | 3 + ...worktrees-existing-branch-checkout.test.ts | 3 + src/main/ipc/worktrees-forget-local.test.ts | 3 + .../worktrees-issue-command-overrides.test.ts | 3 + .../ipc/worktrees-lineage-hydration.test.ts | 3 + .../worktrees-listing-fallback-rows.test.ts | 3 + ...orktrees-local-base-ref-resolution.test.ts | 3 + .../ipc/worktrees-local-create-flow.test.ts | 3 + ...worktrees-orphan-directory-cleanup.test.ts | 3 + ...trees-preserved-branch-fork-remote.test.ts | 3 + .../ipc/worktrees-removal-recovery.test.ts | 3 + .../worktrees-remove-archive-hooks.test.ts | 3 + ...rktrees-remove-host-disambiguation.test.ts | 3 + .../ipc/worktrees-remove-preflight.test.ts | 3 + ...trees-setup-launch-sparse-checkout.test.ts | 3 + .../worktrees-ssh-base-ref-resolution.test.ts | 3 + ...rees-ssh-branch-conflict-suffixing.test.ts | 3 + ...worktrees-ssh-create-base-prefetch.test.ts | 3 + ...ktrees-ssh-fork-push-target-remote.test.ts | 3 + .../worktrees-ssh-local-base-refresh.test.ts | 3 + .../ipc/worktrees-ssh-pr-head-fetch.test.ts | 3 + .../worktrees-ssh-provider-authority.test.ts | 3 + ...orktrees-ssh-repo-owner-resolution.test.ts | 3 + .../ipc/worktrees-ssh-setup-launch.test.ts | 3 + src/main/ipc/worktrees-test-module-mocks.ts | 7 + .../ipc/worktrees-wsl-runtime-routing.test.ts | 3 + src/main/jira/authenticated-request.ts | 13 +- src/main/jira/client.test.ts | 8 + src/main/network/http-client.ts | 41 +++ src/main/network/proxy-settings.ts | 58 +++- src/main/orcad/orcad-entry.ts | 118 +++++++ src/main/plugins/plugin-client-list.ts | 17 + src/main/ports/port-scan-command-client.ts | 15 +- src/main/preflight/agent-detection.ts | 304 +++++++++++++++++ src/main/providers/provider-dispatch.test.ts | 11 + src/main/remote-agent-trust-presets.test.ts | 2 +- src/main/remote-agent-trust-presets.ts | 2 +- .../orca-runtime-agent-skill-share.test.ts | 2 + src/main/runtime/orca-runtime.test.ts | 29 +- src/main/runtime/orca-runtime.ts | 97 +++--- src/main/runtime/rpc/methods/client-events.ts | 2 +- src/main/runtime/rpc/methods/plugins.ts | 2 +- .../runtime/rpc/methods/preflight.test.ts | 2 +- src/main/runtime/rpc/methods/preflight.ts | 2 +- src/main/runtime/rpc/methods/ssh.test.ts | 2 +- src/main/runtime/rpc/methods/ssh.ts | 2 +- .../runtime-browser-commands-factory.ts | 53 +++ src/main/runtime/runtime-desktop-surface.ts | 42 +++ src/main/speech/stt-service.ts | 6 +- src/main/ssh/ssh-target-registry.ts | 89 +++++ src/shared/app-environment.ts | 8 + 92 files changed, 1797 insertions(+), 669 deletions(-) create mode 100644 config/scripts/build-orcad.mjs create mode 100644 config/scripts/runtime-serve-terminal-smoke.mjs create mode 100644 src/main/browser/browser-error.ts create mode 100644 src/main/host/electron-browser-commands.ts create mode 100644 src/main/host/electron-http-client.ts create mode 100644 src/main/host/electron-runtime-desktop-surface.ts create mode 100644 src/main/ipc/pty-host-bindings.ts create mode 100644 src/main/ipc/pty-ipc-test-surface.ts create mode 100644 src/main/ipc/pty-renderer-surface.ts create mode 100644 src/main/network/http-client.ts create mode 100644 src/main/orcad/orcad-entry.ts create mode 100644 src/main/plugins/plugin-client-list.ts create mode 100644 src/main/preflight/agent-detection.ts create mode 100644 src/main/runtime/runtime-browser-commands-factory.ts create mode 100644 src/main/runtime/runtime-desktop-surface.ts create mode 100644 src/main/ssh/ssh-target-registry.ts 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) { From dee48498b2becab8f32435b17085cab0c70a4f5b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:40:47 -0700 Subject: [PATCH 18/27] feat(dictation): add sound-reactive grape visualizer (#16017) * feat(dictation): add sound-reactive grape visualizer * perf: scope dictation meter updates --- .../dictation/DictationController.tsx | 3 +- .../components/dictation/DictationGrapes.tsx | 47 ++++++ .../dictation/DictationIndicator.test.tsx | 52 ++++++- .../dictation/DictationIndicator.tsx | 135 +++++++++++------- .../dictation/dictation-audio-meter.test.ts | 53 +++++++ .../dictation/dictation-audio-meter.ts | 106 ++++++++++++++ .../dictation/dictation-meter-store.test.tsx | 28 ++++ .../dictation/dictation-meter-store.ts | 29 ++++ .../use-audio-capture.capture-loss.test.ts | 96 ++++++++++++- src/renderer/src/hooks/use-audio-capture.ts | 52 ++++++- src/renderer/src/i18n/locales/en.json | 7 +- tests/e2e/dictation-indicator.spec.ts | 91 ++++++++++++ tests/e2e/helpers/runtime-types.ts | 2 + 13 files changed, 637 insertions(+), 64 deletions(-) create mode 100644 src/renderer/src/components/dictation/DictationGrapes.tsx create mode 100644 src/renderer/src/components/dictation/dictation-audio-meter.test.ts create mode 100644 src/renderer/src/components/dictation/dictation-audio-meter.ts create mode 100644 src/renderer/src/components/dictation/dictation-meter-store.test.tsx create mode 100644 src/renderer/src/components/dictation/dictation-meter-store.ts create mode 100644 tests/e2e/dictation-indicator.spec.ts diff --git a/src/renderer/src/components/dictation/DictationController.tsx b/src/renderer/src/components/dictation/DictationController.tsx index 7985c13befa..7a499a2eaf0 100644 --- a/src/renderer/src/components/dictation/DictationController.tsx +++ b/src/renderer/src/components/dictation/DictationController.tsx @@ -14,6 +14,7 @@ import { translate } from '@/i18n/i18n' import { showDictationStartErrorToast } from './dictation-start-error-toast' import { useHoldDictationGesture } from './use-hold-dictation-gesture' import { DICTATION_CONTROL_EVENT, type DictationControlAction } from './dictation-control-events' +import { publishDictationMeter } from './dictation-meter-store' export function DictationController() { const dictationState = useAppStore((s) => s.dictationState) @@ -28,7 +29,7 @@ export function DictationController() { flushBufferedAudio, discardBufferedAudio, getCapturedChunkCount - } = useAudioCapture() + } = useAudioCapture(publishDictationMeter) const dictationStateRef = useRef(dictationState) dictationStateRef.current = dictationState diff --git a/src/renderer/src/components/dictation/DictationGrapes.tsx b/src/renderer/src/components/dictation/DictationGrapes.tsx new file mode 100644 index 00000000000..40c95c36684 --- /dev/null +++ b/src/renderer/src/components/dictation/DictationGrapes.tsx @@ -0,0 +1,47 @@ +import { cn } from '@/lib/utils' + +const GRAPES = [ + { base: 0.64, response: 0.45, lift: 1 }, + { base: 0.76, response: 0.75, lift: -1 }, + { base: 0.9, response: 1, lift: 1 }, + { base: 1, response: 1.25, lift: -1 }, + { base: 0.86, response: 0.9, lift: 1 }, + { base: 0.72, response: 1.1, lift: -1 }, + { base: 0.82, response: 0.7, lift: 1 }, + { base: 0.68, response: 0.55, lift: -1 }, + { base: 0.58, response: 0.35, lift: 1 } +] as const + +type DictationGrapesProps = { + level: number + active: boolean + transitioning: boolean +} + +export function DictationGrapes({ level, active, transitioning }: DictationGrapesProps) { + const normalizedLevel = Math.min(1, Math.max(0, level)) + + return ( + + ) +} diff --git a/src/renderer/src/components/dictation/DictationIndicator.test.tsx b/src/renderer/src/components/dictation/DictationIndicator.test.tsx index 89fb0e726af..6bc3572f520 100644 --- a/src/renderer/src/components/dictation/DictationIndicator.test.tsx +++ b/src/renderer/src/components/dictation/DictationIndicator.test.tsx @@ -10,6 +10,11 @@ import { DICTATION_CONTROL_EVENT, type DictationControlAction } from './dictatio const storeState = { dictationState: 'listening' as DictationState, partialTranscript: '', + dictationMeter: { + level: 0, + isSpeaking: false, + isClipping: false + }, settings: { voice: { dictationMode: 'toggle' } } as { voice?: { dictationMode?: 'toggle' | 'hold' } } | null @@ -20,6 +25,10 @@ vi.mock('@/store', () => { return { useAppStore } }) +vi.mock('./dictation-meter-store', () => ({ + useDictationMeter: () => storeState.dictationMeter +})) + vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) @@ -44,6 +53,11 @@ const originalUserAgent = navigator.userAgent beforeEach(() => { storeState.dictationState = 'listening' storeState.partialTranscript = '' + storeState.dictationMeter = { + level: 0, + isSpeaking: false, + isClipping: false + } storeState.settings = { voice: { dictationMode: 'toggle' } } setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') }) @@ -88,7 +102,7 @@ describe('DictationIndicator', () => { const { container } = render() const pill = container.firstChild as HTMLElement - expect(pill.textContent).toContain('Listening...') + expect(pill.textContent).toContain('Listening') expect(pill.textContent).not.toContain('⌘') }) @@ -104,7 +118,7 @@ describe('DictationIndicator', () => { storeState.dictationState = 'stopping' render() - expect(screen.getByText('Processing...')).toBeTruthy() + expect(screen.getByText('Processing…', { selector: '[aria-hidden="true"]' })).toBeTruthy() expect(screen.queryByRole('button', { name: 'Stop dictation' })).toBeNull() }) @@ -114,4 +128,38 @@ describe('DictationIndicator', () => { expect(container.firstChild).toBeNull() }) + + it('reacts to speaking audio and exposes the semantic state', () => { + storeState.dictationMeter = { + level: 0.72, + isSpeaking: true, + isClipping: false + } + render() + + expect(screen.getByText('Speaking')).toBeTruthy() + expect(screen.getByRole('status').textContent).toBe('Listening') + expect(screen.getByTestId('dictation-grapes').children).toHaveLength(9) + }) + + it('uses the destructive role only while clipping', () => { + storeState.dictationMeter = { + level: 1, + isSpeaking: true, + isClipping: true + } + const { container } = render() + + expect(screen.getByText('Too loud', { selector: '[aria-hidden="true"]' })).toBeTruthy() + expect((container.firstChild as HTMLElement).className).toContain('text-destructive') + }) + + it('keeps streaming transcript separate from the listening state', () => { + storeState.partialTranscript = 'A polished voice visualizer' + render() + + expect(screen.getByText('Listening', { selector: '[aria-hidden="true"]' })).toBeTruthy() + expect(screen.getByText('A polished voice visualizer')).toBeTruthy() + expect(screen.getByRole('status').textContent).toBe('Listening') + }) }) diff --git a/src/renderer/src/components/dictation/DictationIndicator.tsx b/src/renderer/src/components/dictation/DictationIndicator.tsx index 2b8cce868da..5029cf225ed 100644 --- a/src/renderer/src/components/dictation/DictationIndicator.tsx +++ b/src/renderer/src/components/dictation/DictationIndicator.tsx @@ -1,79 +1,106 @@ -import { useAppStore } from '@/store' -import { Mic, Square } from 'lucide-react' -import { Button } from '@/components/ui/button' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' +import { Square } from 'lucide-react' import { dispatchDictationControl } from './dictation-control-events' +import { DictationGrapes } from './DictationGrapes' +import { useDictationMeter } from './dictation-meter-store' export function DictationIndicator() { - const dictationState = useAppStore((s) => s.dictationState) - const partialTranscript = useAppStore((s) => s.partialTranscript) - const isHoldMode = useAppStore((s) => s.settings?.voice?.dictationMode === 'hold') + const dictationState = useAppStore((state) => state.dictationState) + const partialTranscript = useAppStore((state) => state.partialTranscript) + const dictationMeter = useDictationMeter() + const isHoldMode = useAppStore((state) => state.settings?.voice?.dictationMode === 'hold') const shortcut = useShortcutKeyDetails('voice.dictation') - if ( - dictationState !== 'listening' && - dictationState !== 'starting' && - dictationState !== 'stopping' - ) { + const isVisible = ['listening', 'starting', 'stopping'].includes(dictationState) + if (!isVisible) { return null } - const label = + const isListening = dictationState === 'listening' + const isClipping = isListening && dictationMeter.isClipping + const isSpeaking = isListening && dictationMeter.isSpeaking && !isClipping + const lifecycleLabel = dictationState === 'starting' - ? 'Starting...' + ? translate('auto.components.dictation.DictationIndicator.starting', 'Starting mic…') : dictationState === 'stopping' - ? 'Processing...' - : partialTranscript || 'Listening...' - - // Why: the stop path is a no-op once the session is already tearing down. + ? translate('auto.components.dictation.DictationIndicator.processing', 'Processing…') + : translate('auto.components.dictation.DictationIndicator.listening', 'Listening') + const label = isClipping + ? translate('auto.components.dictation.DictationIndicator.tooLoud', 'Too loud') + : isSpeaking + ? translate('auto.components.dictation.DictationIndicator.speaking', 'Speaking') + : lifecycleLabel + const announcedLabel = isClipping ? label : lifecycleLabel const canStop = dictationState !== 'stopping' - // Hold mode stops on key release, so a "press ⌘E" chip would misstate the binding. const showShortcut = !isHoldMode && shortcut.keys.length > 0 + const transcript = partialTranscript.trim() const stopLabel = translate( 'auto.components.dictation.DictationIndicator.335e1bc6cb', 'Stop dictation' ) return ( -
- - {label} - {canStop ? ( - <> - - - - - - - {stopLabel} - {showShortcut ? ( - - ) : null} - - - +
+
+ + + {label} + + + {announcedLabel} + + {canStop ? ( + <> + + + + + + + {stopLabel} + {showShortcut ? ( + + ) : null} + + + + ) : null} +
+ {transcript ? ( +

+ {transcript} +

) : null}
) diff --git a/src/renderer/src/components/dictation/dictation-audio-meter.test.ts b/src/renderer/src/components/dictation/dictation-audio-meter.test.ts new file mode 100644 index 00000000000..e7940664e02 --- /dev/null +++ b/src/renderer/src/components/dictation/dictation-audio-meter.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + analyzeDictationAudioChunk, + createDictationMeterAnalyzerState, + measureDictationAudioChunk, + toPublicDictationMeterState +} from './dictation-audio-meter' + +describe('dictation audio meter', () => { + it('measures RMS and clamps peaks from copied audio samples', () => { + expect(measureDictationAudioChunk(new Float32Array([0.5, -0.5]))).toEqual({ + rms: 0.5, + peak: 0.5 + }) + expect(measureDictationAudioChunk(new Float32Array([2]))).toEqual({ rms: 2, peak: 1 }) + }) + + it('uses a fast attack and slower release for a stable voice envelope', () => { + const initial = createDictationMeterAnalyzerState() + const loud = analyzeDictationAudioChunk(new Float32Array(128).fill(0.3), 100, initial) + const quiet = analyzeDictationAudioChunk(new Float32Array(128), 200, loud) + + expect(loud.level).toBeGreaterThan(0.5) + expect(loud.isSpeaking).toBe(true) + expect(quiet.level).toBeGreaterThan(0.3) + expect(quiet.level).toBeLessThan(loud.level) + }) + + it('holds clipping briefly so a single peak does not flicker', () => { + const initial = createDictationMeterAnalyzerState() + const clipped = analyzeDictationAudioChunk(new Float32Array([1]), 1_000, initial) + const held = analyzeDictationAudioChunk(new Float32Array([0]), 1_400, clipped) + const released = analyzeDictationAudioChunk(new Float32Array([0]), 1_600, held) + + expect(clipped.isClipping).toBe(true) + expect(held.isClipping).toBe(true) + expect(released.isClipping).toBe(false) + }) + + it('publishes only presentation-safe meter fields', () => { + const analyzed = analyzeDictationAudioChunk( + new Float32Array(128).fill(0.25), + 250, + createDictationMeterAnalyzerState() + ) + + expect(toPublicDictationMeterState(analyzed)).toEqual({ + level: Math.round(analyzed.level * 100) / 100, + isSpeaking: analyzed.isSpeaking, + isClipping: analyzed.isClipping + }) + }) +}) diff --git a/src/renderer/src/components/dictation/dictation-audio-meter.ts b/src/renderer/src/components/dictation/dictation-audio-meter.ts new file mode 100644 index 00000000000..c7f9c98117a --- /dev/null +++ b/src/renderer/src/components/dictation/dictation-audio-meter.ts @@ -0,0 +1,106 @@ +export type DictationMeterState = { + level: number + isSpeaking: boolean + isClipping: boolean +} + +export type DictationMeterAnalyzerState = DictationMeterState & { + peak: number + lastUpdatedAt: number + noiseFloor: number + smoothedLevel: number + clippingUntil: number +} + +export const DEFAULT_DICTATION_METER: DictationMeterState = { + level: 0, + isSpeaking: false, + isClipping: false +} + +const CLIPPING_THRESHOLD = 0.98 +const CLIPPING_HOLD_MS = 500 + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +export function createDictationMeterAnalyzerState(): DictationMeterAnalyzerState { + return { + ...DEFAULT_DICTATION_METER, + peak: 0, + lastUpdatedAt: 0, + noiseFloor: 0.008, + smoothedLevel: 0, + clippingUntil: Number.NEGATIVE_INFINITY + } +} + +export function measureDictationAudioChunk(samples: Float32Array): { + rms: number + peak: number +} { + if (samples.length === 0) { + return { rms: 0, peak: 0 } + } + + let sumSquares = 0 + let peak = 0 + for (const sample of samples) { + const absoluteSample = Math.abs(sample) + sumSquares += sample * sample + peak = Math.max(peak, absoluteSample) + } + return { + rms: Math.sqrt(sumSquares / samples.length), + peak: clamp(peak, 0, 1) + } +} + +export function analyzeDictationAudioChunk( + samples: Float32Array, + now: number, + previous: DictationMeterAnalyzerState +): DictationMeterAnalyzerState { + const { rms, peak } = measureDictationAudioChunk(samples) + const noiseFloor = Math.max( + 0.004, + previous.noiseFloor * 0.96 + Math.min(rms, previous.noiseFloor * 2) * 0.04 + ) + const rawLevel = clamp((rms - noiseFloor) / 0.16, 0, 1) + const smoothing = rawLevel > previous.smoothedLevel ? 0.58 : 0.2 + const smoothedLevel = previous.smoothedLevel + (rawLevel - previous.smoothedLevel) * smoothing + const clippingUntil = peak >= CLIPPING_THRESHOLD ? now + CLIPPING_HOLD_MS : previous.clippingUntil + + return { + level: smoothedLevel, + peak, + isSpeaking: smoothedLevel >= 0.1 || peak >= 0.18, + isClipping: now <= clippingUntil, + lastUpdatedAt: now, + noiseFloor, + smoothedLevel, + clippingUntil + } +} + +export function toPublicDictationMeterState( + state: DictationMeterAnalyzerState +): DictationMeterState { + return { + level: Math.round(state.level * 100) / 100, + isSpeaking: state.isSpeaking, + isClipping: state.isClipping + } +} + +export function dictationMeterStatesEqual( + left: DictationMeterState, + right: DictationMeterState +): boolean { + return ( + left.level === right.level && + left.isSpeaking === right.isSpeaking && + left.isClipping === right.isClipping + ) +} diff --git a/src/renderer/src/components/dictation/dictation-meter-store.test.tsx b/src/renderer/src/components/dictation/dictation-meter-store.test.tsx new file mode 100644 index 00000000000..07a74612734 --- /dev/null +++ b/src/renderer/src/components/dictation/dictation-meter-store.test.tsx @@ -0,0 +1,28 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { + publishDictationMeter, + resetDictationMeter, + useDictationMeter +} from './dictation-meter-store' + +beforeEach(resetDictationMeter) +afterEach(resetDictationMeter) + +it('notifies only the scoped meter consumer when presentation state changes', () => { + let renders = 0 + const { result } = renderHook(() => { + renders += 1 + return useDictationMeter() + }) + const speaking = { level: 0.72, isSpeaking: true, isClipping: false } + + act(() => publishDictationMeter(speaking)) + expect(result.current).toEqual(speaking) + expect(renders).toBe(2) + + act(() => publishDictationMeter({ ...speaking })) + expect(renders).toBe(2) +}) diff --git a/src/renderer/src/components/dictation/dictation-meter-store.ts b/src/renderer/src/components/dictation/dictation-meter-store.ts new file mode 100644 index 00000000000..66e97da6a0c --- /dev/null +++ b/src/renderer/src/components/dictation/dictation-meter-store.ts @@ -0,0 +1,29 @@ +import { e2eConfig } from '@/lib/e2e-config' +import { useStore } from 'zustand' +import { createStore } from 'zustand/vanilla' +import { + DEFAULT_DICTATION_METER, + dictationMeterStatesEqual, + type DictationMeterState +} from './dictation-audio-meter' + +const dictationMeterStore = createStore(() => DEFAULT_DICTATION_METER) + +export function publishDictationMeter(meter: DictationMeterState): void { + if (!dictationMeterStatesEqual(dictationMeterStore.getState(), meter)) { + dictationMeterStore.setState(meter, true) + } +} + +export function resetDictationMeter(): void { + dictationMeterStore.setState(DEFAULT_DICTATION_METER, true) +} + +export function useDictationMeter(): DictationMeterState { + return useStore(dictationMeterStore, (state) => state) +} + +if (e2eConfig.exposeStore && typeof window !== 'undefined') { + const testWindow = window as unknown as Record + testWindow.__dictationMeterE2E = { publish: publishDictationMeter } +} diff --git a/src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts b/src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts index 2f9dd125e91..f314b5c82f1 100644 --- a/src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts +++ b/src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts @@ -8,6 +8,12 @@ class FakeTrack extends EventTarget { stop = vi.fn() } +type FakeAudioProcessor = { + connect: ReturnType + disconnect: ReturnType + onaudioprocess: ((event: AudioProcessingEvent) => void) | null +} + function makeStream(track: FakeTrack): MediaStream { return { getTracks: () => [track], @@ -15,8 +21,8 @@ function makeStream(track: FakeTrack): MediaStream { } as unknown as MediaStream } -function installAudioContext(): void { - const processor = { +function installAudioContext(): FakeAudioProcessor { + const processor: FakeAudioProcessor = { connect: vi.fn(), disconnect: vi.fn(), onaudioprocess: null @@ -34,14 +40,16 @@ function installAudioContext(): void { close = vi.fn(async () => undefined) } ) + return processor } describe('useAudioCapture device loss', () => { let track: FakeTrack + let processor: FakeAudioProcessor beforeEach(() => { track = new FakeTrack() - installAudioContext() + processor = installAudioContext() vi.stubGlobal('navigator', { mediaDevices: { getUserMedia: vi.fn(async () => makeStream(track)), @@ -57,6 +65,7 @@ describe('useAudioCapture device loss', () => { }) afterEach(() => { + vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -80,4 +89,85 @@ describe('useAudioCapture device loss', () => { expect(onCaptureLost).not.toHaveBeenCalled() }) + + it('publishes the copied audio envelope without suppressing speech input', async () => { + const publishMeter = vi.fn() + const { result } = renderHook(() => useAudioCapture(publishMeter)) + await result.current.start({ sessionId: 'meter-session' }) + + processor.onaudioprocess?.({ + inputBuffer: { getChannelData: () => new Float32Array(128).fill(0.3) } + } as unknown as AudioProcessingEvent) + + expect(publishMeter).toHaveBeenLastCalledWith( + expect.objectContaining({ isSpeaking: true, level: expect.any(Number) }) + ) + expect(window.api.speech.feedAudio).toHaveBeenCalledWith( + expect.any(Float32Array), + 48_000, + 'meter-session' + ) + }) + + it('caps visual publications and deduplicates steady silence', async () => { + let now = 0 + vi.spyOn(performance, 'now').mockImplementation(() => now) + const publishMeter = vi.fn() + const { result } = renderHook(() => useAudioCapture(publishMeter)) + await result.current.start() + + const process = (sample: number): void => { + processor.onaudioprocess?.({ + inputBuffer: { getChannelData: () => new Float32Array(128).fill(sample) } + } as unknown as AudioProcessingEvent) + } + + process(0) + expect(publishMeter).toHaveBeenCalledTimes(1) + + now = 70 + process(0.3) + now = 90 + process(0) + expect(publishMeter).toHaveBeenCalledTimes(2) + + now = 140 + process(0) + expect(publishMeter).toHaveBeenCalledTimes(3) + }) + + it('skips hidden updates, resumes promptly, and resets immediately on stop', async () => { + let now = 0 + let hidden = true + vi.spyOn(performance, 'now').mockImplementation(() => now) + vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden) + const publishMeter = vi.fn() + const { result } = renderHook(() => useAudioCapture(publishMeter)) + await result.current.start() + + const process = (): void => { + processor.onaudioprocess?.({ + inputBuffer: { getChannelData: () => new Float32Array(128).fill(0.3) } + } as unknown as AudioProcessingEvent) + } + + process() + expect(publishMeter).toHaveBeenCalledTimes(1) + expect(window.api.speech.feedAudio).toHaveBeenCalledTimes(1) + + hidden = false + now = 10 + process() + expect(publishMeter).toHaveBeenCalledTimes(2) + + hidden = true + now = 20 + result.current.stop() + expect(publishMeter).toHaveBeenLastCalledWith({ + level: 0, + isSpeaking: false, + isClipping: false + }) + expect(publishMeter).toHaveBeenCalledTimes(3) + }) }) diff --git a/src/renderer/src/hooks/use-audio-capture.ts b/src/renderer/src/hooks/use-audio-capture.ts index 025e29092e2..702640ae1c9 100644 --- a/src/renderer/src/hooks/use-audio-capture.ts +++ b/src/renderer/src/hooks/use-audio-capture.ts @@ -1,5 +1,13 @@ import { useRef, useCallback } from 'react' import { openMicrophoneCaptureStream } from '@/components/dictation/microphone-devices' +import { + DEFAULT_DICTATION_METER, + analyzeDictationAudioChunk, + createDictationMeterAnalyzerState, + dictationMeterStatesEqual, + toPublicDictationMeterState, + type DictationMeterState +} from '@/components/dictation/dictation-audio-meter' type BufferedAudioChunk = { samples: Float32Array @@ -28,8 +36,11 @@ type StopAudioCaptureOptions = { const MAX_BUFFERED_AUDIO_SECONDS = 30 const MAX_BUFFERED_AUDIO_BYTES = 8 * 1024 * 1024 +const METER_PUBLISH_INTERVAL_MS = 1000 / 15 -export function useAudioCapture() { +type DictationMeterPublisher = (meter: DictationMeterState) => void + +export function useAudioCapture(publishMeter?: DictationMeterPublisher) { const streamRef = useRef(null) const contextRef = useRef(null) const processorRef = useRef(null) @@ -44,6 +55,9 @@ export function useAudioCapture() { const capturedChunkCountRef = useRef(0) const sessionIdRef = useRef('desktop') const trackLostCleanupRef = useRef<(() => void) | null>(null) + const meterAnalyzerRef = useRef(createDictationMeterAnalyzerState()) + const publishedMeterRef = useRef(DEFAULT_DICTATION_METER) + const lastMeterPublishedAtRef = useRef(Number.NEGATIVE_INFINITY) const cleanupCaptureResources = useCallback(() => { trackLostCleanupRef.current?.() @@ -70,6 +84,13 @@ export function useAudioCapture() { bufferedAudioSecondsRef.current = 0 }, []) + const resetMeter = useCallback(() => { + meterAnalyzerRef.current = createDictationMeterAnalyzerState() + publishedMeterRef.current = DEFAULT_DICTATION_METER + lastMeterPublishedAtRef.current = Number.NEGATIVE_INFINITY + publishMeter?.(DEFAULT_DICTATION_METER) + }, [publishMeter]) + const removeOldestBufferedAudioChunk = useCallback(() => { const chunk = bufferedAudioRef.current.shift() if (!chunk) { @@ -112,6 +133,7 @@ export function useAudioCapture() { bufferAudioRef.current = options.bufferAudio ?? false resetBufferedAudio() capturedChunkCountRef.current = 0 + resetMeter() const { stream, fellBackToDefaultMicrophone } = await openMicrophoneCaptureStream({ preferredDeviceId: options.microphoneDeviceId, @@ -175,6 +197,23 @@ export function useAudioCapture() { return } const samples = new Float32Array(e.inputBuffer.getChannelData(0)) + const now = performance.now() + meterAnalyzerRef.current = analyzeDictationAudioChunk( + samples, + now, + meterAnalyzerRef.current + ) + if ( + !document.hidden && + now - lastMeterPublishedAtRef.current >= METER_PUBLISH_INTERVAL_MS + ) { + lastMeterPublishedAtRef.current = now + const meter = toPublicDictationMeterState(meterAnalyzerRef.current) + if (!dictationMeterStatesEqual(publishedMeterRef.current, meter)) { + publishedMeterRef.current = meter + publishMeter?.(meter) + } + } capturedChunkCountRef.current += 1 if (bufferAudioRef.current) { appendBufferedAudioChunk({ @@ -242,7 +281,13 @@ export function useAudioCapture() { throw err } }, - [appendBufferedAudioChunk, cleanupCaptureResources, resetBufferedAudio] + [ + appendBufferedAudioChunk, + cleanupCaptureResources, + publishMeter, + resetBufferedAudio, + resetMeter + ] ) const flushBufferedAudio = useCallback(async () => { @@ -285,8 +330,9 @@ export function useAudioCapture() { resetBufferedAudio() } cleanupCaptureResources() + resetMeter() }, - [cleanupCaptureResources, resetBufferedAudio] + [cleanupCaptureResources, resetBufferedAudio, resetMeter] ) return { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 59ba7e3237c..05c84a4b55e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14610,7 +14610,12 @@ "micDisconnected": "Microphone disconnected. Dictation stopped." }, "DictationIndicator": { - "335e1bc6cb": "Stop dictation" + "335e1bc6cb": "Stop dictation", + "tooLoud": "Too loud", + "speaking": "Speaking", + "starting": "Starting mic…", + "processing": "Processing…", + "listening": "Listening" } }, "dashboard": { diff --git a/tests/e2e/dictation-indicator.spec.ts b/tests/e2e/dictation-indicator.spec.ts new file mode 100644 index 00000000000..b7028c347e8 --- /dev/null +++ b/tests/e2e/dictation-indicator.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from './helpers/orca-app' +import type { Page } from '@stablyai/playwright-test' + +type MeterFixture = { + level: number + isSpeaking: boolean + isClipping: boolean +} + +async function setDictationVisualState( + page: Page, + state: 'listening' | 'stopping', + meter: MeterFixture, + partialTranscript = '' +): Promise { + await page.evaluate( + ({ dictationState, transcript }) => { + const store = window.__store + if (!store) { + throw new Error('Expected the E2E store to be exposed') + } + store.setState({ + dictationState, + partialTranscript: transcript + }) + }, + { dictationState: state, transcript: partialTranscript } + ) + await page.waitForFunction(() => Boolean(window.__dictationMeterE2E)) + await page.evaluate((dictationMeter) => { + window.__dictationMeterE2E?.publish(dictationMeter) + }, meter) +} + +async function pauseForRecordedProof(page: Page): Promise { + if (process.env.ORCA_E2E_RECORD_VIDEO === '1') { + await page.waitForTimeout(700) + } +} + +test('dictation grapes react across the visible recording lifecycle', async ({ orcaPage }) => { + const quiet = { level: 0, isSpeaking: false, isClipping: false } + await setDictationVisualState(orcaPage, 'listening', quiet) + + const indicator = orcaPage.getByTestId('dictation-indicator') + const status = indicator.getByRole('status') + await expect(indicator).toBeVisible() + await expect(status).toHaveText('Listening') + await expect(indicator.getByTestId('dictation-grapes').locator('span')).toHaveCount(9) + await expect(indicator.getByRole('button', { name: 'Stop dictation' })).toBeVisible() + await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) + await expect(indicator.getByTestId('dictation-grapes').locator('span').first()).toHaveCSS( + 'transition-property', + 'none' + ) + await orcaPage.emulateMedia({ reducedMotion: 'no-preference' }) + await pauseForRecordedProof(orcaPage) + + const speaking = { + level: 0.76, + isSpeaking: true, + isClipping: false + } + await setDictationVisualState(orcaPage, 'listening', speaking) + await expect(indicator.getByText('Speaking')).toBeVisible() + await expect(status).toHaveText('Listening') + await pauseForRecordedProof(orcaPage) + + const clipping = { ...speaking, level: 1, isClipping: true } + await setDictationVisualState(orcaPage, 'listening', clipping) + await expect(status).toHaveText('Too loud') + await expect(indicator).toHaveClass(/text-destructive/) + await pauseForRecordedProof(orcaPage) + + await setDictationVisualState( + orcaPage, + 'listening', + speaking, + 'The visualizer follows every word without covering the workspace.' + ) + await expect( + orcaPage.getByText('The visualizer follows every word without covering the workspace.') + ).toBeVisible() + await expect(status).toHaveText('Listening') + await pauseForRecordedProof(orcaPage) + + await setDictationVisualState(orcaPage, 'stopping', quiet) + await expect(status).toHaveText('Processing…') + await expect(indicator.getByRole('button', { name: 'Stop dictation' })).toHaveCount(0) + await pauseForRecordedProof(orcaPage) +}) diff --git a/tests/e2e/helpers/runtime-types.ts b/tests/e2e/helpers/runtime-types.ts index 99ce5b76fbe..215e1ce5bcc 100644 --- a/tests/e2e/helpers/runtime-types.ts +++ b/tests/e2e/helpers/runtime-types.ts @@ -11,6 +11,7 @@ import type { Repo } from '../../../src/shared/repo-types' import type { WorkspaceVisibleTabType } from '../../../src/shared/tab-types' import type { TerminalTab } from '../../../src/shared/terminal-tab-types' import type { Worktree } from '../../../src/shared/worktree/types' +import type { DictationMeterState } from '../../../src/renderer/src/components/dictation/dictation-audio-meter' // Why: window.__store is the Zustand bound store itself, so specs get the whole StoreApi. export type AppStore = { @@ -63,6 +64,7 @@ declare global { // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface interface Window { __store?: AppStore + __dictationMeterE2E?: { publish(meter: DictationMeterState): void } __paneManagers?: Map } } From 03fcfdfb92f63ae53f6db5b7b67169f75148f32d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:47:46 -0700 Subject: [PATCH 19/27] feat(orcad): boot the Orca runtime on plain Node (#15968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(host): resolve the app root through the port in fork-reachable modules `parcel-watcher-entry-path.ts` and `session-scanner-service-entry-path.ts` read the app root via `require('electron').app` inside a try/catch that already returns null when Electron is absent. They were therefore correct under plain Node at runtime and only failed the *static* text check — which is real, not pedantic: the comment in `ports/port-scan-command-client.ts:19` records that the plain-node-entry-guard fails on that literal text, try/catch or not. `hasAppEnvironment() ? getAppEnvironment() : null` gives the identical "no app root here" answer without the text. That restores `hasAppEnvironment`, which an earlier commit in this stack deleted as unused — it now has the caller it was waiting for. Ratchet baseline 27 → 25. Verified: 74 files / 458 tests; `pnpm typecheck` clean; `oxlint` clean. * feat(orcad): boot the Orca runtime on plain Node Closes the last two Electron couplings and makes `orcad` a working artifact: a 4.43 MB Node bundle that boots, pairs, registers a repo, creates a real git worktree and round-trips a PTY — with zero `require("electron")`. Ratchet 2 -> 0, so `config/runtime-electron-baseline.txt` is now empty and its test asserts exactly that: any reachable electron import is a regression. - speech: inject the service factories, so importing ModelManager for its type no longer drags Electron's streaming net.request into the graph - filesystem-watcher: add a WorktreeWatcherRemoval port. Every entry in those maps arrives through an ipcMain handler carrying a renderer sender, so a host with no renderer has nothing to close, restore or forget — the inert default is what the desktop code does against empty maps, not a stub hiding work - user-data-path / profile-storage-paths: resolve userData through AppEnvironment. These surfaced only once orcad pulled the store in Both host ports now anchor to a realm-global symbol. `vi.resetModules()` gives the re-imported graph a fresh module copy, so a binding installed before the reset silently read back as uninstalled. The acceptance smoke drives both hosts through one code path (`--target orcad|electron`) and seeds its own git repo, so it is hermetic and asserts the same contract of each. Wired into PR CI. * test(smoke): remove the seeded workspace container, not just the worktree * test(smoke): surface the server's stderr when it dies before ready * fix(smoke): build node-pty for Node before booting orcad in CI * fix(smoke): drive the CLI built from this checkout, not one on PATH * docs(ratchet): say the baseline must stay empty, not merely shrink * build(orcad): externalize only the native modules actually in the graph --- .github/workflows/pr.yml | 6 + config/runtime-electron-baseline.txt | 7 +- config/scripts/build-orcad.mjs | 18 +- .../check-runtime-electron-ratchet.mjs | 5 +- .../check-runtime-electron-ratchet.test.mjs | 9 +- .../scripts/runtime-serve-terminal-smoke.mjs | 244 ++++++++++++++---- package.json | 2 + src/main/automations/service.test.ts | 3 + src/main/host/electron-speech-services.ts | 9 + src/main/index.ts | 9 +- src/main/ipc/filesystem-watcher.ts | 14 + .../ipc/orca-profile-auth-handlers.test.ts | 5 +- src/main/ipc/orca-profiles.test.ts | 7 +- src/main/ipc/worktree-watcher-removal.ts | 42 +++ .../orca-profiles/profile-index-store.test.ts | 12 +- .../orca-profiles/profile-storage-paths.ts | 6 +- src/main/orcad/main.ts | 8 + src/main/orcad/orcad-entry.ts | 168 +++++++++++- .../persistence-async-write-syscalls.test.ts | 4 + ...tence-duplicate-repo-id-host-scope.test.ts | 4 + ...tence-protected-secret-fail-closed.test.ts | 4 + ...stence-protected-secret-write-race.test.ts | 4 + .../persistence-proxy-secret-recovery.test.ts | 4 + src/main/persistence-single-serialize.test.ts | 4 + src/main/persistence-test-harness.ts | 5 + src/main/persistence-ui-state.test.ts | 4 + ...tence-workspace-session-scrollback.test.ts | 7 + ...rsistence-worktree-name-retirement.test.ts | 4 + .../loading-store/user-data-path.ts | 10 +- .../quit-path-durable-write-blocking.test.ts | 3 + .../mobile-pairing-userdata-path.test.ts | 14 +- src/main/runtime/orca-runtime.test.ts | 20 +- src/main/runtime/orca-runtime.ts | 21 +- src/main/speech/speech-runtime-service.ts | 40 ++- 34 files changed, 603 insertions(+), 123 deletions(-) create mode 100644 src/main/host/electron-speech-services.ts create mode 100644 src/main/ipc/worktree-watcher-removal.ts create mode 100644 src/main/orcad/main.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 636aeed1b92..01724348ba6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -119,6 +119,12 @@ jobs: - name: Enforce runtime Electron-import ratchet run: pnpm run check:runtime-electron-ratchet + # Why both: the ratchet proves nothing reachable from the runtime imports electron, + # which is a property of the import graph. This proves the Node artifact it enables + # actually boots, pairs, creates a worktree and round-trips a real PTY. + - name: Boot orcad and round-trip a terminal + run: pnpm run smoke:orcad-terminal + - name: Verify bundled skill guides run: pnpm run verify:bundled-skill-guides diff --git a/config/runtime-electron-baseline.txt b/config/runtime-electron-baseline.txt index d3af9f74c23..f392f70ecc9 100644 --- a/config/runtime-electron-baseline.txt +++ b/config/runtime-electron-baseline.txt @@ -1,7 +1,6 @@ # Modules reachable from the Orca runtime that import `electron`. # Generated by config/scripts/check-runtime-electron-ratchet.mjs. -# 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/). +# This list is EMPTY and must stay that way: the runtime boots on plain Node +# (see `pnpm run build:orcad`). Any entry means the runtime got less portable; +# migrate the module behind a host port instead (src/main/host/). -src/main/ipc/filesystem-watcher.ts -src/main/speech/model-manager.ts diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index c679e2351ba..989efd2d8e7 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -14,21 +14,19 @@ 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') +const ENTRY = join(ROOT, 'src/main/orcad/main.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 only these: measured, not guessed. `node-pty` is a hard `require.resolve` — orcad +// exits at startup without it. `@parcel/watcher` is a guarded dynamic import, so the +// server boots without it but every watch install fails. `fsevents` is macOS-only and +// optional upstream. better-sqlite3 / keytar / cpu-features were externalized here +// defensively and appear nowhere in the graph; listing them implied a shipping burden +// that does not exist. +const EXTERNAL = ['electron', 'node-pty', '@parcel/watcher', 'fsevents'] /** Why: the UMD build's relative dynamic requires do not bundle. Same fix build-relay.mjs uses. */ const jsoncParserEsm = { diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index 5fe3fb18c1a..c50d5df8f46 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -112,8 +112,9 @@ function renderBaseline(files) { return [ '# Modules reachable from the Orca runtime that import `electron`.', '# Generated by config/scripts/check-runtime-electron-ratchet.mjs.', - '# 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/).', + '# This list is EMPTY and must stay that way: the runtime boots on plain Node', + '# (see `pnpm run build:orcad`). Any entry means the runtime got less portable;', + '# migrate the module behind a host port instead (src/main/host/).', '', ...files ].join('\n') diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index ba75875c20f..e993ca32fc5 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -45,9 +45,12 @@ describe('the checked-in baseline', () => { expect(diffAgainstBaseline(current, baseline)).toEqual({ added: [], removed: [] }) }, 120_000) - it('only lists modules under src/, so a node_modules path cannot pad the count', () => { + // Why an exact-empty assertion now: the reachable set reached zero, so "may only + // shrink" has no room left and any entry at all is a regression. This is strictly + // stronger than the old under-src/ check, which only stopped a node_modules path from + // padding a non-empty count. + it('stays empty, so nothing reachable from the runtime imports electron', () => { const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8')) - expect(baseline.length).toBeGreaterThan(0) - expect(baseline.filter((entry) => !entry.startsWith('src/'))).toEqual([]) + expect(baseline).toEqual([]) }) }) diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs index a9e0dca7ced..21b6cea6b0b 100644 --- a/config/scripts/runtime-serve-terminal-smoke.mjs +++ b/config/scripts/runtime-serve-terminal-smoke.mjs @@ -20,14 +20,15 @@ * - the server exits when asked. */ import { spawn, spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { dirname, 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 ORCAD_ENTRY = join(projectDir, 'out', 'orcad', 'orcad.js') const READY_TIMEOUT_MS = 120_000 const OUTPUT_TIMEOUT_MS = 30_000 const SHUTDOWN_TIMEOUT_MS = 15_000 @@ -43,14 +44,30 @@ function fail(message) { process.exitCode = 1 } +/** + * Prefer the CLI built from this checkout over whatever `orca` is on PATH: it is the + * version under test, and a CI runner has no installed Orca app to fall back on. + */ +function resolveCli() { + const built = join(projectDir, 'out', 'cli', 'index.js') + return existsSync(built) + ? { command: process.execPath, prefix: [built] } + : { command: 'orca', prefix: [] } +} + /** 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 - }) + const cli = resolveCli() + const result = spawnSync( + cli.command, + [...cli.prefix, ...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}`) } @@ -70,10 +87,17 @@ function orca(pairingCode, args) { function waitForReady(child) { return new Promise((resolvePromise, rejectPromise) => { let buffered = '' + let serverErr = '' const timer = setTimeout( () => rejectPromise(new Error(`no ready payload within ${READY_TIMEOUT_MS}ms`)), READY_TIMEOUT_MS ) + // Why read stderr at all: an unread pipe can fill and block the child, and without it + // a boot failure surfaces only as "exited with 1", which says nothing actionable. + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk) => { + serverErr += chunk + }) child.stdout.setEncoding('utf8') child.stdout.on('data', (chunk) => { buffered += chunk @@ -95,7 +119,13 @@ function waitForReady(child) { }) child.on('exit', (code) => { clearTimeout(timer) - rejectPromise(new Error(`server exited with ${code} before signalling ready`)) + rejectPromise( + new Error( + `server exited with ${code} before signalling ready${ + serverErr.trim() ? `:\n${serverErr.trim()}` : ' (no stderr)' + }` + ) + ) }) }) } @@ -125,50 +155,127 @@ async function waitForNonce(pairingCode, terminalHandle, nonce) { return false } +/** + * The two hosts this acceptance drives. + * + * Everything after boot — pairing, worktree list, terminal create, the nonce round trip — + * is identical for both. That is the point: the Node artifact has to satisfy the same + * contract as the Electron server, proven by the same code rather than a parallel test + * that could drift into asserting less. + */ +function resolveLaunch(userDataDir) { + // Why a flag and not just an env var: package scripts have to set this on Windows too, + // and `FOO=bar cmd` is not portable there. + const flagIndex = process.argv.indexOf('--target') + const target = + flagIndex !== -1 ? process.argv[flagIndex + 1] : (process.env.ORCA_SMOKE_TARGET ?? 'electron') + if (target === 'orcad') { + return { + label: `orcad (${ORCAD_ENTRY})`, + command: process.execPath, + args: [ORCAD_ENTRY, '--port', String(PORT), '--json'], + env: { ORCA_USER_DATA: userDataDir } + } + } + if (target !== 'electron') { + throw new Error( + `--target (or ORCA_SMOKE_TARGET) must be 'electron' or 'orcad', got '${target}'` + ) + } + const serveArgs = [ + serveEntry, + '--serve', + '--serve-port', + String(PORT), + '--serve-json', + `--user-data-dir=${userDataDir}` + ] + const override = process.env.ORCA_SMOKE_ELECTRON + return { + label: `electron (${serveEntry})`, + command: override ?? 'npx', + args: override ? serveArgs : ['electron', ...serveArgs], + env: {} + } +} + +/** A throwaway git repo with one commit, so `repo add` has something real to register. */ +function seedGitRepo() { + const dir = mkdtempSync(join(tmpdir(), 'orca-smoke-repo-')) + writeFileSync(join(dir, 'README.md'), '# orca smoke\n') + const git = (...args) => { + const result = spawnSync('git', args, { cwd: dir, encoding: 'utf8' }) + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`) + } + } + git('init', '-b', 'main') + git('config', 'user.email', 'smoke@orca.test') + git('config', 'user.name', 'Orca Smoke') + git('add', '-A') + git('commit', '-m', 'seed') + return dir +} + async function main() { const userDataDir = mkdtempSync(join(tmpdir(), 'orca-serve-smoke-')) - log(`booting ${serveEntry} on port ${PORT} with userData ${userDataDir}`) + const launch = resolveLaunch(userDataDir) + log(`booting ${launch.label} 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'] } - ) + // Why tracked out here: the worktree lands in the real workspaces root, not the temp + // profile, so the finally block has to remove it explicitly or every run leaks one. + let seeded = null + let pairing = null + + const child = spawn(launch.command, launch.args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, ...launch.env } + }) try { const ready = await waitForReady(child) log(`ready: ${ready.advertisedEndpoint}`) const pairingCode = pairingCodeFrom(ready) + pairing = pairingCode - const worktrees = orca(pairingCode, ['worktree', 'list'])?.worktrees ?? [] - if (worktrees.length === 0) { - throw new Error('paired client saw no worktrees; cannot create a terminal') + // Why seed instead of using whatever the profile already holds: a hermetic repo makes + // this runnable on a clean CI box, keeps the assertion deterministic, and exercises + // repo.add + worktree.create rather than assuming someone else registered a worktree. + const repoPath = seedGitRepo() + seeded = { repoPath } + log(`seeded repo at ${repoPath}`) + const repo = orca(pairingCode, ['repo', 'add', '--path', repoPath])?.repo + if (!repo?.id) { + throw new Error('repo.add returned no repo id') } - log(`paired client sees ${worktrees.length} worktree(s)`) - const terminal = orca(pairingCode, [ - 'terminal', + const worktreeName = `smoke-${randomBytes(4).toString('hex')}` + const created = orca(pairingCode, [ + 'worktree', 'create', - '--worktree', - worktrees[0].id - ])?.terminal + '--repo', + `id:${repo.id}`, + '--name', + worktreeName, + '--setup', + 'skip' + ])?.worktree + if (!created?.id) { + throw new Error('worktree.create returned no worktree id') + } + seeded.worktreeId = created.id + log(`created worktree ${created.id}`) + + // Why `show` and not membership in `list`: list is capped, and the Electron target + // reads a shared dev profile that can already hold more worktrees than the cap. The + // point is that the server persisted and can resolve THIS worktree. + const shown = orca(pairingCode, ['worktree', 'show', '--worktree', created.id])?.worktree + if (shown?.id !== created.id) { + throw new Error('worktree.create succeeded but worktree.show cannot resolve it') + } + log(`server resolves ${shown.id}`) + + const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal if (!terminal?.handle) { throw new Error('terminal.create returned no handle') } @@ -196,16 +303,55 @@ async function main() { } 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`) + // Why before SIGTERM: worktree removal is a server operation, so it needs the server. + if (seeded?.worktreeId && pairing) { + const cleanupCli = resolveCli() + const removed = spawnSync( + cleanupCli.command, + [ + ...cleanupCli.prefix, + 'worktree', + 'rm', + '--worktree', + seeded.worktreeId, + '--pairing-code', + pairing, + '--force', + '--json' + ], + { encoding: 'utf8' } + ) + // Why the parent too: `worktree rm` removes the worktree directory, leaving the + // empty `//` container behind. Every run would leak one. + const worktreePath = seeded.worktreeId.split('::')[1] + if (removed.status === 0 && worktreePath) { + rmSync(dirname(worktreePath), { recursive: true, force: true }) + } + if (removed.status !== 0) { + log( + `WARN: could not remove seeded worktree ${seeded.worktreeId}: ` + + `${(removed.stderr?.trim() || removed.stdout?.trim() || '').replace(/\s+/g, ' ')} (exit ${removed.status})` + ) + } + } + // Why the exitCode guard: a server that died during boot has already exited, and + // waiting for a second 'exit' that will never fire reported a bogus shutdown failure + // stacked on top of the real error. + if (child.exitCode === null && child.signalCode === null) { + 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 (seeded?.repoPath) { + rmSync(seeded.repoPath, { recursive: true, force: true }) + } } if (!process.exitCode) { diff --git a/package.json b/package.json index 5c20829e8b0..a026bdb7824 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "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", + "build:orcad": "node config/scripts/build-orcad.mjs", + "smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad", "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", diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 27f96400258..0b546d5a46e 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import type { Repo } from '../../shared/repo-types' import { toRuntimeExecutionHostId } from '../../shared/execution-host' import { AutomationService } from './service' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -21,6 +22,8 @@ vi.mock('electron', () => ({ async function createStore() { vi.resetModules() + // Why: userData resolves through AppEnvironment; point it at this file's temp dir. + installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('../persistence') initDataPath() return new Store() diff --git a/src/main/host/electron-speech-services.ts b/src/main/host/electron-speech-services.ts new file mode 100644 index 00000000000..52ccf9099c1 --- /dev/null +++ b/src/main/host/electron-speech-services.ts @@ -0,0 +1,9 @@ +import { ModelManager } from '../speech/model-manager' +import { SttService } from '../speech/stt-service' +import type { SpeechServiceFactories } from '../speech/speech-runtime-service' + +/** The desktop speech factories. Importing this file is what pulls Electron's net in. */ +export const electronSpeechServiceFactories: SpeechServiceFactories = { + createModelManager: (customModelsDir) => new ModelManager(customModelsDir), + createSttService: (models) => new SttService(models) +} diff --git a/src/main/index.ts b/src/main/index.ts index fc141e98988..42abcd75590 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -29,6 +29,9 @@ import { electronRuntimeBrowserCommandsFactory } from './host/electron-browser-c import { setRuntimeBrowserCommandsFactory } from './runtime/runtime-browser-commands-factory' import { electronHttpClient } from './host/electron-http-client' import { setMainHttpClient } from './network/http-client' +import { electronSpeechServiceFactories } from './host/electron-speech-services' +import { setSpeechServiceFactories } from './speech/speech-runtime-service' +import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal' import { setSecretStore } from '../shared/secret-store' import { ElectronSecretStore } from './host/electron-secret-store' import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' @@ -67,7 +70,7 @@ import { isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys } from './codex/codex-pane-account-registry' -import { closeAllWatchers } from './ipc/filesystem-watcher' +import { closeAllWatchers, desktopWorktreeWatcherRemoval } from './ipc/filesystem-watcher' import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' import { stopFolderRepoGitUpgradeWatch } from './ipc/folder-repo-git-upgrade' import { registerCoreHandlers } from './ipc/register-core-handlers' @@ -902,6 +905,10 @@ if (hasSingleInstanceLock) { // 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 here: constructing the speech services is what pulls Electron's streaming net + // request in. A host without them rejects speech calls rather than pretending. + setSpeechServiceFactories(electronSpeechServiceFactories) + setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval) // 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/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 0a337967386..71830efc7c2 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -39,6 +39,7 @@ import { } from './watcher-removal-drain' // Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows). import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore' +import type { WorktreeWatcherRemoval } from './worktree-watcher-removal' // ── Per-root watcher state ─────────────────────────────────────────── // WatchedRoot/WatcherSubscription live in filesystem-watcher-wsl.ts so native and WSL watchers share one shape. @@ -1959,3 +1960,16 @@ export async function closeAllWatchers(): Promise { } remoteWatchers.clear() } + +/** The desktop binding for {@link WorktreeWatcherRemoval}. Installed during startup. */ +export const desktopWorktreeWatcherRemoval: WorktreeWatcherRemoval = { + closeLocal: (worktreePath, deadline) => + deadline + ? closeLocalWatcherForWorktreePath(worktreePath, deadline) + : closeLocalWatcherForWorktreePath(worktreePath), + restoreLocal: restoreLocalWatcherAfterFailedRemoval, + forgetLocal: forgetLocalWatcherRemovalSnapshot, + closeRemote: closeRemoteWatcherForWorktreePath, + restoreRemote: restoreRemoteWatcherAfterFailedRemoval, + forgetRemote: forgetRemoteWatcherRemovalSnapshot +} diff --git a/src/main/ipc/orca-profile-auth-handlers.test.ts b/src/main/ipc/orca-profile-auth-handlers.test.ts index 73726dd2b52..e16d949dc1a 100644 --- a/src/main/ipc/orca-profile-auth-handlers.test.ts +++ b/src/main/ipc/orca-profile-auth-handlers.test.ts @@ -21,7 +21,6 @@ const { vi.mock('electron', () => ({ app: { exit: vi.fn(), - getPath: () => '/tmp/orca-user-data', relaunch: vi.fn() }, ipcMain: { @@ -56,9 +55,13 @@ vi.mock('../orca-profiles/profile-cloud-service', () => ({ })) import { registerOrcaProfileHandlers } from './orca-profiles' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' describe('registerOrcaProfileHandlers auth channels', () => { beforeEach(() => { + // Why the port and per-test: userData resolves through AppEnvironment now, and + // the global setup's beforeEach reinstates its own fake before this runs. + installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' }) handlers.clear() createCloudLinkedOrcaProfileMock.mockReset() connectCurrentOrcaProfileMock.mockReset() diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts index fe69ce2e138..215b559327d 100644 --- a/src/main/ipc/orca-profiles.test.ts +++ b/src/main/ipc/orca-profiles.test.ts @@ -30,8 +30,7 @@ vi.mock('electron', () => ({ app: { exit: appExitMock, quit: appQuitMock, - relaunch: appRelaunchMock, - getPath: () => '/tmp/orca-user-data' + relaunch: appRelaunchMock }, ipcMain: { handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { @@ -68,9 +67,13 @@ vi.mock('../orca-profiles/profile-project-transfer', () => ({ })) import { registerOrcaProfileHandlers } from './orca-profiles' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' describe('registerOrcaProfileHandlers', () => { beforeEach(() => { + // Why the port and per-test: userData resolves through AppEnvironment now, and + // the global setup's beforeEach reinstates its own fake before this runs. + installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' }) vi.useFakeTimers() handlers.clear() appExitMock.mockReset() diff --git a/src/main/ipc/worktree-watcher-removal.ts b/src/main/ipc/worktree-watcher-removal.ts new file mode 100644 index 00000000000..b5de15592cd --- /dev/null +++ b/src/main/ipc/worktree-watcher-removal.ts @@ -0,0 +1,42 @@ +import type { WatcherRemovalDeadline } from './watcher-removal-drain' + +/** + * Removal-time coordination for the renderer-facing filesystem watchers. + * + * Worktree removal has to close any watcher holding the directory open, then either + * restore it (removal failed) or drop the snapshot (removal succeeded). The desktop + * implementation lives in `./filesystem-watcher`, where that bookkeeping is inseparable + * from the `WebContents` map it suspends and replays. + * + * Why the default is inert rather than a throw: every entry in those maps arrives through + * an `ipcMain` handler carrying a renderer `sender`. A host with no renderer never + * installs one, so there is nothing to close, restore, or forget — the no-op is what the + * desktop code itself would do against empty maps, not a silent stub hiding lost work. + */ +export type WorktreeWatcherRemoval = { + closeLocal(worktreePath: string, deadline?: WatcherRemovalDeadline): Promise + restoreLocal(worktreePath: string): Promise + forgetLocal(worktreePath: string): void + closeRemote(connectionId: string, worktreePath: string): Promise + restoreRemote(connectionId: string, worktreePath: string): Promise + forgetRemote(connectionId: string, worktreePath: string): void +} + +const inert: WorktreeWatcherRemoval = { + closeLocal: async () => {}, + restoreLocal: async () => {}, + forgetLocal: () => {}, + closeRemote: async () => {}, + restoreRemote: async () => {}, + forgetRemote: () => {} +} + +let current: WorktreeWatcherRemoval = inert + +export function setWorktreeWatcherRemoval(next: WorktreeWatcherRemoval | null): void { + current = next ?? inert +} + +export function getWorktreeWatcherRemoval(): WorktreeWatcherRemoval { + return current +} diff --git a/src/main/orca-profiles/profile-index-store.test.ts b/src/main/orca-profiles/profile-index-store.test.ts index 128dc56cea3..06f2efe0903 100644 --- a/src/main/orca-profiles/profile-index-store.test.ts +++ b/src/main/orca-profiles/profile-index-store.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -12,11 +13,10 @@ import { const testState = { dir: '' } -vi.mock('electron', () => ({ - app: { - getPath: () => testState.dir - } -})) +// Why the port and not vi.mock('electron'): profile path resolution reads AppEnvironment +// now, so an electron mock would be inert and every case would share the global fake's +// one temp dir instead of its own. +installFakeAppEnvironment({ getPath: () => testState.dir }) async function loadProfileIndexStore() { vi.resetModules() @@ -30,6 +30,8 @@ function readJson(path: string): unknown { describe('profile index store', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-test-')) + // Why re-install per test: the global setup's beforeEach reinstates its own fake. + installFakeAppEnvironment({ getPath: () => testState.dir }) }) afterEach(() => { diff --git a/src/main/orca-profiles/profile-storage-paths.ts b/src/main/orca-profiles/profile-storage-paths.ts index 5f8dc0df241..81dc990b0e1 100644 --- a/src/main/orca-profiles/profile-storage-paths.ts +++ b/src/main/orca-profiles/profile-storage-paths.ts @@ -1,4 +1,4 @@ -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' const LEGACY_DATA_FILE_NAME = 'orca-data.json' @@ -13,12 +13,12 @@ export const LEGACY_BACKUP_COUNT = 5 let profileUserDataPath: string | null = null export function initOrcaProfilePaths(): void { - profileUserDataPath = app.getPath('userData') + profileUserDataPath = getAppEnvironment().getPath('userData') } export function getProfileUserDataPath(): string { if (!profileUserDataPath) { - profileUserDataPath = app.getPath('userData') + profileUserDataPath = getAppEnvironment().getPath('userData') } return profileUserDataPath } diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts new file mode 100644 index 00000000000..e76ed873cb0 --- /dev/null +++ b/src/main/orcad/main.ts @@ -0,0 +1,8 @@ +/** Executable entry for `orcad`. See `./orcad-entry.ts`. */ +import process from 'node:process' +import { main } from './orcad-entry' + +main().catch((error: unknown) => { + console.error('orcad: failed to start:', error) + process.exit(1) +}) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 9080288cd53..621e89be5dc 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -14,6 +14,7 @@ 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' +import type { ServeReadiness } from '../server/serve-readiness' /** XDG-ish data root. `$ORCA_USER_DATA` wins so a smoke test can isolate state. */ function resolveUserDataPath(): string { @@ -83,16 +84,56 @@ export function installOrcadHostAdapters(): void { setSecretStore(createNodeSecretStore()) } -/** Boot the runtime and serve RPC. Returns once the transport is listening. */ -export async function startOrcad(options: { port?: number } = {}): Promise { +export type OrcadOptions = { + port?: number + json?: boolean + noPairing?: boolean + pairingAddress?: string +} + +export type OrcadHandle = { + readiness: ServeReadiness + stop(): Promise +} + +/** + * Boot the runtime and serve RPC. Resolves once the transport is listening and the + * readiness payload has been published, mirroring the desktop `--serve` contract byte + * for byte so the same harnesses can drive either host. + */ +export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') - const { registerPtyHandlers } = await import('../ipc/pty') + const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } = + await import('../ipc/pty') const { getAppEnvironment } = await import('../../shared/app-environment') + const { resolveAdvertisedPairingEndpoint } = await import('../runtime/pairing-endpoint') + const { ServeReadinessPublisher } = await import('../server/serve-readiness') + const { Store } = await import('../persistence/loading-store/store') + const { ensureActiveOrcaProfile, initOrcaProfilePaths } = + await import('../orca-profiles/profile-index-store') + const { initSshHostKeyStoreFile } = await import('../ssh/ssh-host-key-store') - const runtime = new OrcaRuntimeService(null, undefined, { + const userDataPath = getAppEnvironment().getPath('userData') + initOrcaProfilePaths() + const profile = ensureActiveOrcaProfile(userDataPath) + // Why a real Store: without one every persistence-backed RPC throws `runtime_unavailable` + // and the read paths that use `this.store?.x ?? []` quietly answer "empty" instead — + // a server that pairs and lists nothing looks healthy and is not. + const store = new Store({ dataFile: profile.dataFile }) + // Why: every SSH connect consults this sidecar. Left unbound it reports nothing trusted, + // which is safe but silently discards accept records on every launch. + initSshHostKeyStoreFile(profile.dataFile) + + const runtime = new OrcaRuntimeService(store, undefined, { + // Why lazy: a daemon swap replaces the provider after construction, so an eager + // reference would freeze the pre-daemon one. + getLocalProvider: () => getLocalPtyProvider(), + // Why: destructive worktree removal refuses to run without a provider to stop + // processes through — correctly, since it cannot otherwise verify the tree is idle. + getSshProvider: (connectionId) => getSshPtyProvider(connectionId), // 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. @@ -103,16 +144,125 @@ export async function startOrcad(options: { port?: number } = {}): Promise 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) + // Why the headless entry point rather than registerPtyHandlers(null, …): this is the + // same call `--serve` makes, and it threads the store through. Without the store the + // handlers install fine and every terminal.create then fails at persistence time. + // + // Codex-home and Claude-auth preparation are left unset: both are desktop account + // flows. A launch that needs one fails with its own message rather than silently + // spawning an unauthenticated agent. + registerHeadlessPtyRuntime(runtime, undefined, () => store.getSettings(), undefined, store) + + // Why: same post-registration reconciliation `--serve` performs. Skipping it leaves + // restored orchestration rows claiming an authority this host never took over. + await runtime.refreshRestoredOrchestrationAuthority() + await runtime.reconcileLegacyWorkerTerminals() const rpc = new OrcaRuntimeRpcServer({ runtime, - userDataPath: getAppEnvironment().getPath('userData'), + userDataPath, enableWebSocket: true, exposeNetworkByDefault: true, ...(options.port !== undefined ? { wsPort: options.port, preferPinnedWsPort: true } : {}) - } as never) + }) await rpc.start() + + const boundEndpoint = rpc.getWebSocketEndpoint() + const advertised = boundEndpoint + ? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress) + : null + const offer = options.noPairing + ? ({ + available: false, + reason: 'disabled_by_operator', + guidance: 'Restart without --no-pairing to create a client pairing offer.' + } as const) + : rpc.createPairingOffer({ + address: options.pairingAddress, + name: `CLI ${new Date().toLocaleDateString()}`, + scope: 'runtime' + }) + + const readiness: ServeReadiness = { + runtimeId: runtime.getRuntimeId(), + boundEndpoint, + advertisedEndpoint: advertised?.ok ? advertised.endpoint : null, + // Why 'settled': the WSL CLI reconciliation barrier is a desktop-launch concern. + // orcad never runs it, so there is no pending repair a client could race. + managedWslCliReconciliation: 'settled', + pairing: offer.available + ? { + available: true, + url: offer.pairingUrl, + endpoint: offer.endpoint, + deviceId: offer.deviceId, + webClientUrl: offer.webClientUrl, + scope: 'runtime', + qr: null + } + : offer + } + + await new ServeReadinessPublisher().publish(readiness, { + mode: options.json ? 'json' : 'human' + }) + + return { + readiness, + stop: async () => { + await rpc.stop() + } + } +} + +function parseArgs(argv: string[]): OrcadOptions { + const options: OrcadOptions = {} + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--port') { + const raw = argv[i + 1] + const port = Number(raw) + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`--port expects an integer 0-65535, got ${raw ?? "''"}`) + } + options.port = port + i += 1 + } else if (arg === '--json') { + options.json = true + } else if (arg === '--no-pairing') { + options.noPairing = true + } else if (arg === '--pairing-address') { + const value = argv[i + 1] + if (!value) { + throw new Error('--pairing-address expects a value') + } + options.pairingAddress = value + i += 1 + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + return options +} + +export async function main(argv: string[] = process.argv.slice(2)): Promise { + const handle = await startOrcad(parseArgs(argv)) + let stopping = false + const shutdown = (signal: NodeJS.Signals): void => { + if (stopping) { + return + } + stopping = true + handle + .stop() + .then(() => process.exit(0)) + // Why not rethrow: we are already tearing down on a signal, and an exit code is + // the only thing a supervisor can act on. + .catch((error) => { + console.error(`orcad: shutdown after ${signal} failed:`, error) + process.exit(1) + }) + } + process.on('SIGINT', () => shutdown('SIGINT')) + process.on('SIGTERM', () => shutdown('SIGTERM')) } diff --git a/src/main/persistence-async-write-syscalls.test.ts b/src/main/persistence-async-write-syscalls.test.ts index 2d6d581599e..3a4c1f11e87 100644 --- a/src/main/persistence-async-write-syscalls.test.ts +++ b/src/main/persistence-async-write-syscalls.test.ts @@ -13,6 +13,7 @@ import type * as NodeFsPromises from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { SshRemotePtyLeaseState } from '../shared/ssh-types' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -177,6 +178,9 @@ async function createStore(dir: string): Promise { testState.dir = dir vi.resetModules() const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() as unknown as TestStore } diff --git a/src/main/persistence-duplicate-repo-id-host-scope.test.ts b/src/main/persistence-duplicate-repo-id-host-scope.test.ts index f1a63e876ea..0da93c3a979 100644 --- a/src/main/persistence-duplicate-repo-id-host-scope.test.ts +++ b/src/main/persistence-duplicate-repo-id-host-scope.test.ts @@ -12,6 +12,7 @@ import type { Project, ProjectHostSetup } from '../shared/project-types' import type { Repo } from '../shared/repo-types' import { getDefaultPersistedState } from '../shared/constants' import { toRuntimeExecutionHostId } from '../shared/execution-host' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -52,6 +53,9 @@ async function createStoreFromState(state: Record) { ) vi.resetModules() const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() } diff --git a/src/main/persistence-protected-secret-fail-closed.test.ts b/src/main/persistence-protected-secret-fail-closed.test.ts index 9d407ab505b..c69233bb760 100644 --- a/src/main/persistence-protected-secret-fail-closed.test.ts +++ b/src/main/persistence-protected-secret-fail-closed.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { randomUUID } from 'node:crypto' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' type FailureMode = 'availability-throws' | 'encryption-throws' | 'unavailable' @@ -56,6 +57,9 @@ async function createStore() { describeUnavailable: () => null }) const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() } diff --git a/src/main/persistence-protected-secret-write-race.test.ts b/src/main/persistence-protected-secret-write-race.test.ts index 89849d22dde..e21cc02fd56 100644 --- a/src/main/persistence-protected-secret-write-race.test.ts +++ b/src/main/persistence-protected-secret-write-race.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import type * as NodeFsPromises from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } const cipherState = { available: true } @@ -53,6 +54,9 @@ async function createStore() { describeUnavailable: () => null }) const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() } diff --git a/src/main/persistence-proxy-secret-recovery.test.ts b/src/main/persistence-proxy-secret-recovery.test.ts index faa6f65822e..fb9c74c68be 100644 --- a/src/main/persistence-proxy-secret-recovery.test.ts +++ b/src/main/persistence-proxy-secret-recovery.test.ts @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, mkdtempSync, writeFileSync import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' import { randomUUID } from 'node:crypto' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -63,6 +64,9 @@ async function createStore() { describeUnavailable: () => null }) const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() } diff --git a/src/main/persistence-single-serialize.test.ts b/src/main/persistence-single-serialize.test.ts index 06512f9a089..00becc26d4b 100644 --- a/src/main/persistence-single-serialize.test.ts +++ b/src/main/persistence-single-serialize.test.ts @@ -8,6 +8,7 @@ import { readFileSync, rmSync, mkdtempSync, statSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { randomUUID } from 'node:crypto' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -58,6 +59,9 @@ async function createStore() { describeUnavailable: () => null }) const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() } diff --git a/src/main/persistence-test-harness.ts b/src/main/persistence-test-harness.ts index 94b2c62d238..89f5f03a88f 100644 --- a/src/main/persistence-test-harness.ts +++ b/src/main/persistence-test-harness.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { vi } from 'vitest' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' import type { Project, ProjectHostSetup } from '../shared/project-types' import type { Repo } from '../shared/repo-types' import type { TerminalTab } from '../shared/terminal-tab-types' @@ -13,6 +14,10 @@ export const testState = { dir: '' } /** Reset modules and dynamically import Store so the data-file path picks up the current testState.dir */ export async function createStore() { vi.resetModules() + // Why here and not a per-file vi.mock('electron'): the data-file path resolves through + // AppEnvironment now, and it must point at this test's dir rather than the global + // fake's shared one. Re-installed after resetModules so the fresh graph sees it. + installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() return new Store() diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index 7bb676ffe34..1ff2cda74ee 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -12,6 +12,7 @@ import { readDataFile, makeRepo } from './persistence-test-harness' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -50,6 +51,9 @@ async function createStore() { describeUnavailable: () => null }) const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() } diff --git a/src/main/persistence-workspace-session-scrollback.test.ts b/src/main/persistence-workspace-session-scrollback.test.ts index 1c98d9525a3..f57a2a1b603 100644 --- a/src/main/persistence-workspace-session-scrollback.test.ts +++ b/src/main/persistence-workspace-session-scrollback.test.ts @@ -18,6 +18,7 @@ import { makeSessionWithTerminalBuffers, makeSessionWithBrowserHistory } from './persistence-session-fixtures' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -188,6 +189,9 @@ describe('Store', () => { mkdirSync(profileDataDirectory, { recursive: true }) vi.resetModules() + // Why: the legacy snapshot dir hangs off userData, which resolves through + // AppEnvironment — without this it points at the global fake's shared dir. + installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) @@ -218,6 +222,9 @@ describe('Store', () => { writeFileSync(join(legacySnapshotDir, `${ref}.bin`), 'legacy-scrollback', 'utf-8') vi.resetModules() + // Why: the legacy snapshot dir hangs off userData, which resolves through + // AppEnvironment — without this it points at the global fake's shared dir. + installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) diff --git a/src/main/persistence-worktree-name-retirement.test.ts b/src/main/persistence-worktree-name-retirement.test.ts index dcfb35a5954..2f89775ce87 100644 --- a/src/main/persistence-worktree-name-retirement.test.ts +++ b/src/main/persistence-worktree-name-retirement.test.ts @@ -8,6 +8,7 @@ import { createRetiredNameLookup } from '../shared/worktree/retired-name-registr import type { SshTarget } from '../shared/ssh-types' import { MAX_RETIREMENT_NAMESPACES } from './worktree-retirement-namespace' import { getRuntimeOwnedSshTargetId } from './ssh/ssh-connection-store' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -45,6 +46,9 @@ function sshTarget(id: string, overrides: Partial = {}): SshTarget { async function reloadStore() { vi.resetModules() const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() } diff --git a/src/main/persistence/loading-store/user-data-path.ts b/src/main/persistence/loading-store/user-data-path.ts index db62f706b7b..6dd4b50b1c8 100644 --- a/src/main/persistence/loading-store/user-data-path.ts +++ b/src/main/persistence/loading-store/user-data-path.ts @@ -1,4 +1,4 @@ -import { app } from 'electron' +import { getAppEnvironment } from '../../../shared/app-environment' import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import type { PersistedState } from '../../../shared/persisted-state-types' @@ -11,7 +11,7 @@ let _dataFile: string | null = null let _userDataDir: string | null = null export function initDataPath(): void { - const userDataDir = app.getPath('userData') + const userDataDir = getAppEnvironment().getPath('userData') _userDataDir = userDataDir _dataFile = join(userDataDir, 'orca-data.json') } @@ -19,7 +19,7 @@ export function initDataPath(): void { export function getDataFile(): string { if (!_dataFile) { // Safety fallback — should not be hit in normal startup. - const userDataDir = app.getPath('userData') + const userDataDir = getAppEnvironment().getPath('userData') _userDataDir = userDataDir _dataFile = join(userDataDir, 'orca-data.json') } @@ -51,14 +51,14 @@ export function readGithubCacheSnapshot(dataFile: string): PersistedState['githu } /** - * Return the userData directory captured at initDataPath() time, before app.setName() can change how app.getPath('userData') resolves. + * Return the userData directory captured at initDataPath() time, before app.setName() can change how getAppEnvironment().getPath('userData') resolves. * * Subsystems sharing storage with orca-data.json read this instead of resolving late, which on case-sensitive FS can lose paired devices. */ export function getCanonicalUserDataPath(): string { if (!_userDataDir) { // Safety fallback — should not be hit in normal startup. - _userDataDir = app.getPath('userData') + _userDataDir = getAppEnvironment().getPath('userData') } return _userDataDir } diff --git a/src/main/quit-path-durable-write-blocking.test.ts b/src/main/quit-path-durable-write-blocking.test.ts index 35ebe7460c3..58e055c5297 100644 --- a/src/main/quit-path-durable-write-blocking.test.ts +++ b/src/main/quit-path-durable-write-blocking.test.ts @@ -4,6 +4,7 @@ import type * as NodeFs from 'node:fs' import type * as NodeFsPromises from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' // Why these tests exist: will-quit used to run stats.flush() and store.flush() synchronously, // before preventDefault(). On a stalled network profile mount those fsync/rename syscalls park @@ -115,6 +116,8 @@ async function createStore(dir: string): Promise { testState.dir = dir vi.resetModules() const { Store, initDataPath } = await import('./persistence') + // Why: userData resolves through AppEnvironment; point it at this file's temp dir. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() as unknown as TestStore } diff --git a/src/main/runtime/mobile-pairing-userdata-path.test.ts b/src/main/runtime/mobile-pairing-userdata-path.test.ts index be13c1ebbc3..441b5a57eb2 100644 --- a/src/main/runtime/mobile-pairing-userdata-path.test.ts +++ b/src/main/runtime/mobile-pairing-userdata-path.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' // Import from the production source of truth so a filename rename can't silently // pass these tests against stale names. import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' // Mutable userData the electron mock resolves. We flip it mid-test to simulate // app.setName('Orca') changing how app.getPath('userData') resolves (e.g. from @@ -15,8 +16,12 @@ import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairin // of whether the test host's filesystem is case-sensitive. const appState = { userData: '' } +// Why the port for getPath: userData now resolves through AppEnvironment, so an +// electron mock would be inert here. safeStorage is still mocked because the modules +// under test seal through it directly. +installFakeAppEnvironment({ getPath: () => appState.userData }) + vi.mock('electron', () => ({ - app: { getPath: () => appState.userData }, safeStorage: { isEncryptionAvailable: () => false, encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'), @@ -38,6 +43,8 @@ describe('mobile pairing userData path stability', () => { lateDir = join(root, 'userdata-late') mkdirSync(canonicalDir, { recursive: true }) mkdirSync(lateDir, { recursive: true }) + // Why re-install: the global setup's beforeEach reinstates its own fake. + installFakeAppEnvironment({ getPath: () => appState.userData }) vi.resetModules() }) @@ -55,8 +62,9 @@ describe('mobile pairing userData path stability', () => { appState.userData = lateDir expect(getCanonicalUserDataPath()).toBe(canonicalDir) - const { app } = await import('electron') - expect(getCanonicalUserDataPath()).not.toBe(app.getPath('userData')) + // Why the port: this is the "resolve late" path the captured value must differ from. + const { getAppEnvironment } = await import('../../shared/app-environment') + expect(getCanonicalUserDataPath()).not.toBe(getAppEnvironment().getPath('userData')) }) it('writes DeviceRegistry + E2EE keypair under the canonical path, not the late one', async () => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d1eaee981a0..3c7e27b062c 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -136,6 +136,7 @@ import { setTerminalViewAttributes } from './terminal-view-attribute-store' import { clearConfiguredWorktreeSharedDirectoriesCacheForTests } from '../git/worktree-shared-directories' +import { setWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal' const ORIGINAL_PLATFORM = process.platform const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') @@ -219,14 +220,17 @@ const forgetRemoteWatcherRemovalSnapshotMock = vi.hoisted(() => vi.fn()) const scanLocalRepoWorktreesForResolutionMock = vi.hoisted(() => vi.fn()) vi.mock('electron', () => electronMocks) -vi.mock('../ipc/filesystem-watcher', () => ({ - closeLocalWatcherForWorktreePath: closeLocalWatcherForWorktreePathMock, - closeRemoteWatcherForWorktreePath: closeRemoteWatcherForWorktreePathMock, - restoreLocalWatcherAfterFailedRemoval: restoreLocalWatcherAfterFailedRemovalMock, - restoreRemoteWatcherAfterFailedRemoval: restoreRemoteWatcherAfterFailedRemovalMock, - forgetLocalWatcherRemovalSnapshot: forgetLocalWatcherRemovalSnapshotMock, - forgetRemoteWatcherRemovalSnapshot: forgetRemoteWatcherRemovalSnapshotMock -})) +// Why install the port instead of mocking ../ipc/filesystem-watcher: the runtime calls +// WorktreeWatcherRemoval now, so a module mock would be inert and every assertion below +// would silently pass against the inert default. Same mocks, same expectations. +setWorktreeWatcherRemoval({ + closeLocal: closeLocalWatcherForWorktreePathMock, + closeRemote: closeRemoteWatcherForWorktreePathMock, + restoreLocal: restoreLocalWatcherAfterFailedRemovalMock, + restoreRemote: restoreRemoteWatcherAfterFailedRemovalMock, + forgetLocal: forgetLocalWatcherRemovalSnapshotMock, + forgetRemote: forgetRemoteWatcherRemovalSnapshotMock +}) const { MOCK_GIT_WORKTREES, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 03ba37f0d98..8ddbfa4fdf8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1136,14 +1136,7 @@ import { } from '../worktree-removal-repo-owner' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' -import { - closeLocalWatcherForWorktreePath, - closeRemoteWatcherForWorktreePath, - forgetLocalWatcherRemovalSnapshot, - forgetRemoteWatcherRemovalSnapshot, - restoreLocalWatcherAfterFailedRemoval, - restoreRemoteWatcherAfterFailedRemoval -} from '../ipc/filesystem-watcher' +import { getWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal' import { acquireWatcherRemovalGate } from '../ipc/watcher-removal-gate' import { createWatcherRemovalDeadline, @@ -10234,11 +10227,11 @@ export class OrcaRuntimeService { const results = await Promise.allSettled([ connectionId ? drainBeforeWatcherRemoval( - closeRemoteWatcherForWorktreePath(connectionId, worktreePath), + getWorktreeWatcherRemoval().closeRemote(connectionId, worktreePath), deadline, `remote watcher close for ${worktreePath}` ) - : closeLocalWatcherForWorktreePath(worktreePath, deadline), + : getWorktreeWatcherRemoval().closeLocal(worktreePath, deadline), drainBeforeWatcherRemoval( this.fileCommands.closeFileExplorerWatchersForPath(worktreePath, connectionId), deadline, @@ -10260,16 +10253,16 @@ export class OrcaRuntimeService { ): Promise => { await Promise.all([ connectionId - ? restoreRemoteWatcherAfterFailedRemoval(connectionId, worktreePath) - : restoreLocalWatcherAfterFailedRemoval(worktreePath), + ? getWorktreeWatcherRemoval().restoreRemote(connectionId, worktreePath) + : getWorktreeWatcherRemoval().restoreLocal(worktreePath), this.fileCommands.restoreFileExplorerWatchersAfterFailedRemoval(worktreePath, connectionId) ]) } forgetFileWatchersAfterRemoval = (worktreePath: string, connectionId?: string): void => { if (connectionId) { - forgetRemoteWatcherRemovalSnapshot(connectionId, worktreePath) + getWorktreeWatcherRemoval().forgetRemote(connectionId, worktreePath) } else { - forgetLocalWatcherRemovalSnapshot(worktreePath) + getWorktreeWatcherRemoval().forgetLocal(worktreePath) } this.fileCommands.forgetFileExplorerWatchersAfterRemoval(worktreePath, connectionId) } diff --git a/src/main/speech/speech-runtime-service.ts b/src/main/speech/speech-runtime-service.ts index 97f963ae57b..f5a65a939dc 100644 --- a/src/main/speech/speech-runtime-service.ts +++ b/src/main/speech/speech-runtime-service.ts @@ -1,28 +1,60 @@ -import { ModelManager } from './model-manager' -import { SttService } from './stt-service' +import type { ModelManager } from './model-manager' +import type { SttService } from './stt-service' import type { VoiceSettings } from '../../shared/speech-types' +/** + * Lazy accessors for the speech services. + * + * Why the construction is injected: `ModelManager` downloads models through Electron's + * streaming `net.request` — byte-range resume, progress events, a manual idle timeout — + * and `SttService` resolves paths inside the packaged app. Importing either for its + * *type* is free; constructing one is what pulls Electron in. + * + * The desktop installs the factories. A host without them rejects per call rather than + * returning a stub that silently does nothing: speech is a desktop feature, and a + * headless host saying so is more useful than one that appears to transcribe. + */ + type SpeechSettingsStore = { getSettings(): { voice?: VoiceSettings } } +export type SpeechServiceFactories = { + createModelManager(customModelsDir: string | undefined): ModelManager + createSttService(models: ModelManager): SttService +} + +let factories: SpeechServiceFactories | null = null let modelManager: ModelManager | null = null let sttService: SttService | null = null +export function setSpeechServiceFactories(next: SpeechServiceFactories | null): void { + factories = next + modelManager = null + sttService = null +} + +function requireFactories(): SpeechServiceFactories { + if (!factories) { + throw new Error('speech_unavailable: this host has no speech services') + } + return factories +} + export function getSpeechModelManager(store: SpeechSettingsStore): ModelManager { if (!modelManager) { const settings = store.getSettings() const customDir = settings.voice?.modelsDir || undefined - modelManager = new ModelManager(customDir || undefined) + modelManager = requireFactories().createModelManager(customDir || undefined) } return modelManager } export function getSpeechSttService(store: SpeechSettingsStore): SttService { if (!sttService) { - sttService = new SttService(getSpeechModelManager(store)) + sttService = requireFactories().createSttService(getSpeechModelManager(store)) } return sttService } From 36dfcc3955e38dfe16d84f28aa4153ed05569b16 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:01:45 -0700 Subject: [PATCH 20/27] fix(terminal): arm the restore baseline only on a snapshot that painted content (#16009) The restored-snapshot baseline permanently drops every delivery chunk at or below the snapshot's seq, on the model's claim that those bytes are already painted. reconcileChunkAgainstRestoredSnapshot recovers when the baseline under-reports (a gap ahead re-restores; a rawLength mismatch re-restores) but has no path for a baseline that over-reports: those chunks return drop-duplicate forever, and an idle shell never re-sends them. Gate arming on whether the snapshot painted printable cells. A snapshot claiming seq > 0 while painting nothing cannot be the rendering of the output it claims to cover, so the claim is disproven and the redelivery is the only remaining copy. Also pins disposeHeadlessTerminal's two-part write ordering, which was previously unpinned and silently reversible. Refs STA-5179 --- ...ss-terminal-dispose-write-ordering.test.ts | 106 +++++++ ...ection-restored-baseline-shortfall.test.ts | 263 ++++++++++++++++++ .../terminal-pane/pty-connection.ts | 30 +- .../restored-snapshot-coverage.test.ts | 30 ++ .../restored-snapshot-coverage.ts | 32 +++ 5 files changed, 457 insertions(+), 4 deletions(-) create mode 100644 src/main/runtime/headless-terminal-dispose-write-ordering.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts create mode 100644 src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts create mode 100644 src/renderer/src/components/terminal-pane/restored-snapshot-coverage.ts diff --git a/src/main/runtime/headless-terminal-dispose-write-ordering.test.ts b/src/main/runtime/headless-terminal-dispose-write-ordering.test.ts new file mode 100644 index 00000000000..b26c7bfb62c --- /dev/null +++ b/src/main/runtime/headless-terminal-dispose-write-ordering.test.ts @@ -0,0 +1,106 @@ +/** + * disposeHeadlessTerminal's unwritten contract: no model write is ever dropped. + * + * Two halves, both load-bearing and both silently reversible: + * 1. `headlessTerminals.delete(ptyId)` runs BEFORE disposal, so a write + * issued after the call lazily creates a fresh emulator instead of + * landing on the disposing one. + * 2. `dispose()` is registered on the CURRENT writeChain, so links already + * queued behind it parse before the emulator goes away. + * Swapping either ordering loses output with no error anywhere. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { HeadlessEmulator } from '../daemon/headless-emulator' + +const store = { + getRepo: () => undefined, + getRepos: () => [], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: [] }), + addRetiredWorktreeName: () => {}, + mergeRetiredWorktreeNames: () => false, + getGitHubCache: () => ({ pr: {}, issue: {} }) as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + terminalMainSideEffectAuthority: true + }) +} + +const PTY_ID = 'pty-dispose-ordering' + +function createRuntime(): OrcaRuntimeService { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => ({ cols: 80, rows: 24 }), + resize: () => true + }) + return runtime +} + +describe('disposeHeadlessTerminal write ordering', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('parses a write queued before disposal, then routes later writes to a fresh emulator', async () => { + const events: string[] = [] + const emulators: HeadlessEmulator[] = [] + const write = HeadlessEmulator.prototype.write + const dispose = HeadlessEmulator.prototype.dispose + vi.spyOn(HeadlessEmulator.prototype, 'write').mockImplementation(async function ( + this: HeadlessEmulator, + data: string, + opts + ) { + if (!emulators.includes(this)) { + emulators.push(this) + } + events.push(`write:${emulators.indexOf(this)}:${data.trim()}`) + return write.call(this, data, opts) + }) + vi.spyOn(HeadlessEmulator.prototype, 'dispose').mockImplementation( + function (this: HeadlessEmulator) { + events.push(`dispose:${emulators.indexOf(this)}`) + return dispose.call(this) + } + ) + + const runtime = createRuntime() + // Queued but not yet parsed: the chain link is still pending here. + runtime.onPtyData(PTY_ID, 'QUEUED-BEFORE-DISPOSE\r\n', 1) + runtime.resetPtyModelAfterMigrationFailure(PTY_ID) + runtime.onPtyData(PTY_ID, 'ISSUED-AFTER-DISPOSE\r\n', 2) + await runtime.serializeHiddenOutputRecoveryBuffer(PTY_ID) + + expect(events).toEqual([ + 'write:0:QUEUED-BEFORE-DISPOSE', + 'write:1:ISSUED-AFTER-DISPOSE', + 'dispose:0' + ]) + }) + + it('leaves the post-disposal emulator holding only post-disposal output', async () => { + const runtime = createRuntime() + runtime.onPtyData(PTY_ID, 'QUEUED-BEFORE-DISPOSE\r\n', 1) + runtime.resetPtyModelAfterMigrationFailure(PTY_ID) + runtime.onPtyData(PTY_ID, 'ISSUED-AFTER-DISPOSE\r\n', 2) + + const snapshot = await runtime.serializeHiddenOutputRecoveryBuffer(PTY_ID) + const painted = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}` + expect(painted).toContain('ISSUED-AFTER-DISPOSE') + expect(painted).not.toContain('QUEUED-BEFORE-DISPOSE') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts new file mode 100644 index 00000000000..5ac0a49fcb6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts @@ -0,0 +1,263 @@ +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +const { + resetAndRefreshAllTerminalWebglAtlases, + scheduleTerminalWebglAtlasRecovery, + scheduleRuntimeGraphSync, + shouldSeedCacheTimerOnInitialTitle, + toastInfo, + notifyCodexPaneBoundForStaleSweep +} = vi.hoisted(() => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn(), + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn(), + notifyCodexPaneBoundForStaleSweep: vi.fn() +})) + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let createdTransportOptions: Record[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync })) + +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + resetAndRefreshAllTerminalWebglAtlases +})) + +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState, + subscribe: (listener: (state: StoreState) => void) => { + storeSubscribers.push(listener) + return () => { + storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle +})) + +vi.mock('sonner', () => ({ toast: { info: toastInfo } })) + +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ + notifyCodexPaneBoundForStaleSweep +})) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn((options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + }) +})) + +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn( + (_environmentId: string, options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + } + ) +})) + +vi.mock('./pty-dispatcher', async (importOriginal) => { + const actual = await importOriginal>() + return { ...actual, getEagerPtyBufferHandle: vi.fn(() => undefined) } +}) + +/** Mode/attribute rehydration only — the shape a model emulator serializes when + * a seed or hydration write never landed and it holds no cell content. */ +const BLANK_MODEL_IMAGE = '\x1b[0m\x1b[?25h\x1b[?7h' + +describe('restored snapshot baseline shortfall (STA-5179)', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + function writtenData(pane: ReturnType): string { + return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + } + + async function revealPaneWithModelSnapshot(snapshot: { + data: string + seq: number + pendingDeliveryStartSeq?: number + scrollbackAnsi?: string + }): Promise<{ + pane: ReturnType + dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void + getMainBufferSnapshot: ReturnType + }> { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } as StoreState['settings'] + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: true } + }) as never + ) + await flushAsyncTicks(6) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + cols: 100, + rows: 30, + ...snapshot + }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: snapshot.seq + }) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + pane.terminal.write.mockClear() + return { + pane, + dataCallback: capturedDataCallback.current!, + getMainBufferSnapshot + } + } + + it('recovers the backlog a blank snapshot claimed to have painted', async () => { + // The model over-reports: seq 2_472 with no cell content behind it. Every + // chunk carrying the missing tail sits at or below that seq, so the old + // baseline dropped all of it as duplicates with nothing left to re-send. + const { pane, dataCallback } = await revealPaneWithModelSnapshot({ + data: BLANK_MODEL_IMAGE, + seq: 2_472, + pendingDeliveryStartSeq: 2_400 + }) + + dataCallback('TAIL-INSIDE-WINDOW', { seq: 2_460, rawLength: 18 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('TAIL-INSIDE-WINDOW') + + // Straddling the claimed baseline: the whole chunk is recovered, not sliced. + dataCallback('TAIL-STRADDLING', { seq: 2_475, rawLength: 15 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('TAIL-STRADDLING') + + // An idle shell never re-sends: no further restore is needed to heal. + dataCallback('LIVE-AFTER-BASELINE', { seq: 2_494, rawLength: 19 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('LIVE-AFTER-BASELINE') + }) + + it('recovers the backlog when only scrollback-less blank content was painted', async () => { + const { pane, dataCallback } = await revealPaneWithModelSnapshot({ + data: '\x1b[H\x1b[2J', + scrollbackAnsi: ' \r\n', + seq: 900 + }) + + dataCallback('BLANK-WITH-WHITESPACE-SCROLLBACK', { + seq: 880, + rawLength: 32 + }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('BLANK-WITH-WHITESPACE-SCROLLBACK') + }) + + it('still suppresses backlog duplicates behind a snapshot that painted content', async () => { + // Regression guard for the normal path: a snapshot with real cell content + // keeps its drop-everything baseline, so the ACK backlog stays suppressed. + const { pane, dataCallback } = await revealPaneWithModelSnapshot({ + data: 'restored snapshot\r\n', + seq: 2_472, + pendingDeliveryStartSeq: 2_400 + }) + + dataCallback('COVERED-DUPLICATE', { seq: 2_460, rawLength: 17 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).not.toContain('COVERED-DUPLICATE') + + // Only the post-baseline remainder of a straddling chunk is written. + dataCallback('ABCDEFGHIJKLMNOPQRSTU', { seq: 2_478, rawLength: 21 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('PQRSTU') + expect(writtenData(pane)).not.toContain('ABCDEF') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 068cb9b6087..3050f81708e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -19,6 +19,7 @@ import { isEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags' +import { restoredSnapshotPaintsPrintableContent } from './restored-snapshot-coverage' import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host' import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic' import { isWorktreeRemovalFenceError } from '../../../../shared/worktree/removal-fence-error' @@ -5982,6 +5983,7 @@ export function connectPanePty( let hiddenOutputRestoreReplayingSnapshot: { seq?: number pendingDeliveryStartSeq?: number + paintsContent?: boolean } | null = null let hiddenOutputRestoreFloodRepaintTimer: ReturnType | null = null let cancelHiddenOutputRestoreFloodRepaintPark: (() => void) | null = null @@ -6001,12 +6003,19 @@ export function connectPanePty( function setRestoredSnapshotBaseline( ptyId: string, - snapshot: { seq?: number; pendingDeliveryStartSeq?: number } + snapshot: { seq?: number; pendingDeliveryStartSeq?: number }, + paintsContent: boolean ): void { if (typeof snapshot.seq !== 'number') { clearRestoredSnapshotBaseline() return } + // Why: arming drops the redelivery permanently, so the snapshot's painted + // content must be able to back the seq it claims; a blank image cannot (STA-5179). + if (snapshot.seq > 0 && !paintsContent) { + clearRestoredSnapshotBaseline() + return + } const windowStartSeq = typeof snapshot.pendingDeliveryStartSeq === 'number' ? Math.min(snapshot.pendingDeliveryStartSeq, snapshot.seq) @@ -7220,7 +7229,11 @@ export function connectPanePty( pendingData += sliced ?? chunk.data } if (replayingSnapshot && replayedSeq !== null) { - setRestoredSnapshotBaseline(expectedPtyId, replayingSnapshot) + setRestoredSnapshotBaseline( + expectedPtyId, + replayingSnapshot, + replayingSnapshot.paintsContent === true + ) for (const chunk of pendingChunks) { if (typeof chunk.seq === 'number' && restoredSnapshotExpectedStartSeq !== null) { restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq) @@ -7403,6 +7416,7 @@ export function connectPanePty( if (typeof snapshot.seq === 'number') { hiddenOutputRestoreReplayingSnapshot = { seq: snapshot.seq, + paintsContent: restoredSnapshotPaintsPrintableContent(snapshot), ...(typeof snapshot.pendingDeliveryStartSeq === 'number' ? { pendingDeliveryStartSeq: snapshot.pendingDeliveryStartSeq } : {}) @@ -7726,7 +7740,11 @@ export function connectPanePty( return } // Why: everything at/before snapshot.seq is now painted; chunks still draining from main's ACK backlog below it are duplicates to suppress. - setRestoredSnapshotBaseline(currentPtyId, snapshot) + setRestoredSnapshotBaseline( + currentPtyId, + snapshot, + restoredSnapshotPaintsPrintableContent(snapshot) + ) hiddenOutputRestoreReplayingSnapshot = null const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded hiddenOutputRestoreFreshSnapshotNeeded = false @@ -8537,7 +8555,11 @@ export function connectPanePty( writeReplayData(modelSnapshot.pendingEscapeTailAnsi) } // Why: main sampled its delivery backlog with the snapshot; the baseline drops/slices deferred and live chunks the snapshot already covers. - setRestoredSnapshotBaseline(ptyId, modelSnapshot) + setRestoredSnapshotBaseline( + ptyId, + modelSnapshot, + restoredSnapshotPaintsPrintableContent(modelSnapshot) + ) recordRendererOrderedSeq(modelSnapshot) sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) if (connectResult?.coldRestore && !isRemoteRuntimePtyId(ptyId)) { diff --git a/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts b/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts new file mode 100644 index 00000000000..4a8938b1266 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { restoredSnapshotPaintsPrintableContent } from './restored-snapshot-coverage' + +describe('restoredSnapshotPaintsPrintableContent', () => { + it('accepts a snapshot with cell content in the frame or the scrollback', () => { + expect(restoredSnapshotPaintsPrintableContent({ data: '\x1b[0m$ ls\r\n' })).toBe(true) + expect( + restoredSnapshotPaintsPrintableContent({ + data: '\x1b[H\x1b[2J', + scrollbackAnsi: 'older\r\n' + }) + ).toBe(true) + }) + + it('rejects a mode-rehydration-only image, which no amount of output renders to', () => { + expect( + restoredSnapshotPaintsPrintableContent({ + data: '\x1b[0m\x1b[?25h\x1b[?7h' + }) + ).toBe(false) + expect(restoredSnapshotPaintsPrintableContent({ data: '\x1b]0;title\x07' })).toBe(false) + expect(restoredSnapshotPaintsPrintableContent({})).toBe(false) + expect( + restoredSnapshotPaintsPrintableContent({ + data: '\x1b[2J', + scrollbackAnsi: ' \r\n\t' + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.ts b/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.ts new file mode 100644 index 00000000000..8e80c6485bd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.ts @@ -0,0 +1,32 @@ +import { + stripAnsiEscapeSequences, + TERMINAL_CONTROL_CHARACTER_PATTERN +} from '../../../../shared/ansi-escape-sequences' + +/** + * Whether a restored model snapshot painted any printable cell content. + * + * Why this gates the restore baseline (STA-5179): the baseline makes every + * chunk at or below the snapshot's `seq` unrecoverable, on the model's word + * that those bytes are already painted. An image with no printable characters + * cannot be the rendering of the output it claims to cover, so the claim is + * disproven and the redelivery is the only remaining copy of those bytes. + * Replaying it into the blank screen the snapshot just produced also cannot + * duplicate visible output, so refusing to arm is the safe direction. + */ +export function restoredSnapshotPaintsPrintableContent(snapshot: { + data?: string + scrollbackAnsi?: string +}): boolean { + return hasPrintableContent(snapshot.data) || hasPrintableContent(snapshot.scrollbackAnsi) +} + +function hasPrintableContent(value: string | undefined): boolean { + if (!value) { + return false + } + return ( + stripAnsiEscapeSequences(value).replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '').trim().length > + 0 + ) +} From b851a5e13fa56595a50bc449f5a2235e32d77cce Mon Sep 17 00:00:00 2001 From: erish Date: Sun, 23 Aug 2026 14:11:55 +0900 Subject: [PATCH 21/27] fix(github): add GHES avatar fallback to PR and task views (#13981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(pr-page): route remaining user avatars through GitHubUserAvatar On a private-mode GitHub Enterprise instance the stored avatar URL 302s to /login, and the renderer's default Electron session carries no cookie, so the image never loads. #8784 added GitHubUserAvatar for exactly this — it degrades to an initials placeholder via onError — but three call sites in PullRequestPage kept a bare : the reviewer picker, the comment author, and the @ mention suggestions. Each only guarded on avatarUrl being absent, so on GHE the URL is present, the placeholder branch never runs, and a broken image is left on screen. The authorAvatarUrl type comment already documents the intended contract ("falls back to the login URL and finally an initials placeholder"). GitHubUserAvatar was already imported in this file for the PR author, so this makes all five avatars in the page consistent. Note the three switched slots now carry the shared border/bg styling, matching the two that already did. Add a boundary test that fails if any avatar is rendered through a bare again. Fixes #13976 * fix(task-page): route GitHub avatar cells through GitHubUserAvatar too Auditing the rest of the GHE avatar path turned up the same bare in TaskPage: GitHubAssigneeAvatar, GHAssigneesCell and PRReviewCell. Fixing only the PR page would leave half of #13976 in place. GitHubAssigneeAvatar is the clearest case — ReviewChipAvatar directly above it already renders through GitHubUserAvatar, so two adjacent functions disagreed on how a GitHub user avatar is drawn. Its border also moves from border-border/40 to /50, matching the neighbour. Linear member avatars in this file are left alone; they use their own provider path and are out of scope here. Move the regression assertions into the existing repro-8784 file rather than a new boundary test — that file already guards PullRequestPage and TaskPage together, so it is where this belongs. The PR-page check matches the pattern instead of specific field names, so a rename or a newly added avatar slot cannot slip past it; the TaskPage check is scoped per function to avoid catching the Linear cells. * test(github): scope the avatar guard per call site and cover TaskPage names Addresses review feedback on the regression guard. The field-name regex missed aliases and resolver expressions, and the PR-page assertion did not require GitHubUserAvatar in each migrated slot — deleting all three would have passed. Reject any bare within the component scope instead, which is safe now that every assertion is scoped to one function. Drive all six slots from one table so each gets its own named case, and extend the display-name contract to TaskPage, which previously went unchecked. The ConversationTab entry carries displayName: null because PRComment has no display-name field. Reverting the fix now fails 11 cases instead of 3. --- src/renderer/src/components/TaskPage.tsx | 51 ++++++--------- .../repro-8784-ghe-avatar-fallback.test.ts | 64 +++++++++++++++++++ .../conversation/comment-card.tsx | 16 ++--- .../pull-request-page/mentions/textarea.tsx | 14 ++-- .../reviewers/picker-row.tsx | 14 ++-- 5 files changed, 104 insertions(+), 55 deletions(-) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index cf6e884b7b0..b0265713971 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -1438,25 +1438,14 @@ function ReviewChipAvatar({ } function GitHubAssigneeAvatar({ assignee }: { assignee: GitHubAssignableUser }): React.JSX.Element { - if (assignee.avatarUrl) { - return ( - {assignee.login} - ) - } return ( - - {assignee.login.slice(0, 1).toUpperCase()} - + ) } @@ -1896,13 +1885,12 @@ function GHAssigneesCell({ ) : null}
- {user.avatarUrl ? ( - - ) : ( - - {user.login.slice(0, 1).toUpperCase()} - - )} + {user.login} {user.name ? ( @@ -2388,13 +2376,12 @@ function PRReviewCell({ {selected ? : null} - {reviewer.avatarUrl ? ( - - ) : ( - - {reviewer.login.slice(0, 1).toUpperCase()} - - )} + {reviewer.login} diff --git a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts index f30da28d603..533274b2a95 100644 --- a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts +++ b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts @@ -51,4 +51,68 @@ describe('issue #8784 GHE avatar fallback (regression)', () => { // Why: list chip must not hardcode github.com/{login}.png. expect(taskPage).not.toMatch(/github\.com\/\$\{reviewer\.login\}\.png/) }) + + // GHES URLs can exist but fail unauthenticated; target slots need onError fallbacks. + // Scope checks because TaskPage also renders non-GitHub provider avatars. + const GITHUB_AVATAR_SLOTS = [ + { + file: 'pull-request-page/reviewers/picker-row.tsx', + fn: 'ReviewerPickerRow', + login: 'reviewer.login', + displayName: 'reviewer.name' + }, + { + file: 'pull-request-page/conversation/comment-card.tsx', + fn: 'ConversationCommentCard', + login: 'comment.author', + displayName: null + }, + { + file: 'pull-request-page/mentions/textarea.tsx', + fn: 'MentionTextarea', + login: 'option.login', + displayName: 'option.name' + }, + { + file: 'TaskPage.tsx', + fn: 'GitHubAssigneeAvatar', + login: 'assignee.login', + displayName: 'assignee.name' + }, + { file: 'TaskPage.tsx', fn: 'GHAssigneesCell', login: 'user.login', displayName: 'user.name' }, + { + file: 'TaskPage.tsx', + fn: 'PRReviewCell', + login: 'reviewer.login', + displayName: 'reviewer.name' + } + ] as const + + function componentBody(file: string, fn: string): string { + const source = readFileSync(join(__dirname, '..', file), 'utf8') + const start = source.indexOf(`function ${fn}`) + expect(start, `${file}: function ${fn} not found`).toBeGreaterThanOrEqual(0) + const next = source.indexOf('\nfunction ', start + 1) + return source.slice(start, next === -1 ? undefined : next) + } + + it.each(GITHUB_AVATAR_SLOTS)( + 'renders the $fn avatar through GitHubUserAvatar (#13976)', + ({ file, fn, login }) => { + const body = componentBody(file, fn) + + // Reject aliases and resolver expressions as well as direct avatarUrl fields. + expect(body, `${fn} still renders a bare img`).not.toMatch(/]/) + expect(body, `${fn} does not use GitHubUserAvatar`).toContain(' slot.displayName !== null))( + 'passes the $fn display name so initials are not reduced to one letter (#13976)', + ({ file, fn, displayName }) => { + expect(componentBody(file, fn)).toContain(`name={${displayName}}`) + } + ) }) diff --git a/src/renderer/src/components/pull-request-page/conversation/comment-card.tsx b/src/renderer/src/components/pull-request-page/conversation/comment-card.tsx index 66ed253a6c3..598a23ad6ca 100644 --- a/src/renderer/src/components/pull-request-page/conversation/comment-card.tsx +++ b/src/renderer/src/components/pull-request-page/conversation/comment-card.tsx @@ -12,6 +12,7 @@ import { import { formatRelativeTime } from '@/components/github/work-item-state-presentation' import { CommentCodeContext } from '@/components/github/CommentCodeContext' import { CommentReactions } from '@/components/github/CommentReactions' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { translate } from '@/i18n/i18n' import type { GitHubOwnerRepo, GitHubPRFile } from '../../../../../shared/github/pull-request-types' import type { PRComment } from '../../../../../shared/github/comment-types' @@ -62,15 +63,12 @@ export function ConversationCommentCard({ )} >
- {comment.authorAvatarUrl ? ( - {comment.author} - ) : ( -
- )} + - {option.avatarUrl ? ( - - ) : ( -
- {option.login.slice(0, 1).toUpperCase()} -
- )} + @{option.login} {option.name && ( diff --git a/src/renderer/src/components/pull-request-page/reviewers/picker-row.tsx b/src/renderer/src/components/pull-request-page/reviewers/picker-row.tsx index a8b347765f2..5e24fffdbbb 100644 --- a/src/renderer/src/components/pull-request-page/reviewers/picker-row.tsx +++ b/src/renderer/src/components/pull-request-page/reviewers/picker-row.tsx @@ -2,6 +2,7 @@ import React from 'react' import { Check } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' export function ReviewerPickerRow({ @@ -59,13 +60,12 @@ export function ReviewerPickerRow({ {selected ? : null} - {reviewer.avatarUrl ? ( - - ) : ( - - {reviewer.login.slice(0, 1).toUpperCase()} - - )} + {reviewer.login} From 838f5bfb7536a5e197289698f313f11f97a9995a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:30:11 -0700 Subject: [PATCH 22/27] fix(secrets): tell Linux users when their secrets are only obfuscated (#16033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux with no keyring, Electron falls back to the `basic_text` backend, which "encrypts" with a hardcoded password. `isEncryptionAvailable()` returns true for it, so Orca reported those secrets as sealed. They are not. The obvious fix — returning false for basic_text — is wrong and would have been a credential regression: `decryptWithStatus()` skips decryption entirely when encryption is unavailable, so every already-stored secret would read back empty. Sealing genuinely works on basic_text and must keep working. So capability and trust are now separate questions. `isEncryptionAvailable()` still answers "can this host seal and unseal", and `describeProtectionGap()` (renamed from `describeUnavailable`) answers "is my data actually protected", covering both no-sealing and weak-sealing. That method had no production caller — the port documented a promise nothing kept. `reportSecretProtectionGap()` now reads it at startup. A user-visible surface is follow-up; this at least stops the silence. Adds a bootstrap wiring guard over all nine host port installs. The no-op defaults are correct for a renderer-less host and silently wrong for the desktop, and a dropped or reordered install fails no existing test. Verified in both directions: it fails when an install is removed, and when one moves after the runtime is constructed. --- config/scripts/vitest-host-ports-setup.ts | 2 +- .../bitbucket/credential-connection.test.ts | 2 +- src/main/bitbucket/credential-store.test.ts | 2 +- src/main/bitbucket/status-no-decrypt.test.ts | 2 +- src/main/host/electron-secret-store.test.ts | 61 ++++++++++++++++- src/main/host/electron-secret-store.ts | 24 ++++--- .../host/secret-protection-report.test.ts | 38 +++++++++++ src/main/host/secret-protection-report.ts | 30 +++++++++ src/main/index.ts | 4 ++ .../worktree-watcher-removal-binding.test.ts | 60 +++++++++++++++++ src/main/jira/client.test.ts | 2 +- src/main/linear/client.test.ts | 2 +- src/main/orcad/orcad-entry.ts | 4 +- ...tence-protected-secret-fail-closed.test.ts | 2 +- ...stence-protected-secret-write-race.test.ts | 2 +- .../persistence-proxy-secret-recovery.test.ts | 2 +- src/main/persistence-single-serialize.test.ts | 2 +- src/main/persistence-ui-state.test.ts | 2 +- src/main/protected-secret-persistence.test.ts | 2 +- src/main/speech/openai-api-key-store.test.ts | 2 +- .../host-port-bootstrap-wiring.test.ts | 66 +++++++++++++++++++ src/shared/secret-store.test.ts | 6 +- src/shared/secret-store.ts | 16 +++-- 23 files changed, 300 insertions(+), 35 deletions(-) create mode 100644 src/main/host/secret-protection-report.test.ts create mode 100644 src/main/host/secret-protection-report.ts create mode 100644 src/main/ipc/worktree-watcher-removal-binding.test.ts create mode 100644 src/main/startup/host-port-bootstrap-wiring.test.ts diff --git a/config/scripts/vitest-host-ports-setup.ts b/config/scripts/vitest-host-ports-setup.ts index c12e16b593f..34df265b7e2 100644 --- a/config/scripts/vitest-host-ports-setup.ts +++ b/config/scripts/vitest-host-ports-setup.ts @@ -62,6 +62,6 @@ beforeEach(() => { } return text.slice(SEAL_PREFIX.length) }, - describeUnavailable: () => null + describeProtectionGap: () => null }) }) diff --git a/src/main/bitbucket/credential-connection.test.ts b/src/main/bitbucket/credential-connection.test.ts index 0d1a7e632bb..ea07cf6a96c 100644 --- a/src/main/bitbucket/credential-connection.test.ts +++ b/src/main/bitbucket/credential-connection.test.ts @@ -15,7 +15,7 @@ async function loadModule() { isEncryptionAvailable: () => true, encryptString: (value) => Buffer.from(value), decryptString: (value) => value.toString('utf-8'), - describeUnavailable: () => null + describeProtectionGap: () => null }) vi.doMock('node:os', async () => { const actual = await vi.importActual('node:os') diff --git a/src/main/bitbucket/credential-store.test.ts b/src/main/bitbucket/credential-store.test.ts index 601831a5930..34797bb516b 100644 --- a/src/main/bitbucket/credential-store.test.ts +++ b/src/main/bitbucket/credential-store.test.ts @@ -24,7 +24,7 @@ async function loadStore( isEncryptionAvailable: () => true, encryptString: (value) => Buffer.from(value), decryptString: decryptStringMock, - describeUnavailable: () => null + describeProtectionGap: () => null }) vi.doMock('node:os', async () => { const actual = await vi.importActual('node:os') diff --git a/src/main/bitbucket/status-no-decrypt.test.ts b/src/main/bitbucket/status-no-decrypt.test.ts index 05543108ea6..11dc39bfcd8 100644 --- a/src/main/bitbucket/status-no-decrypt.test.ts +++ b/src/main/bitbucket/status-no-decrypt.test.ts @@ -24,7 +24,7 @@ async function loadModules() { isEncryptionAvailable: () => true, encryptString: (value) => Buffer.from(value), decryptString: decryptSpy, - describeUnavailable: () => null + describeProtectionGap: () => null }) vi.doMock('node:os', async () => { const actual = await vi.importActual('node:os') diff --git a/src/main/host/electron-secret-store.test.ts b/src/main/host/electron-secret-store.test.ts index 3c171d7d499..0a1f3127cb6 100644 --- a/src/main/host/electron-secret-store.test.ts +++ b/src/main/host/electron-secret-store.test.ts @@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const safeStorageMock = vi.hoisted(() => ({ isEncryptionAvailable: vi.fn(() => true), encryptString: vi.fn((plainText: string) => Buffer.from(`os-sealed:${plainText}`)), - decryptString: vi.fn((cipher: Buffer) => cipher.toString().slice('os-sealed:'.length)) + decryptString: vi.fn((cipher: Buffer) => cipher.toString().slice('os-sealed:'.length)), + getSelectedStorageBackend: vi.fn(() => 'gnome_libsecret') })) vi.mock('electron', () => ({ safeStorage: safeStorageMock })) @@ -14,6 +15,60 @@ describe('ElectronSecretStore', () => { beforeEach(() => { vi.clearAllMocks() safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + safeStorageMock.getSelectedStorageBackend.mockReturnValue('gnome_libsecret') + }) + + function withPlatform(platform: NodeJS.Platform, run: () => T): T { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return run() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } + } + + describe('at-rest protection reporting', () => { + it('reports no gap when a real keyring backend is selected', () => { + withPlatform('linux', () => { + expect(new ElectronSecretStore().describeProtectionGap()).toBeNull() + }) + }) + + it('reports a gap for the Linux basic_text backend, which protects nothing', () => { + safeStorageMock.getSelectedStorageBackend.mockReturnValue('basic_text') + withPlatform('linux', () => { + const gap = new ElectronSecretStore().describeProtectionGap() + expect(gap).toMatch(/built-in key/) + expect(gap).toMatch(/gnome-keyring|kwallet/) + }) + }) + + it('keeps sealing AVAILABLE on basic_text so stored credentials still decrypt', () => { + // Why this matters more than the warning: flipping isEncryptionAvailable() would + // make decryptWithStatus() stop attempting and return every stored secret as + // empty. The gap is a trust signal, never a capability switch. + safeStorageMock.getSelectedStorageBackend.mockReturnValue('basic_text') + withPlatform('linux', () => { + const store = new ElectronSecretStore() + expect(store.isEncryptionAvailable()).toBe(true) + expect(store.decryptString(store.encryptString('linear-token'))).toBe('linear-token') + }) + }) + + it('does not consult the backend on macOS, where basic_text does not exist', () => { + withPlatform('darwin', () => { + expect(new ElectronSecretStore().describeProtectionGap()).toBeNull() + }) + expect(safeStorageMock.getSelectedStorageBackend).not.toHaveBeenCalled() + }) + + it('reports the missing-keyring gap before considering the backend', () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + withPlatform('linux', () => { + expect(new ElectronSecretStore().describeProtectionGap()).toMatch(/keyring is unavailable/) + }) + }) }) // Why this shape: the whole safety argument for the SecretStore refactor is that the @@ -47,12 +102,12 @@ describe('ElectronSecretStore', () => { }) it('has no reason to give while sealing works', () => { - expect(new ElectronSecretStore().describeUnavailable()).toBeNull() + expect(new ElectronSecretStore().describeProtectionGap()).toBeNull() }) it('names the missing facility when sealing is unavailable, so the plaintext fallback is explainable', () => { safeStorageMock.isEncryptionAvailable.mockReturnValue(false) - const reason = new ElectronSecretStore().describeUnavailable() + const reason = new ElectronSecretStore().describeProtectionGap() expect(reason).toContain('unencrypted') if (process.platform === 'linux') { expect(reason).toContain('keyring') diff --git a/src/main/host/electron-secret-store.ts b/src/main/host/electron-secret-store.ts index 63d4bb78d9b..ae9f8643a74 100644 --- a/src/main/host/electron-secret-store.ts +++ b/src/main/host/electron-secret-store.ts @@ -18,14 +18,22 @@ export class ElectronSecretStore implements SecretStore { return safeStorage.decryptString(cipher) } - describeUnavailable(): string | null { - if (safeStorage.isEncryptionAvailable()) { - return null + describeProtectionGap(): string | null { + if (!safeStorage.isEncryptionAvailable()) { + // Why platform-specific: the fix differs, and "encryption unavailable" alone + // sends users looking in the wrong place. + return process.platform === 'linux' + ? 'The OS keyring is unavailable, so secrets are stored unencrypted. Install and unlock gnome-keyring or kwallet to seal them.' + : 'The OS keychain is unavailable, so secrets are stored unencrypted.' } - // Why platform-specific: the fix differs, and "encryption unavailable" alone - // sends users looking in the wrong place. - return process.platform === 'linux' - ? 'The OS keyring is unavailable, so secrets are stored unencrypted. Install and unlock gnome-keyring or kwallet to seal them.' - : 'The OS keychain is unavailable, so secrets are stored unencrypted.' + // Why this is not folded into isEncryptionAvailable(): on Linux with no keyring, + // Electron falls back to `basic_text`, which "encrypts" with a hardcoded password. + // It round-trips, so sealing and unsealing genuinely work and must keep working — + // reporting it unavailable would strand every credential already stored this way. + // But it protects nothing, and reporting it as sealed is the actual lie. + if (process.platform === 'linux' && safeStorage.getSelectedStorageBackend() === 'basic_text') { + return 'Secrets are obfuscated with a built-in key, not protected by the OS keyring. Install and unlock gnome-keyring or kwallet, then restart Orca, to seal them properly.' + } + return null } } diff --git a/src/main/host/secret-protection-report.test.ts b/src/main/host/secret-protection-report.test.ts new file mode 100644 index 00000000000..2288bf11806 --- /dev/null +++ b/src/main/host/secret-protection-report.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { setSecretStore, _resetSecretStoreForTests } from '../../shared/secret-store' +import { reportSecretProtectionGap } from './secret-protection-report' + +function installStore(gap: string | null): void { + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (plainText) => Buffer.from(plainText), + decryptString: (cipher) => cipher.toString(), + describeProtectionGap: () => gap + }) +} + +describe('reportSecretProtectionGap', () => { + it('logs and returns the gap when protection is weaker than a user would assume', () => { + installStore('Secrets are obfuscated with a built-in key.') + const logged: string[] = [] + expect(reportSecretProtectionGap((m) => logged.push(m))).toMatch(/built-in key/) + expect(logged).toHaveLength(1) + expect(logged[0]).toContain('[secrets]') + }) + + it('stays silent when secrets are properly sealed', () => { + installStore(null) + const logged: string[] = [] + expect(reportSecretProtectionGap((m) => logged.push(m))).toBeNull() + expect(logged).toEqual([]) + }) + + it('does not throw startup when no store is installed', () => { + // Why: this is diagnostics running early in bootstrap. The useful error is the one + // raised by the first real read, not a crash from the thing reporting on it. + _resetSecretStoreForTests() + const logged: string[] = [] + expect(() => reportSecretProtectionGap((m) => logged.push(m))).not.toThrow() + expect(logged[0]).toContain('could not determine at-rest protection') + }) +}) diff --git a/src/main/host/secret-protection-report.ts b/src/main/host/secret-protection-report.ts new file mode 100644 index 00000000000..511b2a4425c --- /dev/null +++ b/src/main/host/secret-protection-report.ts @@ -0,0 +1,30 @@ +import { getSecretStore } from '../../shared/secret-store' + +/** + * Report at-rest secret protection once at startup. + * + * Why this exists: the port has always been able to describe a protection gap, and + * nothing read it — so a Linux user whose secrets are obfuscated with a built-in key + * was told nothing at all. Reading it here is the difference between the port + * documenting a promise and keeping one. + * + * Deliberately not fatal and not a dialog: sealing still works, so blocking startup + * would be worse than the gap it reports. + */ +export function reportSecretProtectionGap( + log: (message: string) => void = (message) => console.warn(message) +): string | null { + let gap: string | null + try { + gap = getSecretStore().describeProtectionGap() + } catch (error) { + // Why swallow: this is diagnostics. An uninstalled store is already a hard failure + // at the first real read, and that error is the useful one. + log(`[secrets] could not determine at-rest protection: ${String(error)}`) + return null + } + if (gap) { + log(`[secrets] ${gap}`) + } + return gap +} diff --git a/src/main/index.ts b/src/main/index.ts index 42abcd75590..5686dd41e33 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -34,6 +34,7 @@ import { setSpeechServiceFactories } from './speech/speech-runtime-service' import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal' import { setSecretStore } from '../shared/secret-store' import { ElectronSecretStore } from './host/electron-secret-store' +import { reportSecretProtectionGap } from './host/secret-protection-report' import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' @@ -883,6 +884,9 @@ if (hasSingleInstanceLock) { // the app.setName ordering the userData captures below depend on. setAppEnvironment(new ElectronAppEnvironment()) setSecretStore(new ElectronSecretStore()) + // Why right after install: this is the first moment the answer is knowable, and a + // silent weak backend is the gap users most need told about. + reportSecretProtectionGap() // 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 diff --git a/src/main/ipc/worktree-watcher-removal-binding.test.ts b/src/main/ipc/worktree-watcher-removal-binding.test.ts new file mode 100644 index 00000000000..b0b99ed1f79 --- /dev/null +++ b/src/main/ipc/worktree-watcher-removal-binding.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { getWorktreeWatcherRemoval, setWorktreeWatcherRemoval } from './worktree-watcher-removal' + +/** + * Why: the port's default is inert by design — a host with no renderer has no watchers + * to close. That is correct for orcad and silently wrong for the desktop, where an + * uninstalled port means worktree removal stops releasing the directory and Windows + * fails the delete on a locked file. The default is indistinguishable from a working + * install at the call site, so it gets asserted here. + */ +describe('WorktreeWatcherRemoval port', () => { + const METHODS = [ + 'closeLocal', + 'restoreLocal', + 'forgetLocal', + 'closeRemote', + 'restoreRemote', + 'forgetRemote' + ] as const + + it('defaults to inert so a renderer-less host is honest, not broken', async () => { + setWorktreeWatcherRemoval(null) + const inert = getWorktreeWatcherRemoval() + for (const method of METHODS) { + await expect( + Promise.resolve(inert[method]('repo::/tmp/w', '/tmp/w' as never)) + ).resolves.not.toThrow() + } + }) + + it('routes every method to the installed binding', async () => { + const calls: string[] = [] + setWorktreeWatcherRemoval({ + closeLocal: async () => void calls.push('closeLocal'), + restoreLocal: async () => void calls.push('restoreLocal'), + forgetLocal: () => void calls.push('forgetLocal'), + closeRemote: async () => void calls.push('closeRemote'), + restoreRemote: async () => void calls.push('restoreRemote'), + forgetRemote: () => void calls.push('forgetRemote') + }) + for (const method of METHODS) { + await getWorktreeWatcherRemoval()[method]('conn', '/tmp/w' as never) + } + expect(calls).toEqual([...METHODS]) + setWorktreeWatcherRemoval(null) + }) + + it('exposes a desktop binding that delegates rather than stubbing', async () => { + // Why import lazily: filesystem-watcher pulls electron, so it must not load in the + // inert-default case above. + const { desktopWorktreeWatcherRemoval } = await import('./filesystem-watcher') + for (const method of METHODS) { + expect(typeof desktopWorktreeWatcherRemoval[method], method).toBe('function') + // An inert stub is an empty arrow; a real delegation is not. + expect(desktopWorktreeWatcherRemoval[method].toString(), method).not.toMatch( + /^\s*(async )?\(\s*\)\s*=>\s*\{?\s*\}?\s*$/ + ) + } + }) +}) diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index 7480a98db03..7f57ad80b01 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -114,7 +114,7 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) { isEncryptionAvailable: () => options.encryptionAvailable ?? false, encryptString: (value) => Buffer.from(value), decryptString: options.decryptString ?? ((value) => value.toString('utf-8')), - describeUnavailable: () => null + describeProtectionGap: () => null }) vi.doMock('os', async () => { const actual = await vi.importActual('os') diff --git a/src/main/linear/client.test.ts b/src/main/linear/client.test.ts index f2105a250de..1043a0997ec 100644 --- a/src/main/linear/client.test.ts +++ b/src/main/linear/client.test.ts @@ -95,7 +95,7 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) { isEncryptionAvailable: () => options.encryptionAvailable ?? false, encryptString: (value) => Buffer.from(value), decryptString: options.decryptString ?? ((value) => value.toString('utf-8')), - describeUnavailable: () => null + describeProtectionGap: () => null }) vi.doMock('os', async () => { const actual = await vi.importActual('os') diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 621e89be5dc..bab68282e53 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -63,7 +63,7 @@ function createNodeAppEnvironment(): AppEnvironment { /** * 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. + * `describeProtectionGap()` gives the reason a client can surface. */ function createNodeSecretStore(): SecretStore { return { @@ -74,7 +74,7 @@ function createNodeSecretStore(): SecretStore { decryptString: () => { throw new Error('orcad_secret_sealing_unavailable') }, - describeUnavailable: () => + describeProtectionGap: () => 'This host has no OS keyring, so credentials are stored unencrypted. Pair from a desktop to manage secrets, or install and unlock a keyring.' } } diff --git a/src/main/persistence-protected-secret-fail-closed.test.ts b/src/main/persistence-protected-secret-fail-closed.test.ts index c69233bb760..26f49ea2e44 100644 --- a/src/main/persistence-protected-secret-fail-closed.test.ts +++ b/src/main/persistence-protected-secret-fail-closed.test.ts @@ -54,7 +54,7 @@ async function createStore() { } return decoded.slice('enc:'.length + 36 + 1) }, - describeUnavailable: () => null + describeProtectionGap: () => null }) const { Store, initDataPath } = await import('./persistence') // Why here: userData resolves through AppEnvironment, and this must point at this diff --git a/src/main/persistence-protected-secret-write-race.test.ts b/src/main/persistence-protected-secret-write-race.test.ts index e21cc02fd56..21aa20d13b8 100644 --- a/src/main/persistence-protected-secret-write-race.test.ts +++ b/src/main/persistence-protected-secret-write-race.test.ts @@ -51,7 +51,7 @@ async function createStore() { isEncryptionAvailable: () => cipherState.available, encryptString: (plaintext) => Buffer.from(`enc:${plaintext}`, 'utf-8'), decryptString: (ciphertext) => ciphertext.toString('utf-8').slice('enc:'.length), - describeUnavailable: () => null + describeProtectionGap: () => null }) const { Store, initDataPath } = await import('./persistence') // Why here: userData resolves through AppEnvironment, and this must point at this diff --git a/src/main/persistence-proxy-secret-recovery.test.ts b/src/main/persistence-proxy-secret-recovery.test.ts index fb9c74c68be..176113192c7 100644 --- a/src/main/persistence-proxy-secret-recovery.test.ts +++ b/src/main/persistence-proxy-secret-recovery.test.ts @@ -61,7 +61,7 @@ async function createStore() { } return decoded.slice('enc:'.length + 36 + 1) }, - describeUnavailable: () => null + describeProtectionGap: () => null }) const { Store, initDataPath } = await import('./persistence') // Why here: userData resolves through AppEnvironment, and this must point at this diff --git a/src/main/persistence-single-serialize.test.ts b/src/main/persistence-single-serialize.test.ts index 00becc26d4b..1293bb30f48 100644 --- a/src/main/persistence-single-serialize.test.ts +++ b/src/main/persistence-single-serialize.test.ts @@ -56,7 +56,7 @@ async function createStore() { } return decoded.slice('enc:'.length + 36 + 1) }, - describeUnavailable: () => null + describeProtectionGap: () => null }) const { Store, initDataPath } = await import('./persistence') // Why here: userData resolves through AppEnvironment, and this must point at this diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index 1ff2cda74ee..1b092293c68 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -48,7 +48,7 @@ async function createStore() { } return decoded.slice('encrypted:'.length) }, - describeUnavailable: () => null + describeProtectionGap: () => null }) const { Store, initDataPath } = await import('./persistence') // Why here: userData resolves through AppEnvironment, and this must point at this diff --git a/src/main/protected-secret-persistence.test.ts b/src/main/protected-secret-persistence.test.ts index ff71a9e8c9d..28a9ca6cf43 100644 --- a/src/main/protected-secret-persistence.test.ts +++ b/src/main/protected-secret-persistence.test.ts @@ -10,7 +10,7 @@ describe('ProtectedSecretPersistence', () => { isEncryptionAvailable: () => cipherState.available, encryptString: (plaintext) => Buffer.from(`encrypted:${plaintext}`), decryptString: (ciphertext) => ciphertext.toString().slice('encrypted:'.length), - describeUnavailable: () => null + describeProtectionGap: () => null }) }) diff --git a/src/main/speech/openai-api-key-store.test.ts b/src/main/speech/openai-api-key-store.test.ts index 2153c9fe608..e9dafd63781 100644 --- a/src/main/speech/openai-api-key-store.test.ts +++ b/src/main/speech/openai-api-key-store.test.ts @@ -17,7 +17,7 @@ async function loadStoreModule() { const { setSecretStore } = await import('../../shared/secret-store') setSecretStore({ ...safeStorageMock, - describeUnavailable: () => null + describeProtectionGap: () => null }) vi.doMock('os', async () => { const actual = await vi.importActual('os') diff --git a/src/main/startup/host-port-bootstrap-wiring.test.ts b/src/main/startup/host-port-bootstrap-wiring.test.ts new file mode 100644 index 00000000000..cfebf6cb0a8 --- /dev/null +++ b/src/main/startup/host-port-bootstrap-wiring.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guards the single point of failure for every host port. + * + * The ports deliberately split two ways when uninstalled: AppEnvironment and SecretStore + * throw, because a silent default writes real user state to the wrong place; the rest + * default to no-ops or inert stubs, because a host with no renderer legitimately has + * nothing to register. That asymmetry is only safe while the desktop installs all of + * them before anything reads state — a dropped or reordered line here does not fail a + * unit test, it silently degrades the shipped app (worktree removal stops closing + * watchers, notifications stop firing, browser panes start rejecting). + * + * Source-level because that is the property: these run once at module scope during + * startup, so there is no seam to assert against at runtime. + */ +describe('host port bootstrap wiring', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + const INSTALLS = [ + 'setAppEnvironment(new ElectronAppEnvironment())', + 'setSecretStore(new ElectronSecretStore())', + 'setPtyHostBindings({', + 'setRuntimeDesktopSurface(electronRuntimeDesktopSurface)', + 'setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory)', + 'setDefaultProxySessionResolver(', + 'setMainHttpClient(electronHttpClient)', + 'setSpeechServiceFactories(electronSpeechServiceFactories)', + 'setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval)' + ] + + it('installs every host port exactly once', () => { + for (const install of INSTALLS) { + expect(source.split(install).length - 1, `${install} should appear exactly once`).toBe(1) + } + }) + + it('installs every port before the runtime, the PTY handlers, or any window exists', () => { + // Why these three: they are the first things that resolve a path, seal a credential, + // or register against an injected surface. + const firstUse = Math.min( + ...['new OrcaRuntimeService(', 'registerHeadlessPtyRuntime(', 'function openMainWindow('] + .map((marker) => source.indexOf(marker)) + .filter((index) => index >= 0) + ) + expect(firstUse).toBeGreaterThan(0) + + for (const install of INSTALLS) { + expect(source.indexOf(install), `${install} must run before first use`).toBeLessThan(firstUse) + } + }) + + it('installs the ports at process level, not per window', () => { + // Why: installing per window registered the PTY surfaces against no-ops on the + // serve path, where no window ever opens. Caught in CI by the SSH docker E2E. + const openWindow = source.indexOf('function openMainWindow(') + for (const install of INSTALLS) { + expect( + source.indexOf(install, openWindow), + `${install} must not be re-installed per window` + ).toBe(-1) + } + }) +}) diff --git a/src/shared/secret-store.test.ts b/src/shared/secret-store.test.ts index c0478472cd5..7c325018047 100644 --- a/src/shared/secret-store.test.ts +++ b/src/shared/secret-store.test.ts @@ -12,7 +12,7 @@ function fakeStore(overrides: Partial = {}): SecretStore { isEncryptionAvailable: () => true, encryptString: (plainText) => Buffer.from(`sealed:${plainText}`), decryptString: (cipher) => cipher.toString().slice('sealed:'.length), - describeUnavailable: () => null, + describeProtectionGap: () => null, ...overrides } } @@ -45,9 +45,9 @@ describe('SecretStore registry', () => { setSecretStore( fakeStore({ isEncryptionAvailable: () => false, - describeUnavailable: () => 'The OS keyring is unavailable.' + describeProtectionGap: () => 'The OS keyring is unavailable.' }) ) - expect(getSecretStore().describeUnavailable()).toBe('The OS keyring is unavailable.') + expect(getSecretStore().describeProtectionGap()).toBe('The OS keyring is unavailable.') }) }) diff --git a/src/shared/secret-store.ts b/src/shared/secret-store.ts index 4f3d594e207..312c28e557f 100644 --- a/src/shared/secret-store.ts +++ b/src/shared/secret-store.ts @@ -6,7 +6,7 @@ * The contract mirrors safeStorage exactly, including the part that matters most: * `isEncryptionAvailable()` may return false. A store that cannot seal must say so * rather than throw; how a caller degrades is its own decision (persistence retains - * the prior sealed blob rather than writing plaintext). See `describeUnavailable()`, + * the prior sealed blob rather than writing plaintext). See `describeProtectionGap()`, * which exists so the reason reaches the user, not a console warning nobody reads. */ @@ -15,12 +15,16 @@ export type SecretStore = { encryptString(plainText: string): Buffer decryptString(cipher: Buffer): string /** - * Why: "encryption unavailable" is a security posture, not a detail. When - * `isEncryptionAvailable()` is false this returns a short, user-safe sentence - * explaining which host facility is missing, for the degradation surface. - * Returns null when encryption IS available. + * Why separate from `isEncryptionAvailable()`: that answers "can this host seal and + * unseal at all", which must stay true for a backend that seals weakly — flipping it + * would stop `decryptWithStatus()` even attempting, and every already-stored + * credential would read back empty. This answers the different question a user cares + * about: "is my data actually protected at rest?" + * + * Non-null means the protection is not what a user would assume — either no sealing, + * or sealing with a backend that does not meaningfully protect. Null means sealed. */ - describeUnavailable(): string | null + describeProtectionGap(): string | null } /** From 0cb076f03c60fcac05d0bbd0f19b248c13c47414 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:34:38 -0700 Subject: [PATCH 23/27] fix(dictation): address visualizer review feedback (#16035) --- docs/STYLEGUIDE.md | 2 +- src/renderer/src/assets/main.css | 5 ++-- .../DictationIndicator.localization.test.ts | 24 +++++++++++++++++++ .../dictation/DictationIndicator.tsx | 12 +++++----- src/renderer/src/i18n/locales/en.json | 10 ++++---- src/renderer/src/i18n/locales/es.json | 7 +++++- src/renderer/src/i18n/locales/ja.json | 7 +++++- src/renderer/src/i18n/locales/ko.json | 7 +++++- src/renderer/src/i18n/locales/zh.json | 7 +++++- 9 files changed, 63 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/components/dictation/DictationIndicator.localization.test.ts diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index a76d981dd2f..06be8eda659 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -102,7 +102,7 @@ Orca uses shadows sparingly. Three levels in practice: 1. **Inset hairline** — `border` + `border` token. The default. Almost everything sits at this level. 2. **Subtle lift** — `shadow-xs` + a single-token border. Outline buttons, embedded cards. -3. **Floating** — `0 10px 24px rgba(0, 0, 0, 0.18)`. Popovers, popups that escape the editor surface. Reserved. +3. **Floating** — `shadow-floating` (`0 10px 24px rgba(0, 0, 0, 0.18)`). Popovers, popups that escape the editor surface. Reserved. Don't add a fourth level. If something needs more emphasis than "floating," you're probably reaching for the focus `ring` instead. diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 9b4df1b4aa9..9d75836cd11 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -117,6 +117,7 @@ --color-git-graph-lane-4: var(--git-graph-lane-4); --color-git-graph-lane-5: var(--git-graph-lane-5); --color-annotation-highlight: var(--annotation-highlight); + --shadow-floating: 0 10px 24px rgb(0 0 0 / 0.18); --radius-sm: calc(var(--radius) * 0.6); --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); @@ -395,7 +396,7 @@ flex-wrap: wrap; gap: 10px; padding: 14px 16px; - box-shadow: 0 10px 24px rgb(0 0 0 / 0.18); + box-shadow: var(--shadow-floating); } [data-sonner-toaster] [data-sonner-toast][data-styled='true'] [data-icon] { @@ -1322,7 +1323,7 @@ html.native-shell .app-layout { background: var(--popover); color: var(--popover-foreground); /* STYLEGUIDE: floating elevation for popovers that escape the editor surface. */ - box-shadow: 0 10px 24px rgb(0 0 0 / 0.18); + box-shadow: var(--shadow-floating); } /* Why: caret points down at the usage meters this notice explains. */ diff --git a/src/renderer/src/components/dictation/DictationIndicator.localization.test.ts b/src/renderer/src/components/dictation/DictationIndicator.localization.test.ts new file mode 100644 index 00000000000..8b2863d7613 --- /dev/null +++ b/src/renderer/src/components/dictation/DictationIndicator.localization.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import en from '@/i18n/locales/en.json' +import es from '@/i18n/locales/es.json' +import ja from '@/i18n/locales/ja.json' +import ko from '@/i18n/locales/ko.json' +import zh from '@/i18n/locales/zh.json' + +const englishLabels = en.auto.components.dictation.DictationIndicator as Record +const translatedLabels = { es, ja, ko, zh } + +describe('DictationIndicator localization', () => { + it('uses generated localization keys', () => { + expect(Object.keys(englishLabels).every((key) => /^[a-f0-9]{10}$/.test(key))).toBe(true) + }) + + it.each(Object.entries(translatedLabels))('translates every label in %s', (_locale, catalog) => { + const labels = catalog.auto.components.dictation.DictationIndicator as Record + + for (const [key, englishLabel] of Object.entries(englishLabels)) { + expect(labels[key]).toBeTruthy() + expect(labels[key]).not.toBe(englishLabel) + } + }) +}) diff --git a/src/renderer/src/components/dictation/DictationIndicator.tsx b/src/renderer/src/components/dictation/DictationIndicator.tsx index 5029cf225ed..caa9981ea62 100644 --- a/src/renderer/src/components/dictation/DictationIndicator.tsx +++ b/src/renderer/src/components/dictation/DictationIndicator.tsx @@ -27,14 +27,14 @@ export function DictationIndicator() { const isSpeaking = isListening && dictationMeter.isSpeaking && !isClipping const lifecycleLabel = dictationState === 'starting' - ? translate('auto.components.dictation.DictationIndicator.starting', 'Starting mic…') + ? translate('auto.components.dictation.DictationIndicator.7f3660a7ba', 'Starting mic…') : dictationState === 'stopping' - ? translate('auto.components.dictation.DictationIndicator.processing', 'Processing…') - : translate('auto.components.dictation.DictationIndicator.listening', 'Listening') + ? translate('auto.components.dictation.DictationIndicator.f082d0cb9d', 'Processing…') + : translate('auto.components.dictation.DictationIndicator.3de5a129e7', 'Listening') const label = isClipping - ? translate('auto.components.dictation.DictationIndicator.tooLoud', 'Too loud') + ? translate('auto.components.dictation.DictationIndicator.4977162383', 'Too loud') : isSpeaking - ? translate('auto.components.dictation.DictationIndicator.speaking', 'Speaking') + ? translate('auto.components.dictation.DictationIndicator.25f2b7a6a5', 'Speaking') : lifecycleLabel const announcedLabel = isClipping ? label : lifecycleLabel const canStop = dictationState !== 'stopping' @@ -50,7 +50,7 @@ export function DictationIndicator() { data-testid="dictation-indicator" className={cn( 'fixed bottom-12 left-1/2 z-50 -translate-x-1/2 overflow-hidden', - 'border border-border bg-popover/95 text-sm text-popover-foreground shadow-[0_10px_24px_rgba(0,0,0,0.18)] backdrop-blur', + 'border border-border bg-popover/95 text-sm text-popover-foreground shadow-floating backdrop-blur', 'transition-[width,border-radius,opacity] duration-200 ease-out motion-reduce:transition-none', transcript ? 'w-[min(28rem,calc(100vw-2rem))] rounded-xl' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 05c84a4b55e..596ef2f4e3d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14611,11 +14611,11 @@ }, "DictationIndicator": { "335e1bc6cb": "Stop dictation", - "tooLoud": "Too loud", - "speaking": "Speaking", - "starting": "Starting mic…", - "processing": "Processing…", - "listening": "Listening" + "7f3660a7ba": "Starting mic…", + "f082d0cb9d": "Processing…", + "3de5a129e7": "Listening", + "4977162383": "Too loud", + "25f2b7a6a5": "Speaking" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index add3a3cf295..3689e21bd12 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13277,7 +13277,12 @@ "5d2c3e7ae3": "No se detectó ninguna voz." }, "DictationIndicator": { - "335e1bc6cb": "Detener dictado" + "335e1bc6cb": "Detener dictado", + "7f3660a7ba": "Iniciando micrófono…", + "f082d0cb9d": "Procesando…", + "3de5a129e7": "Escuchando", + "4977162383": "Volumen demasiado alto", + "25f2b7a6a5": "Hablando" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 22a47205978..304d5f1d5eb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13277,7 +13277,12 @@ "5d2c3e7ae3": "音声が検出されませんでした。" }, "DictationIndicator": { - "335e1bc6cb": "ディクテーションを停止する" + "335e1bc6cb": "ディクテーションを停止する", + "7f3660a7ba": "マイクを起動中…", + "f082d0cb9d": "処理中…", + "3de5a129e7": "聞き取り中", + "4977162383": "音量が大きすぎます", + "25f2b7a6a5": "発話中" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c3e8f863415..1108cdd735e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13295,7 +13295,12 @@ "5d2c3e7ae3": "음성이 감지되지 않았습니다." }, "DictationIndicator": { - "335e1bc6cb": "받아쓰기 중지" + "335e1bc6cb": "받아쓰기 중지", + "7f3660a7ba": "마이크 시작 중…", + "f082d0cb9d": "처리 중…", + "3de5a129e7": "듣는 중", + "4977162383": "음량이 너무 큽니다", + "25f2b7a6a5": "말하는 중" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4d59715176a..cbb41b0fa66 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13297,7 +13297,12 @@ "5d2c3e7ae3": "未检测到语音。" }, "DictationIndicator": { - "335e1bc6cb": "停止听写" + "335e1bc6cb": "停止听写", + "7f3660a7ba": "正在启动麦克风…", + "f082d0cb9d": "正在处理…", + "3de5a129e7": "正在聆听", + "4977162383": "音量过大", + "25f2b7a6a5": "正在说话" } }, "dashboard": { From 9ea2636b38a05cc28ca9a1b4f661ab8e68fd24ba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:37:40 -0700 Subject: [PATCH 24/27] fix(remote): keep editor focus after unfocused HTML side preview (STA-5001) (#15716) Opening a remote HTML preview to the side created an empty split and made it the active group. The next host session-tab snapshot still had the terminal active, so the client treated that empty group as a terminal focus change. Do not activate the empty split for unfocused remote previews, and when a reserved preview group is still empty, keep the sibling editor as the visible tab instead of following the host terminal. --- src/renderer/src/lib/file-preview.test.ts | 19 ++ src/renderer/src/lib/file-preview.ts | 7 +- ...b-session-focus-intent-visible-tab.test.ts | 32 ++- .../src/runtime/web-session-focus-intent.ts | 27 ++ ...ssion-tabs-sync-html-preview-focus.test.ts | 265 ++++++++++++++++++ .../src/runtime/web-session-tabs-sync.ts | 12 + .../tabs-empty-split-activation.test.ts | 55 ++++ src/renderer/src/store/slices/tabs.ts | 15 +- 8 files changed, 426 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts create mode 100644 src/renderer/src/store/slices/tabs-empty-split-activation.test.ts diff --git a/src/renderer/src/lib/file-preview.test.ts b/src/renderer/src/lib/file-preview.test.ts index 8196e5b4e05..07f5239f1ab 100644 --- a/src/renderer/src/lib/file-preview.test.ts +++ b/src/renderer/src/lib/file-preview.test.ts @@ -111,6 +111,9 @@ describe('openFileInBrowserTab', () => { sourceGroupId: 'group-1' }) + expect(mocks.createEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right', { + activate: false + }) expect(mocks.createWebRuntimeSessionBrowserTab).toHaveBeenCalledWith({ worktreeId: 'wt-1', environmentId: 'runtime-1', @@ -124,6 +127,22 @@ describe('openFileInBrowserTab', () => { expect(mocks.createBrowserTab).not.toHaveBeenCalled() }) + it('creates local side previews in an activated right-hand split', () => { + openFilePreviewToSide({ + language: 'html', + filePath: '/tmp/example.html', + worktreeId: 'wt-1', + sourceGroupId: 'group-1' + }) + + expect(mocks.createEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right') + expect(mocks.createBrowserTab).toHaveBeenCalledWith('wt-1', 'file:///tmp/example.html', { + title: 'example.html', + targetGroupId: 'group-2', + activate: true + }) + }) + it('reports a paired-runtime recovery failure without an unhandled rejection', async () => { mocks.environmentId = 'runtime-1' mocks.browserAvailability = { state: 'enabled', provider: 'paired-runtime' } diff --git a/src/renderer/src/lib/file-preview.ts b/src/renderer/src/lib/file-preview.ts index 7f50873e3a8..7bea5d29012 100644 --- a/src/renderer/src/lib/file-preview.ts +++ b/src/renderer/src/lib/file-preview.ts @@ -207,8 +207,11 @@ export function openFilePreviewToSide(params: { let targetGroupId = existingSibling if (!targetGroupId) { // Why: no split yet — create one to the right so the preview lands beside - // the editor. createEmptySplitGroup returns the new (empty) group id. - targetGroupId = state.createEmptySplitGroup(worktreeId, sourceGroupId, 'right') + // the editor. Remote previews stay unfocused until click, so do not activate + // the empty group or a host snapshot will treat it as a terminal pane. + targetGroupId = environmentId + ? state.createEmptySplitGroup(worktreeId, sourceGroupId, 'right', { activate: false }) + : state.createEmptySplitGroup(worktreeId, sourceGroupId, 'right') } if (!targetGroupId) { return diff --git a/src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts b/src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts index 09ca8760d7e..1434a8ef3d4 100644 --- a/src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts +++ b/src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { resolveWebSessionVisibleTabId } from './web-session-focus-intent' +import { + resolveWebSessionSiblingVisibleTabId, + resolveWebSessionVisibleTabId +} from './web-session-focus-intent' import { toVisibleTabType, type Tab, @@ -235,6 +238,33 @@ describe('resolveWebSessionVisibleTabId — no-group compatibility path', () => }) }) +describe('resolveWebSessionSiblingVisibleTabId', () => { + it('returns the sibling whose active tab matches the remembered visible type', () => { + const editor = tab({ id: 'tab-editor', entityId: 'file-1', contentType: 'editor' }) + const terminal = tab({ + id: 'tab-terminal', + entityId: 'term-1', + contentType: 'terminal', + groupId: GROUP_B + }) + const state = makeState({ + unifiedTabsByWorktree: { [WT]: [editor, terminal] }, + groupsByWorktree: { + [WT]: [ + group({ id: GROUP_A, activeTabId: editor.id, tabOrder: [editor.id] }), + group({ id: GROUP_B, activeTabId: null, tabOrder: [] }) + ] + }, + activeGroupIdByWorktree: { [WT]: GROUP_B }, + activeTabType: 'editor', + activeTabTypeByWorktree: { [WT]: 'editor' } + }) + + expect(resolveWebSessionVisibleTabId(state, WT)).toBeNull() + expect(resolveWebSessionSiblingVisibleTabId(state, WT)).toBe(editor.id) + }) +}) + describe('toVisibleTabType', () => { it('collapses every editor-family kind and preserves the rest', () => { expect(toVisibleTabType('editor')).toBe('editor') diff --git a/src/renderer/src/runtime/web-session-focus-intent.ts b/src/renderer/src/runtime/web-session-focus-intent.ts index e69d7edf467..5fd06192f40 100644 --- a/src/renderer/src/runtime/web-session-focus-intent.ts +++ b/src/renderer/src/runtime/web-session-focus-intent.ts @@ -95,6 +95,33 @@ export function resolveWebSessionVisibleTabId( ) } +export function resolveWebSessionSiblingVisibleTabId( + state: WebSessionVisibleTabState, + worktreeId: string, + tabs = state.unifiedTabsByWorktree?.[worktreeId] ?? [] +): string | null { + const activeGroupId = state.activeGroupIdByWorktree?.[worktreeId] ?? null + const preferredType = + state.activeTabTypeByWorktree?.[worktreeId] ?? + (state.activeWorktreeId === worktreeId ? state.activeTabType : null) + const tabById = new Map(tabs.map((tab) => [tab.id, tab])) + let fallback: string | null = null + for (const group of state.groupsByWorktree?.[worktreeId] ?? []) { + if (group.id === activeGroupId || group.activeTabId == null) { + continue + } + const tab = tabById.get(group.activeTabId) + if (!tab || tab.groupId !== group.id) { + continue + } + if (preferredType && toVisibleTabType(tab.contentType) === preferredType) { + return tab.id + } + fallback ??= tab.id + } + return fallback +} + function focusIntentPartitionKey(owner: WebSessionIntentOwner, worktreeId: string): string { return `${webSessionIntentOwnerKey(owner)}\0${worktreeId}` } diff --git a/src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts new file mode 100644 index 00000000000..77ed83dcbf9 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import type { OpenFile } from '../store/slices/editor' +import type { Tab } from '../../../shared/tab-types' +import { recordWebSessionBrowserPlacement } from './web-session-browser-placement' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' +import { + ENV, + LEAF_ID, + NOW, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from './web-session-tabs-sync-test-harness' + +vi.mock('../store', () => ({ + useAppStore: { + setState: vi.fn() + } +})) + +const EDITOR_GROUP = 'client-editor-group' +const PREVIEW_GROUP = 'client-preview-group' +const EDITOR_FILE_ID = '/repo/paired-html-focus.html' +const EDITOR_TAB_ID = 'local-html-editor' +const HOST_TERMINAL_PARENT = 'host-terminal' +const HOST_TERMINAL_SURFACE = `${HOST_TERMINAL_PARENT}::${LEAF_ID}` +const HOST_BROWSER_TAB = 'host-html-browser-tab' +const HOST_BROWSER_PAGE = 'host-html-browser-page' + +function htmlOpenFile(): OpenFile { + return { + id: EDITOR_FILE_ID, + filePath: EDITOR_FILE_ID, + relativePath: 'paired-html-focus.html', + worktreeId: WT, + language: 'html', + isDirty: false, + runtimeEnvironmentId: ENV, + mode: 'edit' + } +} + +function htmlEditorTab(): Tab { + return { + id: EDITOR_TAB_ID, + entityId: EDITOR_FILE_ID, + groupId: EDITOR_GROUP, + worktreeId: WT, + contentType: 'editor', + label: 'paired-html-focus.html', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: NOW, + isPreview: false, + isPinned: false + } +} + +function mirroredTerminalTab(terminalTabId: string): Tab { + return { + id: terminalTabId, + entityId: terminalTabId, + groupId: EDITOR_GROUP, + worktreeId: WT, + contentType: 'terminal', + label: 'host shell', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW, + isPreview: false, + isPinned: false + } +} + +function editorFocusedSplitState(activeGroupId: string): WebSessionTabsSyncState { + const terminalTabId = toWebTerminalSurfaceTabId(HOST_TERMINAL_PARENT) + const editorTab = htmlEditorTab() + const openFile = htmlOpenFile() + return makeState({ + activeFileId: openFile.id, + activeFileIdByWorktree: { [WT]: openFile.id }, + activeGroupIdByWorktree: { [WT]: activeGroupId }, + activeTabId: terminalTabId, + activeTabIdByWorktree: { [WT]: terminalTabId }, + activeTabType: 'editor', + activeTabTypeByWorktree: { [WT]: 'editor' }, + openFiles: [openFile], + tabsByWorktree: { + [WT]: [ + { + id: terminalTabId, + ptyId: 'remote:web-env-1@@terminal-1', + worktreeId: WT, + title: 'host shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + ] + }, + unifiedTabsByWorktree: { [WT]: [mirroredTerminalTab(terminalTabId), editorTab] }, + groupsByWorktree: { + [WT]: [ + { + id: EDITOR_GROUP, + worktreeId: WT, + activeTabId: editorTab.id, + tabOrder: [terminalTabId, editorTab.id], + recentTabIds: [terminalTabId, editorTab.id] + }, + { + id: PREVIEW_GROUP, + worktreeId: WT, + activeTabId: null, + tabOrder: [] + } + ] + }, + layoutByWorktree: { + [WT]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: EDITOR_GROUP }, + second: { type: 'leaf', groupId: PREVIEW_GROUP }, + ratio: 0.5 + } + } + }) +} + +function unfocusedHtmlPreviewSnapshot() { + return makeSnapshot( + [ + { + type: 'terminal', + id: HOST_TERMINAL_SURFACE, + title: 'host shell', + parentTabId: HOST_TERMINAL_PARENT, + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1' + }, + { + type: 'browser', + id: HOST_BROWSER_TAB, + title: 'paired-html-focus.html', + browserWorkspaceId: 'host-html-workspace', + browserPageId: HOST_BROWSER_PAGE, + url: 'file:///repo/paired-html-focus.html', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: false + } + ], + { + activeGroupId: 'host-group-1', + activeTabId: HOST_TERMINAL_SURFACE, + activeTabType: 'terminal', + tabGroups: [ + { + id: 'host-group-1', + activeTabId: HOST_TERMINAL_PARENT, + tabOrder: [HOST_TERMINAL_PARENT, HOST_BROWSER_TAB] + } + ] + } + ) +} + +function previewFocus(state: WebSessionTabsSyncState): { + activeGroupId: string | null + activeTabId: string | null + activeTabType: WebSessionTabsSyncState['activeTabType'] | null +} { + const activeGroup = (state.groupsByWorktree[WT] ?? []).find( + (group) => group.id === state.activeGroupIdByWorktree[WT] + ) + return { + activeGroupId: activeGroup?.id ?? null, + activeTabId: activeGroup?.activeTabId ?? null, + activeTabType: state.activeTabTypeByWorktree[WT] ?? null + } +} + +describe('applyWebSessionTabsSnapshot — unfocused HTML side preview', () => { + beforeEach(resetWebSessionTabsSyncTestState) + + it('keeps the source editor focused when the empty preview split is still the active group', () => { + // Why: Open Preview to the Side creates the right-hand group first. Until the + // host browser lands, that group is empty — a host snapshot with terminal still + // active must not treat that emptiness as "the user focused a terminal". + recordWebSessionBrowserPlacement({ + environmentId: ENV, + worktreeId: WT, + remotePageId: HOST_BROWSER_PAGE, + groupId: PREVIEW_GROUP, + callerCreatedGroup: true + }) + const state = editorFocusedSplitState(PREVIEW_GROUP) + + const patch = applyWebSessionTabsSnapshot( + state, + unfocusedHtmlPreviewSnapshot(), + ENV, + NOW + 10 + ) as Partial + const next = { ...state, ...patch } + + expect(previewFocus(next)).toEqual({ + activeGroupId: EDITOR_GROUP, + activeTabId: EDITOR_TAB_ID, + activeTabType: 'editor' + }) + expect( + next.unifiedTabsByWorktree[WT]?.find((tab) => tab.contentType === 'browser') + ).toMatchObject({ id: HOST_BROWSER_TAB, groupId: PREVIEW_GROUP }) + }) + + it('still follows a host terminal snapshot when the empty split is not a reserved preview', () => { + const state = editorFocusedSplitState(PREVIEW_GROUP) + + const patch = applyWebSessionTabsSnapshot( + state, + unfocusedHtmlPreviewSnapshot(), + ENV, + NOW + 10 + ) as Partial + const next = { ...state, ...patch } + + expect(previewFocus(next).activeGroupId).not.toBe(EDITOR_GROUP) + expect(previewFocus(next).activeTabType).toBe('editor') + }) + + it('keeps the source editor focused when the editor group is still active', () => { + recordWebSessionBrowserPlacement({ + environmentId: ENV, + worktreeId: WT, + remotePageId: HOST_BROWSER_PAGE, + groupId: PREVIEW_GROUP, + callerCreatedGroup: true + }) + const state = editorFocusedSplitState(EDITOR_GROUP) + + const patch = applyWebSessionTabsSnapshot( + state, + unfocusedHtmlPreviewSnapshot(), + ENV, + NOW + 10 + ) as Partial + const next = { ...state, ...patch } + + expect(previewFocus(next)).toEqual({ + activeGroupId: EDITOR_GROUP, + activeTabId: EDITOR_TAB_ID, + activeTabType: 'editor' + }) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index d4bdc8d971a..cff237cb129 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -64,6 +64,7 @@ import { clearWebSessionFocusIntent, clearWebSessionFocusIntentsForOwner, peekWebSessionFocusIntent, + resolveWebSessionSiblingVisibleTabId, resolveWebSessionVisibleTabId } from './web-session-focus-intent' import { @@ -2908,6 +2909,16 @@ function applyWebSessionTabsSnapshotWithContext( worktreeId, nextUnifiedTabs ?? [] ) + const activeGroupId = state.activeGroupIdByWorktree[worktreeId] + // Why: Open Preview to the Side can activate an empty reserved group before the host + // browser lands. A snapshot that still has the host terminal active must not treat + // that emptiness as a terminal focus change. + const reservedEmptyPreviewFallbackTabId = + currentVisibleUnifiedTabId == null && + activeGroupId != null && + isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId: activeGroupId }) + ? resolveWebSessionSiblingVisibleTabId(state, worktreeId, nextUnifiedTabs ?? []) + : null // Why: a client-initiated activation also drives the visible unified tab, overriding the sticky current-visible tab. const intentUnifiedTabId = honorSnapshotActiveFocus ? navigationIntentTab?.type === 'browser' @@ -2921,6 +2932,7 @@ function applyWebSessionTabsSnapshotWithContext( const nextActiveUnifiedTabId = intentUnifiedTabId ?? currentVisibleUnifiedTabId ?? + reservedEmptyPreviewFallbackTabId ?? (snapshot.activeTabType === 'browser' ? (activeMirroredBrowserTabId ?? mirroredBrowserTabs[0]?.unifiedTab.id ?? diff --git a/src/renderer/src/store/slices/tabs-empty-split-activation.test.ts b/src/renderer/src/store/slices/tabs-empty-split-activation.test.ts new file mode 100644 index 00000000000..0d29c0d7784 --- /dev/null +++ b/src/renderer/src/store/slices/tabs-empty-split-activation.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { createTabsSliceMockApi } from './tabs-slice-test-harness' +import { createTestStore } from './store-test-helpers' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) + } +}) + +createTabsSliceMockApi() + +const WT = 'repo1::/tmp/feature' + +describe('createEmptySplitGroup', () => { + let store: ReturnType + + beforeEach(() => { + store = createTestStore() + }) + + it('activates the new group by default so terminal splits land in the empty pane', () => { + const tab = store.getState().createUnifiedTab(WT, 'terminal') + const sourceGroupId = store.getState().groupsByWorktree[WT][0].id + + const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right') + + expect(targetGroupId).toBeTruthy() + expect(store.getState().activeGroupIdByWorktree[WT]).toBe(targetGroupId) + expect( + store.getState().groupsByWorktree[WT].find((group) => group.id === sourceGroupId) + ).toEqual(expect.objectContaining({ tabOrder: [tab.id] })) + }) + + it('keeps the source group focused when activate is false', () => { + store.getState().createUnifiedTab(WT, 'editor', { id: 'file-a.ts', label: 'file-a.ts' }) + const sourceGroupId = store.getState().groupsByWorktree[WT][0].id + + const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right', { + activate: false + }) + + expect(targetGroupId).toBeTruthy() + expect(store.getState().activeGroupIdByWorktree[WT]).toBe(sourceGroupId) + expect(store.getState().groupsByWorktree[WT].map((group) => group.id)).toEqual([ + sourceGroupId, + targetGroupId + ]) + }) +}) diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 221c3fd7c4a..7a1c5b1150a 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -157,7 +157,8 @@ export type TabsSlice = { createEmptySplitGroup: ( worktreeId: string, sourceGroupId: string, - direction: TabSplitDirection + direction: TabSplitDirection, + opts?: { activate?: boolean } ) => string | null moveUnifiedTabToGroup: ( tabId: string, @@ -1645,7 +1646,7 @@ export const createTabsSlice: StateCreator = (set, return true }, - createEmptySplitGroup: (worktreeId, sourceGroupId, direction) => { + createEmptySplitGroup: (worktreeId, sourceGroupId, direction, opts) => { const newGroupId = createBrowserUuid() const newGroup: TabGroup = { id: newGroupId, @@ -1653,6 +1654,7 @@ export const createTabsSlice: StateCreator = (set, activeTabId: null, tabOrder: [] } + const shouldActivate = opts?.activate !== false set((state) => { const existing = state.groupsByWorktree[worktreeId] ?? [] const currentLayout = @@ -1669,7 +1671,14 @@ export const createTabsSlice: StateCreator = (set, ...state.layoutByWorktree, [worktreeId]: replaceLeaf(currentLayout, sourceGroupId, replacement) }, - activeGroupIdByWorktree: { ...state.activeGroupIdByWorktree, [worktreeId]: newGroupId } + ...(shouldActivate + ? { + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: newGroupId + } + } + : {}) } }) get().recordFeatureInteraction?.('terminal-panes') From 8d1557c225f5b6e02cc45c43b0a80b3dd0051121 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:00:23 -0700 Subject: [PATCH 25/27] fix(skills): discover Hermes home skills (#15862) Co-authored-by: kriptoburak --- src/main/skills/discovery.test.ts | 75 +++++++++++++++++++ src/main/skills/discovery.ts | 4 +- .../skill-discovery-concurrency.test.ts | 7 +- src/main/skills/skill-discovery-sources.ts | 14 +++- .../skill-discovery-wsl-plugins.test.ts | 9 ++- .../skill-provider-runtime-roots.test.ts | 67 +++++++++++++++++ .../skills/skill-provider-runtime-roots.ts | 43 +++++++++++ 7 files changed, 214 insertions(+), 5 deletions(-) diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index 3d905a01d84..740c4af7cd0 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -48,11 +48,16 @@ function makeRepo(path: string, connectionId: string | null = null): Repo { beforeEach(() => { // Roots are shared between scans for a few seconds; each case owns its own tree. clearSkillRootScanCache() + // Why: the Hermes root is the only home root that env vars can move outside the + // per-case tree, so a developer's real install must not leak into these scans. + vi.stubEnv('HERMES_HOME', '') + vi.stubEnv('LOCALAPPDATA', '') vi.spyOn(console, 'info').mockImplementation(() => undefined) }) afterEach(() => { unavailableRootPath = null + vi.unstubAllEnvs() vi.restoreAllMocks() }) @@ -367,6 +372,7 @@ describe('skill discovery', () => { '/home/test/.config/opencode/skills', '/home/test/.pi/agent/skills', '/home/test/.omp/agent/skills', + '/home/test/.hermes/skills', '/home/test/.gemini/skills', '/home/test/.gemini/antigravity/skills', '/home/test/.cursor/skills', @@ -393,6 +399,9 @@ describe('skill discovery', () => { expect( roots.find((root) => root.path.replace(/\\/g, '/') === '/home/test/.omp/agent/skills')?.owner ).toBe('omp') + expect( + roots.find((root) => root.path.replace(/\\/g, '/') === '/home/test/.hermes/skills')?.owner + ).toBe('hermes') }) it('does not add runtime-owned repository paths to local scan roots', () => { @@ -471,6 +480,72 @@ describe('skill discovery', () => { expect(skill?.providers).toEqual(['agent-skills']) }) + it('discovers Skills installed in the Hermes home', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const skillDir = join(home, '.hermes', 'skills', 'social-media', 'research') + await mkdir(skillDir, { recursive: true }) + await writeFile( + join(skillDir, 'SKILL.md'), + ['---', 'name: social-research', 'description: Research social sources.', '---', ''].join( + '\n' + ) + ) + + const result = await discoverSkills({ + homeDir: home, + cwd: join(root, 'missing-cwd') + }) + + expect(result.skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'social-research', + sourceKind: 'home', + sourceLabel: 'Hermes home', + directoryPath: skillDir, + providers: ['agent-skills'] + }) + ]) + ) + expect(result.sources.find((source) => source.id === 'home-hermes')).toMatchObject({ + owner: 'hermes', + exists: true + }) + }) + + it('discovers Hermes Skills under a relocated HERMES_HOME profile', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const hermesHome = join(root, 'profiles', 'coder') + const skillDir = join(hermesHome, 'skills', 'research') + await mkdir(skillDir, { recursive: true }) + await writeFile( + join(skillDir, 'SKILL.md'), + ['---', 'name: profile-research', 'description: Research sources.', '---', ''].join('\n') + ) + vi.stubEnv('HERMES_HOME', hermesHome) + + const result = await discoverSkills({ + homeDir: home, + cwd: join(root, 'missing-cwd') + }) + + expect(result.skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'profile-research', + sourceLabel: 'Hermes home', + directoryPath: skillDir + }) + ]) + ) + expect(result.sources.find((source) => source.id === 'home-hermes')).toMatchObject({ + path: join(hermesHome, 'skills'), + exists: true + }) + }) + it('discovers worktree .agents skill symlinks from the requested cwd', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index c5b8515c319..77e1e477ffd 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -34,11 +34,11 @@ const MAX_MARKDOWN_BYTES = 256 * 1024 // them for a few seconds is what bounds that fan-out. export const SKILL_ROOT_SCAN_TTL_MS = 10_000 // Why: sized off the root formula, not a round number. One scan builds -// `12 fixed home roots + 2 per local repo (+ cwd) + plugin roots`, so a bound +// `17 fixed home roots + 7 per local repo (+ cwd) + plugin roots`, so a bound // smaller than a single scan's root count makes that scan evict its own earlier // entries and the cache degrades to a ~0% hit rate — exactly the walk this // exists to prevent. The live key space is the union across targets — the fixed -// home roots plus two per repo plus two per distinct workspace cwd — so this holds +// home roots plus seven per repo plus seven per distinct workspace cwd — so this holds // a few hundred repos with panes open, not an unbounded install. Past that the LRU // keeps the hot home roots and the repo roots thrash, which degrades rather than // breaks. Most repo roots do not exist, and a missing root caches as diff --git a/src/main/skills/skill-discovery-concurrency.test.ts b/src/main/skills/skill-discovery-concurrency.test.ts index da7079331bd..a51d6dc9d52 100644 --- a/src/main/skills/skill-discovery-concurrency.test.ts +++ b/src/main/skills/skill-discovery-concurrency.test.ts @@ -60,11 +60,16 @@ function readdirCountUnder(path: string): number { beforeEach(() => { clearSkillRootScanCache() readdirPaths.length = 0 + // Why: the Hermes root is the one home root an env var can move outside the + // fixture, so a developer with a real Hermes install would score it `present`. + vi.stubEnv('HERMES_HOME', '') + vi.stubEnv('LOCALAPPDATA', '') vi.spyOn(console, 'info').mockImplementation(() => undefined) }) afterEach(() => { clearSkillRootScanCache() + vi.unstubAllEnvs() vi.restoreAllMocks() }) @@ -180,7 +185,7 @@ describe('bounded concurrent skill discovery', () => { const line = String(info.mock.calls.at(0)?.at(0)) // `present` is the signal that separates "big tree" from "big root set", and // is not derivable from the other counts. - expect(line).toContain('[skills] scan roots=23 present=3 walked=23 skills=3') + expect(line).toContain('[skills] scan roots=24 present=3 walked=24 skills=3') expect(line).toContain('home-claude') expect(line).not.toContain(home) expect(line).not.toContain(tmpdir()) diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index 91221a55413..9d9781c3687 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -11,7 +11,11 @@ import type { AgentType } from '../../shared/agent-status-types' import type { Repo } from '../../shared/repo-types' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { SkillProviderRootOverrides } from './skill-provider-destinations' -import { resolveEnvironmentSkillProviderRoots } from './skill-provider-runtime-roots' +import { + resolveDefaultHermesSkillsRoot, + resolveEnvironmentHermesSkillsRoot, + resolveEnvironmentSkillProviderRoots +} from './skill-provider-runtime-roots' export type SkillScanRoot = Omit type SkillDiscoveryPathApi = Pick @@ -77,6 +81,13 @@ export function buildSkillDiscoverySources( const cwd = args.cwd ?? process.cwd() const providerRootOverrides = args.providerRootOverrides ?? (args.pathApi ? {} : resolveEnvironmentSkillProviderRoots()) + // Why: HERMES_HOME moves the whole profile tree, so the default home path + // finds nothing for `hermes -p `. Only this process's own host can + // read it — a custom pathApi means the home belongs to another host, whose + // Hermes install is POSIX-shaped even when this process runs on Windows. + const hermesSkillsRoot = args.pathApi + ? pathApi.join(home, '.hermes', 'skills') + : (resolveEnvironmentHermesSkillsRoot() ?? resolveDefaultHermesSkillsRoot({ homeDir: home })) const roots: SkillScanRoot[] = [ source( 'home-codex', @@ -144,6 +155,7 @@ export function buildSkillDiscoverySources( ['agent-skills'], 'omp' ), + source('home-hermes', 'Hermes home', hermesSkillsRoot, 'home', ['agent-skills'], 'hermes'), source( 'home-prime-agent', 'Prime Agent home', diff --git a/src/main/skills/skill-discovery-wsl-plugins.test.ts b/src/main/skills/skill-discovery-wsl-plugins.test.ts index d3b5913fc57..f3ee9024ff2 100644 --- a/src/main/skills/skill-discovery-wsl-plugins.test.ts +++ b/src/main/skills/skill-discovery-wsl-plugins.test.ts @@ -1,6 +1,6 @@ import type { WslResult } from '../wsl/wsl-runner' import { posix as pathPosix } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const runWslProcessMock = vi.hoisted(() => vi.fn()) @@ -19,10 +19,15 @@ function wslResult(stdout: string): WslResult { describe('WSL Claude plugin skill discovery', () => { beforeEach(() => runWslProcessMock.mockReset()) + afterEach(() => vi.unstubAllEnvs()) it('reads enabled plugin metadata and scans the selected install inside the distro', async () => { const homeDir = '/home/alice' const cwd = '/work/orca' + // Why: a Windows host's own Hermes location says nothing about the distro's, + // so neither variable may reach the posix scan script. + vi.stubEnv('HERMES_HOME', 'C:\\Users\\alice\\hermes') + vi.stubEnv('LOCALAPPDATA', 'C:\\Users\\alice\\AppData\\Local') const pluginId = 'compound-engineering@compound-engineering-plugin' const installPath = '/home/alice/.claude/plugins/cache/compound/3.14.3' const installed = JSON.stringify({ @@ -58,6 +63,8 @@ describe('WSL Claude plugin skill discovery', () => { expect(runWslProcessMock).toHaveBeenCalledTimes(2) const scanScript = runWslProcessMock.mock.calls[1]?.[0].script as string + expect(scanScript).toContain('/home/alice/.hermes/skills') + expect(scanScript).not.toContain('AppData') expect(scanScript).toContain(`${installPath}/skills`) expect(result.skills).toEqual([ expect.objectContaining({ diff --git a/src/main/skills/skill-provider-runtime-roots.test.ts b/src/main/skills/skill-provider-runtime-roots.test.ts index 07d9d422dac..f933d4b2ff0 100644 --- a/src/main/skills/skill-provider-runtime-roots.test.ts +++ b/src/main/skills/skill-provider-runtime-roots.test.ts @@ -1,6 +1,8 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { + resolveDefaultHermesSkillsRoot, + resolveEnvironmentHermesSkillsRoot, resolveEnvironmentSkillProviderRoots, resolveWslGrokSkillProviderRoot, withClaudeSkillProviderRoot @@ -30,6 +32,71 @@ describe('skill provider runtime roots', () => { }) }) + it('maps a relocated HERMES_HOME to its skill root and ignores relative ones', () => { + expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: join('/srv', 'hermes') })).toBe( + join('/srv', 'hermes', 'skills') + ) + expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: '../hermes' })).toBeNull() + expect(resolveEnvironmentHermesSkillsRoot({})).toBeNull() + }) + + it('defaults the Hermes skills root to the home dotfolder off Windows', () => { + expect( + resolveDefaultHermesSkillsRoot({ + homeDir: join('/users', 'alice'), + platform: 'linux', + env: { LOCALAPPDATA: join('/local') }, + directoryExists: () => true + }) + ).toBe(join('/users', 'alice', '.hermes', 'skills')) + }) + + it('defaults the Hermes skills root under LOCALAPPDATA on Windows', () => { + const resolved = resolveDefaultHermesSkillsRoot({ + homeDir: join('/users', 'alice'), + platform: 'win32', + env: { LOCALAPPDATA: join('/local') }, + directoryExists: (candidate) => candidate === join('/local', 'hermes') + }) + expect(resolved).toBe(join('/local', 'hermes', 'skills')) + }) + + it('keeps a pre-LOCALAPPDATA Windows dotfolder install discoverable', () => { + const resolved = resolveDefaultHermesSkillsRoot({ + homeDir: join('/users', 'alice'), + platform: 'win32', + env: { LOCALAPPDATA: join('/local') }, + directoryExists: (candidate) => candidate === join('/users', 'alice', '.hermes') + }) + expect(resolved).toBe(join('/users', 'alice', '.hermes', 'skills')) + }) + + it('prefers the LOCALAPPDATA tree on Windows when both layouts exist', () => { + const resolved = resolveDefaultHermesSkillsRoot({ + homeDir: join('/users', 'alice'), + platform: 'win32', + env: { LOCALAPPDATA: join('/local') }, + directoryExists: () => true + }) + expect(resolved).toBe(join('/local', 'hermes', 'skills')) + }) + + it('falls back to the dotfolder when Windows exposes no usable LOCALAPPDATA', () => { + const seen: string[] = [] + const resolved = resolveDefaultHermesSkillsRoot({ + homeDir: join('/users', 'alice'), + platform: 'win32', + env: { LOCALAPPDATA: 'relative\\local' }, + directoryExists: (candidate) => { + seen.push(candidate) + return true + } + }) + expect(resolved).toBe(join('/users', 'alice', '.hermes', 'skills')) + // A rejected LOCALAPPDATA must not cost a stat: there is nothing to compare. + expect(seen).toEqual([]) + }) + it('maps the WSL login shell GROK_HOME to a host-readable skill root', async () => { await expect( resolveWslGrokSkillProviderRoot('Ubuntu-24.04', async () => '/srv/grok\n') diff --git a/src/main/skills/skill-provider-runtime-roots.ts b/src/main/skills/skill-provider-runtime-roots.ts index dd58e84d211..d4dec7bfba7 100644 --- a/src/main/skills/skill-provider-runtime-roots.ts +++ b/src/main/skills/skill-provider-runtime-roots.ts @@ -1,3 +1,4 @@ +import { statSync } from 'node:fs' import { isAbsolute, join, resolve } from 'node:path' import { toWindowsWslPath } from '../../shared/wsl-paths' import { runWslProcess } from '../wsl/wsl-runner' @@ -37,6 +38,48 @@ export function resolveEnvironmentSkillProviderRoots( } } +// Why: `HERMES_HOME` relocates a whole Hermes profile tree (`hermes -p coder`), +// and the rest of Orca already treats it as authoritative. Hermes is not an +// install provider, so it stays out of `SkillProviderRootOverrides`. +export function resolveEnvironmentHermesSkillsRoot( + env: NodeJS.ProcessEnv = process.env +): string | null { + const hermesHome = normalizedRoot(env.HERMES_HOME) + return hermesHome ? join(hermesHome, 'skills') : null +} + +function isExistingDirectory(candidate: string): boolean { + return statSync(candidate, { throwIfNoEntry: false })?.isDirectory() === true +} + +/** + * The Hermes home when `HERMES_HOME` is unset. Windows installs land in + * `%LOCALAPPDATA%\hermes`, not a dotfolder in the user profile, so the POSIX + * `~/.hermes` default discovers nothing there. The dotfolder still wins on + * Windows when it exists and the LOCALAPPDATA tree does not, which is the + * pre-LOCALAPPDATA install Hermes itself keeps honouring rather than orphaning. + */ +export function resolveDefaultHermesSkillsRoot(input: { + homeDir: string + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + directoryExists?: (candidate: string) => boolean +}): string { + const dotfolderHome = join(input.homeDir, '.hermes') + if ((input.platform ?? process.platform) !== 'win32') { + return join(dotfolderHome, 'skills') + } + const localAppData = normalizedRoot((input.env ?? process.env).LOCALAPPDATA) + if (!localAppData) { + return join(dotfolderHome, 'skills') + } + const localAppDataHome = join(localAppData, 'hermes') + const directoryExists = input.directoryExists ?? isExistingDirectory + return !directoryExists(localAppDataHome) && directoryExists(dotfolderHome) + ? join(dotfolderHome, 'skills') + : join(localAppDataHome, 'skills') +} + export function withClaudeSkillProviderRoot( roots: SkillProviderRootOverrides, claudeConfigDirectory: string | null | undefined From af2e825626f620278deb1bfd42259cdb1bd93fb7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:00:27 -0700 Subject: [PATCH 26/27] fix(worktree): stop warning about a stale local base branch that does not exist yet (#15331) (#15871) Co-authored-by: vam --- .../worktree-add-local-base-refresh.test.ts | 116 +++++++++++++++++- src/main/git/worktree-base-ref-probe.ts | 29 +++++ src/main/git/worktree.ts | 12 +- src/main/ipc/worktree-remote.ts | 31 +++-- .../worktrees-ssh-local-base-refresh.test.ts | 114 +++++++++++++++++ 5 files changed, 290 insertions(+), 12 deletions(-) diff --git a/src/main/git/worktree-add-local-base-refresh.test.ts b/src/main/git/worktree-add-local-base-refresh.test.ts index bfe76b397a7..cec1151e0eb 100644 --- a/src/main/git/worktree-add-local-base-refresh.test.ts +++ b/src/main/git/worktree-add-local-base-refresh.test.ts @@ -347,13 +347,19 @@ describe('addWorktree', () => { it('skips updating the local branch when it has diverged', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward')) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // for-each-ref refs/heads/main (exists) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add resolveCreationBaseConfigWrite() gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote - await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) + const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_not_fast_forward', + baseRef: 'origin/main', + localBranch: 'main' + }) expect(gitExecFileAsyncMock.mock.calls).toEqual([ [ ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], @@ -363,6 +369,10 @@ describe('addWorktree', () => { ['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'], expect.objectContaining({ cwd: '/repo' }) ], + [ + ['for-each-ref', '--count=1', '--format=%(refname)', 'refs/heads/main'], + expect.objectContaining({ cwd: '/repo' }) + ], [ [ 'worktree', @@ -396,12 +406,112 @@ describe('addWorktree', () => { ]) }) + // #15331: evaluation runs before `-b ` exists, so rev-list fails on the missing local ref. + it('does not warn when worktree add itself creates the local base branch', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/feature-x^{commit} + .mockRejectedValueOnce( + new Error( + "fatal: ambiguous argument 'refs/heads/feature-x...refs/remotes/origin/feature-x': unknown revision or path not in the working tree." + ) + ) // rev-list: refs/heads/feature-x does not exist yet + .mockResolvedValueOnce({ stdout: '' }) // for-each-ref refs/heads/feature-x (missing) + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree( + '/repo', + '/repo-feature-x', + 'feature-x', + 'origin/feature-x', + true + ) + + expect(result.localBaseRefRefresh).toBeUndefined() + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ + 'worktree', + 'add', + '--no-track', + '-b', + 'feature-x', + '/repo-feature-x', + 'refs/remotes/origin/feature-x' + ]) + // Nothing was refreshed, so no ref mutation. + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0][0])).not.toContain('update-ref') + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0][0])).not.toContain('reset') + }) + + // #15331: same missing-local-branch class, but the new branch name differs from the base's. + it('does not warn when the local base branch does not exist in a fetch-only clone', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit} + .mockRejectedValueOnce(new Error('unknown revision refs/heads/main')) // rev-list: no local main + .mockResolvedValueOnce({ stdout: '' }) // for-each-ref refs/heads/main (missing) + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true) + + expect(result.localBaseRefRefresh).toBeUndefined() + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ + 'for-each-ref', + '--count=1', + '--format=%(refname)', + 'refs/heads/main' + ]) + }) + + // A failed probe is not proof of absence, so the warning must survive it. + it('keeps the warning when the local base ref probe itself fails', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit} + .mockRejectedValueOnce(new Error('rev-list failed')) // drift probe + .mockRejectedValueOnce(new Error('fatal: not a git repository')) // for-each-ref probe could not run + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true) + + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_not_fast_forward', + baseRef: 'origin/main', + localBranch: 'main' + }) + }) + + it('still suggests nothing but keeps the warning when the local base ref exists and diverged', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit} + .mockResolvedValueOnce({ stdout: '2\t3\n' }) // rev-list: 2 local-only commits + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree('/repo', '/repo-feature', 'main', 'origin/main', true) + + // Same branch name as the base, but rev-list succeeded: real divergence must still warn. + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_not_fast_forward', + baseRef: 'origin/main', + localBranch: 'main' + }) + }) + it('skips local base refresh when captured OIDs are no longer ancestor-safe', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '0\t2\n' }) // stale rev-list result gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'new-local\n' }) // rev-parse refs/heads/main^{commit} gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit} gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not an ancestor')) // merge-base captured OIDs + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // for-each-ref refs/heads/main (exists) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add resolveCreationBaseConfigWrite() gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) @@ -435,6 +545,10 @@ describe('addWorktree', () => { ['merge-base', '--is-ancestor', 'new-local', 'remote-main'], expect.objectContaining({ cwd: '/repo' }) ], + [ + ['for-each-ref', '--count=1', '--format=%(refname)', 'refs/heads/main'], + expect.objectContaining({ cwd: '/repo' }) + ], [ [ 'worktree', diff --git a/src/main/git/worktree-base-ref-probe.ts b/src/main/git/worktree-base-ref-probe.ts index 44b8d4d85e9..85c8c8baa8f 100644 --- a/src/main/git/worktree-base-ref-probe.ts +++ b/src/main/git/worktree-base-ref-probe.ts @@ -37,3 +37,32 @@ export async function hasWorktreeBaseCommitRef( ): Promise { return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null } + +export type WorktreeBaseRefPresence = 'present' | 'absent' | 'unknown' + +/** + * Distinguish "the ref does not exist" from "the probe itself failed". + * + * Why for-each-ref: it exits 0 whether or not the pattern matches, so an empty result + * proves absence while a rejection still means the probe never ran (broken repo, dead + * SSH transport). `rev-parse --verify --quiet` exits 1 for both, and reading that as + * "absent" would silently drop warnings the caller must still surface. + * + * Executor-injected so the SSH path can route the same argv through the relay. + */ +export async function probeWorktreeBaseRefPresence( + runGit: (args: string[]) => Promise<{ stdout: string }>, + qualifiedRef: string +): Promise { + try { + const { stdout } = await runGit([ + 'for-each-ref', + '--count=1', + '--format=%(refname)', + qualifiedRef + ]) + return stdout.trim() === qualifiedRef ? 'present' : 'absent' + } catch { + return 'unknown' + } +} diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 1f96523899e..fcd2a0067fa 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -33,7 +33,7 @@ import { import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' import { gitExecFileAsync, translateWslOutputPaths } from './runner' import { resolveGitDir, runWithGitReadCacheInvalidation } from './status' -import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' +import { hasWorktreeBaseCommitRef, probeWorktreeBaseRefPresence } from './worktree-base-ref-probe' export type AddWorktreeResult = { localBaseRefRefresh?: LocalBaseRefRefreshResult @@ -265,6 +265,16 @@ async function evaluateLocalBaseRefRefreshability( ) drift = parsedDrift } catch { + // Why (#15331): the probes above also fail when refs/heads/ is simply absent; a branch that + // does not exist yet cannot be stale, so report nothing instead of a bogus divergence warning. + // Only a proven absence suppresses: an unusable repo leaves the warning alone. + const presence = await probeWorktreeBaseRefPresence( + (args) => gitExecFileAsync(args, gitExecOptions(repoPath, options)), + parsed.fullRef + ) + if (presence === 'absent') { + return undefined + } return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 72ef373758e..1d63cf97deb 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -35,6 +35,7 @@ import { } from '../git/repo' import { resolveLocalGitUsername, getSshGitUsername } from '../git/git-username' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' +import { probeWorktreeBaseRefPresence } from '../git/worktree-base-ref-probe' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' import { getHostedReviewForBranch } from '../source-control/hosted-review' @@ -181,7 +182,8 @@ type RemoteLocalBaseRefRefreshability = } | { refreshable: false - result: LocalBaseRefRefreshResult + // undefined = nothing to refresh (no local branch yet), so the caller reports no status at all. + result: LocalBaseRefRefreshResult | undefined } function appendWorktreeCreateWarning(current: string | undefined, next: string): string { @@ -698,8 +700,7 @@ async function hasRemoteWorktreeBaseRef( repoPath: string, baseRef: string ): Promise { - const refExists = (qualifiedRef: string) => - hasRemoteTrackingRefSsh(provider, repoPath, qualifiedRef) + const refExists = (qualifiedRef: string) => hasCommitRefSsh(provider, repoPath, qualifiedRef) const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) if (resolvedBaseRef !== baseRef) { return true @@ -710,8 +711,8 @@ async function hasRemoteWorktreeBaseRef( return hasRemoteCommitObject(provider, repoPath, baseRef) } -// Why: hasRemoteCommitObject resolves only SHAs, not symbolic remote-tracking refs; detect those directly for the fetch-failed local fallback. -async function hasRemoteTrackingRefSsh( +// Why: hasRemoteCommitObject resolves only SHAs, not symbolic refs; resolve any qualified ref (remote-tracking or local head) directly. +async function hasCommitRefSsh( provider: SshGitProvider, repoPath: string, ref: string @@ -1262,7 +1263,7 @@ async function resolveRemoteWorktreeCreateBasePlan( baseBranchCandidate ) if (remoteTrackingBase) { - if (await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref)) { + if (await hasCommitRefSsh(provider, repo.path, remoteTrackingBase.ref)) { return true } return hasRemoteWorktreeBaseRef(provider, repo.path, baseBranchCandidate) @@ -1313,7 +1314,7 @@ export async function prefetchRemoteWorktreeCreateBase( } if (basePlan.remoteTrackingBase) { if ( - (await hasRemoteTrackingRefSsh(provider, repo.path, basePlan.remoteTrackingBase.ref)) || + (await hasCommitRefSsh(provider, repo.path, basePlan.remoteTrackingBase.ref)) || !(await hasRemoteWorktreeBaseRef(provider, repo.path, basePlan.baseBranch)) ) { await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, basePlan.remoteTrackingBase) @@ -1333,7 +1334,7 @@ async function refreshLocalBaseRefForRemoteWorktreeCreate( provider: SshGitProvider, repoPath: string, remoteTrackingBase: RemoteTrackingBase -): Promise { +): Promise { const evaluation = await evaluateRemoteLocalBaseRefRefreshability( provider, repoPath, @@ -1393,6 +1394,16 @@ async function evaluateRemoteLocalBaseRefRefreshability( } } } catch { + // Why (#15331): the probes above also fail when refs/heads/ is simply absent; the relay's + // `worktree add -b` is about to create it, so there is nothing stale to warn about. Only a proven + // absence suppresses: a dropped relay connection is not evidence the branch is missing. + const presence = await probeWorktreeBaseRefPresence( + (args) => provider.exec(args, repoPath), + fullRef + ) + if (presence === 'absent') { + return { refreshable: false, result: undefined } + } return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } } @@ -1550,7 +1561,7 @@ export async function createRemoteWorktree( let baseFallback: WorktreeCreateBaseFallback | undefined if (remoteTrackingBase) { - const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh( + const hasRemoteTrackingBaseRef = await hasCommitRefSsh( provider, repo.path, remoteTrackingBase.ref @@ -1700,7 +1711,7 @@ export async function createRemoteWorktree( await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, remoteTrackingBase) } catch { // Why: a refresh failure shouldn't block create if a usable (stale) local base ref exists; probe after registerRoot and hard-fail only when none does. - if (!(await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref))) { + if (!(await hasCommitRefSsh(provider, repo.path, remoteTrackingBase.ref))) { throw new Error( `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` ) 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 b739b482884..0b76025a979 100644 --- a/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts +++ b/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts @@ -477,4 +477,118 @@ describe('registerWorktreeHandlers', () => { }) expect(result.localBaseRefUpdateSuggestion).toBeUndefined() }) + // #15331: the pre-create merge-base probe fails when refs/heads/ does not exist yet. + const buildMissingLocalBaseSshCase = (presence: 'absent' | 'present' | 'probe-failed') => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'for-each-ref' && args.at(-1) === 'refs/heads/main') { + if (presence === 'probe-failed') { + throw new Error('ssh: connection closed by remote host') + } + return { stdout: presence === 'present' ? 'refs/heads/main\n' : '', stderr: '' } + } + if (args[0] === 'rev-parse') { + const ref = args.at(-1) ?? '' + if (ref.startsWith('refs/heads/main')) { + return { stdout: presence === 'present' ? 'local-main\n' : '', stderr: '' } + } + // Other refs/heads probes are the new-branch conflict check; it must stay unresolvable. + return { stdout: ref.startsWith('refs/heads/') ? '' : 'remote-main\n', stderr: '' } + } + if (args[0] === 'merge-base') { + throw new Error('fatal: Not a valid object name refs/heads/main') + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }), + refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined) + } + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: true, + workspaceDir: '/workspace' + }) + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + return provider + } + + it('does not report an SSH local base refresh when the local base branch does not exist', async () => { + const provider = buildMissingLocalBaseSshCase('absent') + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard' + })) as CreateWorktreeResult + + expect(provider.exec).toHaveBeenCalledWith( + ['for-each-ref', '--count=1', '--format=%(refname)', 'refs/heads/main'], + '/remote/repo' + ) + expect(result.localBaseRefRefresh).toBeUndefined() + expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled() + }) + + it('keeps the SSH not-fast-forward status when the local base branch exists and diverged', async () => { + const provider = buildMissingLocalBaseSshCase('present') + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard' + })) as CreateWorktreeResult + + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_not_fast_forward', + baseRef: 'origin/main', + localBranch: 'main' + }) + expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled() + }) + + // Losing the relay mid-probe is not evidence the branch is missing. + it('keeps the SSH not-fast-forward status when the local base ref probe fails', async () => { + const provider = buildMissingLocalBaseSshCase('probe-failed') + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard' + })) as CreateWorktreeResult + + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_not_fast_forward', + baseRef: 'origin/main', + localBranch: 'main' + }) + expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled() + }) }) From 202d74a8a4be3fe6537e1b4eac0e9e4901cf7d96 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:00:31 -0700 Subject: [PATCH 27/27] fix(git): enable Windows long paths for worktree creation (local, sparse, and SSH hosts) (#15866) Co-authored-by: hwantage --- src/main/git/add-sparse-worktree.test.ts | 43 +++++- .../git/worktree-add-creation-config.test.ts | 91 +++++++++++- src/main/git/worktree.ts | 14 +- src/relay/git-handler-worktree-ops.test.ts | 134 ++++++++++++++---- src/relay/git-handler-worktree-ops.ts | 17 ++- src/shared/windows-long-path-git-args.test.ts | 29 ++++ src/shared/windows-long-path-git-args.ts | 25 ++++ 7 files changed, 320 insertions(+), 33 deletions(-) create mode 100644 src/shared/windows-long-path-git-args.test.ts create mode 100644 src/shared/windows-long-path-git-args.ts diff --git a/src/main/git/add-sparse-worktree.test.ts b/src/main/git/add-sparse-worktree.test.ts index 84aef686719..2541b822438 100644 --- a/src/main/git/add-sparse-worktree.test.ts +++ b/src/main/git/add-sparse-worktree.test.ts @@ -1,5 +1,5 @@ import type * as FsPromises from 'node:fs/promises' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' const { gitExecFileAsyncMock, @@ -67,6 +67,10 @@ beforeEach(() => { }) describe('addSparseWorktree', () => { + // Why: argv now depends on the host OS, so pin a non-Windows default or the exact-argv + // assertions below would fail for a maintainer running vitest on Windows. + let platformSpy: MockInstance<() => NodeJS.Platform> + beforeEach(() => { resetWorktreeGitMocks({ gitExecFileAsyncMock, @@ -75,6 +79,43 @@ describe('addSparseWorktree', () => { statMock, resolveGitDirMock }) + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + }) + + afterEach(() => { + platformSpy.mockRestore() + }) + + it('enables long paths on the Windows commands that actually write the deep checkout', async () => { + // Why: `worktree add --no-checkout` writes nothing, so the long-path flag on it alone + // left sparse creation failing with "Filename too long" (issue #15785). + platformSpy.mockReturnValue('win32') + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await addSparseWorktree('C:\\repo', 'C:\\repo-feature', 'feature/test', ['packages/web']) + + const calls = getGitCalls() + expect(calls).toEqual( + expect.arrayContaining([ + 'git -c core.longpaths=true sparse-checkout set -- packages/web', + 'git -c core.longpaths=true checkout feature/test' + ]) + ) + }) + + it('omits the long-path option on non-Windows hosts', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['packages/web']) + + const calls = getGitCalls() + expect(calls).toEqual( + expect.arrayContaining([ + 'git sparse-checkout set -- packages/web', + 'git checkout feature/test' + ]) + ) + expect(calls.some((call) => call.includes('core.longpaths'))).toBe(false) }) it('separates sparse checkout directory operands from options', async () => { diff --git a/src/main/git/worktree-add-creation-config.test.ts b/src/main/git/worktree-add-creation-config.test.ts index f6b3d0d51d6..44394b7728d 100644 --- a/src/main/git/worktree-add-creation-config.test.ts +++ b/src/main/git/worktree-add-creation-config.test.ts @@ -1,5 +1,5 @@ // addWorktree: checkout creation, branch-base/push.autoSetupRemote config writes, ref qualification. -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' const { gitExecFileAsyncMock, @@ -40,10 +40,19 @@ describe('addWorktree', () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base } + // Why: argv now depends on the host OS, so pin a non-Windows default or every + // exact-argv assertion below would fail for a maintainer running vitest on Windows. + let platformSpy: MockInstance<() => NodeJS.Platform> + beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileSyncMock.mockReset() translateWslOutputPathsMock.mockClear() + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + }) + + afterEach(() => { + platformSpy.mockRestore() }) it('creates the worktree without touching the local base ref by default', async () => { @@ -99,6 +108,86 @@ describe('addWorktree', () => { ]) }) + it('enables long paths for native Windows worktree creation', async () => { + platformSpy.mockReturnValue('win32') + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + + await addWorktree( + 'C:\\repo', + 'C:\\repo-feature', + 'feature/test', + 'feature/test', + false, + false, + { checkoutExistingBranch: true } + ) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'], + { cwd: 'C:\\repo', timeout: WORKTREE_ADD_TIMEOUT_MS } + ) + }) + + it('still enables long paths for a Windows-path repo that has a WSL distro configured', async () => { + // Why: a C:\ cwd can be served by host git.exe even with wslDistro set, and that + // is exactly the MAX_PATH-prone case; Linux git parses and ignores the key. + platformSpy.mockReturnValue('win32') + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + + await addWorktree( + 'C:\\repo', + 'C:\\repo-feature', + 'feature/test', + 'feature/test', + false, + false, + { checkoutExistingBranch: true, wslDistro: 'Ubuntu' } + ) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'], + { cwd: 'C:\\repo', wslDistro: 'Ubuntu', timeout: WORKTREE_ADD_TIMEOUT_MS } + ) + }) + + it('does not pass the Windows-only long-path option for a WSL UNC repo path', async () => { + platformSpy.mockReturnValue('win32') + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + + const repoPath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo' + await addWorktree( + repoPath, + '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature', + 'feature/test', + 'feature/test', + false, + false, + { checkoutExistingBranch: true, wslDistro: 'Ubuntu' } + ) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['worktree', 'add', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature', 'feature/test'], + { cwd: repoPath, wslDistro: 'Ubuntu', timeout: WORKTREE_ADD_TIMEOUT_MS } + ) + }) + + it.each(['darwin', 'linux'] as const)( + 'does not pass the long-path option on %s', + async (platform) => { + platformSpy.mockReturnValue(platform) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + + await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, { + checkoutExistingBranch: true + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['worktree', 'add', '/repo-feature', 'feature/test'], + { cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS } + ) + } + ) + it('bounds the worktree add call with a positive timeout (STA-1292 OneDrive stall guard)', async () => { // Why: without a timeout, a OneDrive cloud-placeholder checkout can stall // `git worktree add` for minutes. Assert the runner receives a non-zero diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index fcd2a0067fa..55c5d33cf79 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -14,6 +14,7 @@ import { scheduleWorktreeTrashDeletion } from '../worktree-trash' import { parseWslPath } from '../wsl' +import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' import type { LocalBaseRefRefreshResult, LocalBaseRefUpdateSuggestion @@ -985,7 +986,8 @@ async function performAddWorktree( ): Promise { let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined - const args = ['worktree', 'add'] + // Why: enable long paths for this Windows checkout without changing user Git config. + const args = [...windowsLongPathGitArgs(repoPath), 'worktree', 'add'] let effectiveBase: string | undefined if (noCheckout) { args.push('--no-checkout') @@ -1091,15 +1093,21 @@ export async function addSparseWorktree( options ) created = true + // Why: `worktree add --no-checkout` writes no files, so these are the calls that + // actually materialize the deep path and need the long-path escape hatch. + const longPathArgs = windowsLongPathGitArgs(worktreePath) await gitExecFileAsync( ['sparse-checkout', 'init', '--cone'], gitExecOptions(worktreePath, options) ) await gitExecFileAsync( - ['sparse-checkout', 'set', '--', ...directories], + [...longPathArgs, 'sparse-checkout', 'set', '--', ...directories], + gitExecOptions(worktreePath, options) + ) + await gitExecFileAsync( + [...longPathArgs, 'checkout', branch], gitExecOptions(worktreePath, options) ) - await gitExecFileAsync(['checkout', branch], gitExecOptions(worktreePath, options)) return addResult } catch (error) { const wrapped: SparseWorktreeCreateError = diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts index cedcf8b797c..0e7dfd282fc 100644 --- a/src/relay/git-handler-worktree-ops.test.ts +++ b/src/relay/git-handler-worktree-ops.test.ts @@ -31,12 +31,16 @@ describe('addWorktreeOp', () => { it('writes durable branch base config after creating an SSH new-branch worktree', async () => { const git = vi.fn(async () => ({ stdout: '', stderr: '' })) - await addWorktreeOp(git, { - repoPath: '/repo', - branchName: 'feature/test', - targetDir: '/repo-feature', - base: 'origin/main' - }) + await addWorktreeOp( + git, + { + repoPath: '/repo', + branchName: 'feature/test', + targetDir: '/repo-feature', + base: 'origin/main' + }, + 'linux' + ) expect(git.mock.calls.map((call) => call[0])).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], @@ -75,13 +79,17 @@ describe('addWorktreeOp', () => { it('does not write branch base config when checking out an existing SSH branch', async () => { const git = vi.fn(async () => ({ stdout: '', stderr: '' })) - await addWorktreeOp(git, { - repoPath: '/repo', - branchName: 'feature/test', - targetDir: '/repo-feature', - base: 'origin/main', - checkoutExistingBranch: true - }) + await addWorktreeOp( + git, + { + repoPath: '/repo', + branchName: 'feature/test', + targetDir: '/repo-feature', + base: 'origin/main', + checkoutExistingBranch: true + }, + 'linux' + ) expect(git.mock.calls.map((call) => call[0])).toEqual([ ['worktree', 'add', '/repo-feature', 'feature/test'] @@ -91,11 +99,15 @@ describe('addWorktreeOp', () => { it('does not write branch base config when SSH creation has no base', async () => { const git = vi.fn(async () => ({ stdout: '', stderr: '' })) - await addWorktreeOp(git, { - repoPath: '/repo', - branchName: 'feature/no-base', - targetDir: '/repo-feature' - }) + await addWorktreeOp( + git, + { + repoPath: '/repo', + branchName: 'feature/no-base', + targetDir: '/repo-feature' + }, + 'linux' + ) expect(git.mock.calls.map((call) => call[0])).toEqual([ ['worktree', 'add', '--no-track', '-b', 'feature/no-base', '/repo-feature'], @@ -103,6 +115,76 @@ describe('addWorktreeOp', () => { ]) }) + it('enables long paths when the SSH execution host is Windows', async () => { + // Why: only the host's OS matters — a macOS client can drive a Windows SSH host, + // which hits the same MAX_PATH ceiling (issue #15785). + const git = vi.fn(async () => ({ stdout: '', stderr: '' })) + + await addWorktreeOp( + git, + { + repoPath: 'C:\\repo', + branchName: 'feature/test', + targetDir: 'C:\\repo-feature', + checkoutExistingBranch: true + }, + 'win32' + ) + + expect(git.mock.calls.map((call) => call[0])).toEqual([ + ['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'] + ]) + }) + + it('keeps --no-checkout ahead of -b once the long-path prefix is present', async () => { + const git = vi.fn(async () => ({ stdout: '', stderr: '' })) + + await addWorktreeOp( + git, + { + repoPath: 'C:\\repo', + branchName: 'feature/test', + targetDir: 'C:\\repo-feature', + noCheckout: true + }, + 'win32' + ) + + expect(git.mock.calls[0][0]).toEqual([ + '-c', + 'core.longpaths=true', + 'worktree', + 'add', + '--no-track', + '--no-checkout', + '-b', + 'feature/test', + 'C:\\repo-feature' + ]) + }) + + it('omits the long-path option on a WSL UNC target on a Windows SSH host', async () => { + const git = vi.fn(async () => ({ stdout: '', stderr: '' })) + + await addWorktreeOp( + git, + { + repoPath: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo', + branchName: 'feature/test', + targetDir: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature', + checkoutExistingBranch: true + }, + 'win32' + ) + + expect(git.mock.calls[0][0]).toEqual([ + 'worktree', + 'add', + '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature', + 'feature/test' + ]) + }) + it('warns and unsets stale branch base config when SSH base persistence fails', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const git = vi.fn(async (args) => { @@ -113,12 +195,16 @@ describe('addWorktreeOp', () => { }) await expect( - addWorktreeOp(git, { - repoPath: '/repo', - branchName: 'feature/test', - targetDir: '/repo-feature', - base: 'origin/main' - }) + addWorktreeOp( + git, + { + repoPath: '/repo', + branchName: 'feature/test', + targetDir: '/repo-feature', + base: 'origin/main' + }, + 'linux' + ) ).resolves.toBeUndefined() expect(warnSpy).toHaveBeenCalledWith( diff --git a/src/relay/git-handler-worktree-ops.ts b/src/relay/git-handler-worktree-ops.ts index 0cdb0761b47..4095851a82f 100644 --- a/src/relay/git-handler-worktree-ops.ts +++ b/src/relay/git-handler-worktree-ops.ts @@ -1,5 +1,6 @@ import * as path from 'node:path' import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' +import { windowsLongPathGitArgs } from '../shared/windows-long-path-git-args' import type { GitExec } from './git-handler-ops' export { removeWorktreeOp } from './git-handler-worktree-remove' export { readRelayWorktreeList } from './git-handler-worktree-list' @@ -28,7 +29,12 @@ async function persistRelayWorktreeCreationBase( } } -export async function addWorktreeOp(git: GitExec, params: Record): Promise { +export async function addWorktreeOp( + git: GitExec, + params: Record, + // Why: only the execution host's OS matters here — the client may be macOS while the SSH host is Windows. + platform: NodeJS.Platform = process.platform +): Promise { const repoPath = params.repoPath as string const branchName = params.branchName as string const targetDir = params.targetDir as string @@ -62,11 +68,14 @@ export async function addWorktreeOp(git: GitExec, params: Record { + it('enables long paths for a Windows drive path', () => { + expect(windowsLongPathGitArgs('C:\\Users\\dev\\repo', 'win32')).toEqual([ + '-c', + 'core.longpaths=true' + ]) + }) + + it.each(['darwin', 'linux'] as const)('returns nothing on %s', (platform) => { + expect(windowsLongPathGitArgs('/home/dev/repo', platform)).toEqual([]) + }) + + it.each(['\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo', '\\\\wsl$\\Ubuntu\\home\\dev\\repo'])( + 'returns nothing for the WSL UNC path %s', + (cwd) => { + expect(windowsLongPathGitArgs(cwd, 'win32')).toEqual([]) + } + ) + + it('never mutates the shared constant', () => { + const first = windowsLongPathGitArgs('C:\\repo', 'win32') + first.push('--bogus') + expect(windowsLongPathGitArgs('C:\\repo', 'win32')).toEqual(['-c', 'core.longpaths=true']) + }) +}) diff --git a/src/shared/windows-long-path-git-args.ts b/src/shared/windows-long-path-git-args.ts new file mode 100644 index 00000000000..c22393fcaa0 --- /dev/null +++ b/src/shared/windows-long-path-git-args.ts @@ -0,0 +1,25 @@ +import { parseWslUncPath } from './wsl-paths' + +const WINDOWS_LONG_PATH_GIT_ARGS = ['-c', 'core.longpaths=true'] as const + +/** + * Global `git -c` options that let a Windows checkout exceed MAX_PATH. + * + * Why command scope: Git for Windows aborts deep checkouts with "Filename too + * long" unless core.longpaths is on, and `-c` applies it to this invocation + * only — never `--global`, `--system`, or `--local`, so no user config is + * written. Available since Git 1.9, well under the 2.25 baseline. + * + * Why keyed off cwd rather than a wslDistro option: a `C:\...` cwd can still be + * served by host git.exe even when a distro is configured, and Linux git parses + * and ignores the key, so only a true `\\wsl.localhost\...` path opts out. + */ +export function windowsLongPathGitArgs( + cwd: string, + platform: NodeJS.Platform = process.platform +): string[] { + if (platform !== 'win32' || parseWslUncPath(cwd)) { + return [] + } + return [...WINDOWS_LONG_PATH_GIT_ARGS] +}