From da4a83bd222ad694641f6e0e853c7199d57e177a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:35:37 -0700 Subject: [PATCH 01/94] fix(linux): give the CLI one entrypoint by extracting the AppImage once --- ...package-electron-runtime-contract.test.mjs | 4 + docs/reference/headless-linux-server.md | 4 +- resources/linux/packaging/after-install.sh | 17 +- src/main/appimage-runtime-identity.test.ts | 241 +++++++++++ src/main/appimage-runtime-identity.ts | 198 +++++++++ src/main/cli/appimage-cache-layout.ts | 34 ++ src/main/cli/appimage-extracted-root.test.ts | 363 ++++++++++++++++ src/main/cli/appimage-extracted-root.ts | 267 ++++++++++++ .../cli/appimage-extraction-pruning.test.ts | 221 ++++++++++ src/main/cli/appimage-extraction-pruning.ts | 142 +++++++ src/main/cli/appimage-registration-lock.ts | 32 ++ src/main/cli/appimage-stable-launcher.test.ts | 185 +++++++++ src/main/cli/appimage-stable-launcher.ts | 226 ++++++++++ .../cli/cli-command-filesystem-transaction.ts | 209 ++++++++++ src/main/cli/cli-command-inspection.ts | 67 +-- .../cli-command-installation-races.test.ts | 386 ++++++++++++++++++ src/main/cli/cli-command-installation.ts | 303 +++++++++++--- ...cli-command-privileged-transaction.test.ts | 167 ++++++++ src/main/cli/cli-install-location.ts | 57 ++- .../cli-installer-appimage-ownership.test.ts | 229 +++++++++++ .../cli-installer-appimage-removal.test.ts | 190 +++++++++ src/main/cli/cli-installer-contracts.ts | 4 +- src/main/cli/cli-installer.test.ts | 167 ++++++-- src/main/cli/cli-installer.ts | 112 ++++- ...pper.ts => legacy-appimage-cli-wrapper.ts} | 22 +- .../cli/linux-bare-orca-dispatcher.test.ts | 238 ++++++++++- src/main/cli/linux-bare-orca-dispatcher.ts | 228 ++++++++--- .../cli/linux-terminal-orca-cli-shim.test.ts | 108 ++++- src/main/cli/linux-terminal-orca-cli-shim.ts | 75 +++- src/main/cli/packaged-cli-assets.test.ts | 62 ++- .../cli-appimage-stale-registration.test.ts | 382 +++++++++++++++++ src/main/ipc/cli.ts | 71 +++- src/main/ipc/pty-ipc-mock-registry.ts | 5 +- src/main/ipc/pty-ipc-suite-environment.ts | 6 + ...restored-appimage-cli-shim-refresh.test.ts | 76 ++++ src/main/ipc/pty/register-handlers.ts | 8 + 36 files changed, 4799 insertions(+), 307 deletions(-) create mode 100644 src/main/appimage-runtime-identity.test.ts create mode 100644 src/main/appimage-runtime-identity.ts create mode 100644 src/main/cli/appimage-cache-layout.ts create mode 100644 src/main/cli/appimage-extracted-root.test.ts create mode 100644 src/main/cli/appimage-extracted-root.ts create mode 100644 src/main/cli/appimage-extraction-pruning.test.ts create mode 100644 src/main/cli/appimage-extraction-pruning.ts create mode 100644 src/main/cli/appimage-registration-lock.ts create mode 100644 src/main/cli/appimage-stable-launcher.test.ts create mode 100644 src/main/cli/appimage-stable-launcher.ts create mode 100644 src/main/cli/cli-command-filesystem-transaction.ts create mode 100644 src/main/cli/cli-command-installation-races.test.ts create mode 100644 src/main/cli/cli-command-privileged-transaction.test.ts create mode 100644 src/main/cli/cli-installer-appimage-ownership.test.ts create mode 100644 src/main/cli/cli-installer-appimage-removal.test.ts rename src/main/cli/{appimage-cli-wrapper.ts => legacy-appimage-cli-wrapper.ts} (61%) create mode 100644 src/main/ipc/cli-appimage-stale-registration.test.ts create mode 100644 src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 8945c7b9f8a..9f62802c84f 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -363,6 +363,10 @@ describe('Electron runtime package contract', () => { expect(afterInstallScript).toContain('chrome-sandbox') expect(afterInstallScript).toContain('chmod 4755 "$sandbox"') expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"') + expect(afterInstallScript).toContain('is_owned_link()') + expect(afterInstallScript).toContain('readlink -f -- "$link"') + expect(afterInstallScript).toContain('[ ! -e "$link" ] && [ ! -L "$link" ]') + expect(afterInstallScript).not.toContain('[ ! -e "$link" ] || [ -L "$link" ]') }) it('advances only the skill release ledger in a taggable release-cut commit', () => { diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index dc3fdf31da0..beb0220d15b 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -56,7 +56,9 @@ of the libraries installed. On Ubuntu 20.04 and 22.04, install `libfuse2` to execute the AppImage through FUSE. On Ubuntu 24.04 and Debian 13 the package is `libfuse2t64`, though the plain `libfuse2` name also resolves there because nothing else provides it. FUSE is -optional: without it, use the AppImage's supported extraction path: +optional: without it, use the AppImage's supported extraction path. CLI +registration does this once automatically, so registered commands do not need +FUSE: ```bash cd /opt/orca diff --git a/resources/linux/packaging/after-install.sh b/resources/linux/packaging/after-install.sh index a5b598e2bf2..06e77ecbaaa 100755 --- a/resources/linux/packaging/after-install.sh +++ b/resources/linux/packaging/after-install.sh @@ -11,6 +11,19 @@ set -e link="/usr/bin/orca-ide" +is_owned_link() { + [ -L "$link" ] || return 1 + local link_target candidate candidate_target + link_target="$(readlink -f -- "$link" 2>/dev/null || true)" + for candidate in /opt/Orca/resources/bin/orca-ide /opt/orca-ide/resources/bin/orca-ide /opt/orca/resources/bin/orca-ide; do + candidate_target="$(readlink -f -- "$candidate" 2>/dev/null || true)" + if [ -n "$candidate_target" ] && [ "$link_target" = "$candidate_target" ]; then + return 0 + fi + done + return 1 +} + for dir in /opt/Orca /opt/orca-ide /opt/orca; do sandbox="$dir/chrome-sandbox" if [ -f "$sandbox" ]; then @@ -22,8 +35,8 @@ for dir in /opt/Orca /opt/orca-ide /opt/orca; do shim="$dir/resources/bin/orca-ide" if [ -x "$shim" ]; then # Only manage our own symlink; never clobber an unrelated /usr/bin/orca-ide. - if [ ! -e "$link" ] || [ -L "$link" ]; then - ln -sf "$shim" "$link" + if { [ ! -e "$link" ] && [ ! -L "$link" ]; } || is_owned_link; then + ln -sfn -- "$shim" "$link" fi break fi diff --git a/src/main/appimage-runtime-identity.test.ts b/src/main/appimage-runtime-identity.test.ts new file mode 100644 index 00000000000..fe7d44dd5f5 --- /dev/null +++ b/src/main/appimage-runtime-identity.test.ts @@ -0,0 +1,241 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + hasAppImageRuntimeEnvironment, + resolveAppImageRuntimeIdentity +} from './appimage-runtime-identity' + +const fixtureRoots: string[] = [] + +function appImageHeader(machine: number): Buffer { + const header = Buffer.alloc(64) + header.set([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01]) + header.set([0x41, 0x49, 0x02], 8) + header.writeUInt16LE(machine, 18) + return header +} + +function createFixture(appDirName = '.mount_Orca123', machine = 0x3e) { + const root = mkdtempSync(join(tmpdir(), 'orca-appimage-identity-')) + const appImagePath = join(root, 'Applications', 'Orca.AppImage') + const appDirPath = join(root, appDirName) + const execPath = join(appDirPath, 'orca-ide') + const resourcesPath = join(appDirPath, 'resources') + const packageTypePath = join(resourcesPath, 'package-type') + const packageMarkerPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json') + fixtureRoots.push(root) + mkdirSync(dirname(appImagePath), { recursive: true }) + mkdirSync(dirname(packageMarkerPath), { recursive: true }) + writeFileSync(appImagePath, appImageHeader(machine), { mode: 0o755 }) + writeFileSync(join(appDirPath, 'AppRun'), '#!/bin/sh\n', { mode: 0o755 }) + writeFileSync(execPath, appImageHeader(machine), { mode: 0o755 }) + writeFileSync( + packageMarkerPath, + JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs', private: true }) + ) + return { + root, + appImagePath, + appDirPath, + execPath, + resourcesPath, + packageTypePath, + packageMarkerPath, + identity: { + platform: 'linux' as const, + environment: { APPIMAGE: appImagePath, APPDIR: appDirPath }, + execPath, + resourcesPath + } + } +} + +afterEach(() => { + for (const root of fixtureRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe.skipIf(process.platform === 'win32')('resolveAppImageRuntimeIdentity', () => { + it.each([ + ['x64', 0x3e, '.mount_Orca123'], + ['ARM64 extract-and-run', 0xb7, 'appimage_extracted_123'] + ])('accepts a complete %s AppImage runtime', (_architecture, machine, appDirName) => { + const fixture = createFixture(appDirName, machine) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toEqual({ + appImagePath: fixture.appImagePath, + appDirPath: fixture.appDirPath + }) + }) + + it('accepts an AppImage moved independently of its runtime directory', () => { + const fixture = createFixture() + const movedPath = join(fixture.root, 'Moved Apps', 'Orca current.AppImage') + mkdirSync(dirname(movedPath), { recursive: true }) + renameSync(fixture.appImagePath, movedPath) + fixture.identity.environment.APPIMAGE = movedPath + expect(resolveAppImageRuntimeIdentity(fixture.identity)?.appImagePath).toBe(movedPath) + }) + + it('accepts the exact AppImage package-type marker', () => { + const fixture = createFixture() + rmSync(fixture.packageMarkerPath) + writeFileSync(fixture.packageTypePath, 'AppImage') + expect(resolveAppImageRuntimeIdentity(fixture.identity)).not.toBeNull() + }) + + it.each([ + ['APPIMAGE', undefined], + ['APPIMAGE', 'relative/Orca.AppImage'], + ['APPDIR', undefined], + ['APPDIR', 'relative/mount'], + ['APPIMAGE', '/tmp/Orca\0.AppImage'] + ] as const)('rejects an unusable %s value', (key, value) => { + const fixture = createFixture() + expect( + resolveAppImageRuntimeIdentity({ + ...fixture.identity, + environment: { ...fixture.identity.environment, [key]: value } + }) + ).toBeNull() + }) + + it.each([ + ['an ordinary executable', (path: string) => writeFileSync(path, '#!/bin/sh\n')], + [ + 'bad ELF magic', + (path: string) => { + const header = appImageHeader(0x3e) + header[0] = 0 + writeFileSync(path, header) + } + ], + [ + 'bad AppImage type magic', + (path: string) => { + const header = appImageHeader(0x3e) + header[10] = 1 + writeFileSync(path, header) + } + ], + ['a non-executable file', (path: string) => chmodSync(path, 0o644)], + [ + 'a directory', + (path: string) => { + rmSync(path) + mkdirSync(path) + } + ] + ])('rejects APPIMAGE pointing to %s', (_case, mutate) => { + const fixture = createFixture() + mutate(fixture.appImagePath) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it.each([ + [ + 'AppRun', + (fixture: ReturnType) => rmSync(join(fixture.appDirPath, 'AppRun')) + ], + [ + 'an executable AppRun', + (fixture: ReturnType) => + chmodSync(join(fixture.appDirPath, 'AppRun'), 0o644) + ], + [ + 'the Orca package marker', + (fixture: ReturnType) => rmSync(fixture.packageMarkerPath) + ] + ])('rejects a runtime missing %s', (_case, mutate) => { + const fixture = createFixture() + mutate(fixture) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects forged AppImage variables around an ordinary packaged layout', () => { + const fixture = createFixture() + rmSync(join(fixture.appDirPath, 'AppRun')) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects a package marker for a different application', () => { + const fixture = createFixture() + writeFileSync( + fixture.packageMarkerPath, + JSON.stringify({ name: 'foreign-compiled-output', type: 'commonjs' }) + ) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects an inexact package-type marker without the Orca fallback', () => { + const fixture = createFixture() + rmSync(fixture.packageMarkerPath) + writeFileSync(fixture.packageTypePath, 'appimage') + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects payload evidence that resolves outside APPDIR', () => { + const fixture = createFixture() + const externalAppRun = join(fixture.root, 'foreign-AppRun') + writeFileSync(externalAppRun, '#!/bin/sh\n', { mode: 0o755 }) + rmSync(join(fixture.appDirPath, 'AppRun')) + symlinkSync(externalAppRun, join(fixture.appDirPath, 'AppRun')) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects a package marker that resolves outside APPDIR', () => { + const fixture = createFixture() + const externalMarker = join(fixture.root, 'foreign-package.json') + writeFileSync( + externalMarker, + JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs' }) + ) + rmSync(fixture.packageMarkerPath) + symlinkSync(externalMarker, fixture.packageMarkerPath) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects inherited AppImage variables around a non-AppImage executable', () => { + const fixture = createFixture() + expect( + resolveAppImageRuntimeIdentity({ + ...fixture.identity, + execPath: join(fixture.root, 'opt', 'Orca', 'orca-ide'), + resourcesPath: join(fixture.root, 'opt', 'Orca', 'resources') + }) + ).toBeNull() + }) + + it('rejects a resources path outside the runtime root', () => { + const fixture = createFixture() + expect( + resolveAppImageRuntimeIdentity({ + ...fixture.identity, + resourcesPath: `${fixture.appDirPath}-other/resources` + }) + ).toBeNull() + }) + + it('rejects the identity off Linux', () => { + const fixture = createFixture() + expect(resolveAppImageRuntimeIdentity({ ...fixture.identity, platform: 'darwin' })).toBeNull() + }) +}) + +describe('hasAppImageRuntimeEnvironment', () => { + it('detects either AppImage runtime variable', () => { + expect(hasAppImageRuntimeEnvironment({ APPIMAGE: '/tmp/Orca.AppImage' })).toBe(true) + expect(hasAppImageRuntimeEnvironment({ APPDIR: '/tmp/.mount_Orca123' })).toBe(true) + expect(hasAppImageRuntimeEnvironment({ APPIMAGE: '', APPDIR: '' })).toBe(false) + }) +}) diff --git a/src/main/appimage-runtime-identity.ts b/src/main/appimage-runtime-identity.ts new file mode 100644 index 00000000000..810be5f042a --- /dev/null +++ b/src/main/appimage-runtime-identity.ts @@ -0,0 +1,198 @@ +import { + closeSync, + constants, + fstatSync, + openSync, + readSync, + realpathSync, + statSync, + type Stats +} from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' + +const APPIMAGE_HEADER_LENGTH = 11 +const ORCA_PACKAGE_MARKER_MAX_BYTES = 1_024 +const PACKAGE_TYPE_MARKER_MAX_BYTES = 32 + +export type AppImageRuntimeIdentity = { + appImagePath: string + appDirPath: string +} + +export type AppImageRuntimeIdentityInput = { + platform?: NodeJS.Platform + environment?: NodeJS.ProcessEnv + execPath?: unknown + resourcesPath?: unknown +} + +function isAbsolutePath(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && !value.includes('\0') && isAbsolute(value) +} + +function readExact(fd: number, length: number): Buffer | null { + const bytes = Buffer.alloc(length) + let offset = 0 + while (offset < length) { + const count = readSync(fd, bytes, offset, length - offset, offset) + if (count === 0) { + return null + } + offset += count + } + return bytes +} + +function inspectRegularFile( + filePath: string, + inspect: (fd: number, stats: Stats) => T +): T | null { + let fd: number | undefined + try { + fd = openSync(filePath, constants.O_RDONLY | constants.O_NONBLOCK) + const stats = fstatSync(fd) + return stats.isFile() ? inspect(fd, stats) : null + } catch { + return null + } finally { + if (fd !== undefined) { + closeSync(fd) + } + } +} + +function hasAppImageHeader(appImagePath: string): boolean { + return ( + inspectRegularFile(appImagePath, (fd, stats) => { + const header = readExact(fd, APPIMAGE_HEADER_LENGTH) + return ( + (stats.mode & 0o111) !== 0 && + header?.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) === true && + header.subarray(8, 11).equals(Buffer.from([0x41, 0x49, 0x02])) + ) + }) === true + ) +} + +function isInside(rootPath: string, candidatePath: string): boolean { + const candidateRelative = relative(rootPath, candidatePath) + return ( + candidateRelative.length > 0 && + candidateRelative !== '..' && + !candidateRelative.startsWith(`..${sep}`) && + !isAbsolute(candidateRelative) + ) +} + +function isExecutablePayloadFile(runtimeRoot: string, filePath: string): boolean { + try { + const canonicalPath = realpathSync(filePath) + const stats = statSync(canonicalPath) + return stats.isFile() && (stats.mode & 0o111) !== 0 && isInside(runtimeRoot, canonicalPath) + } catch { + return false + } +} + +function readPayloadMarker( + runtimeRoot: string, + markerPath: string, + maxBytes: number +): string | null { + try { + const canonicalPath = realpathSync(markerPath) + if (!isInside(runtimeRoot, canonicalPath)) { + return null + } + return inspectRegularFile(canonicalPath, (fd, stats) => + stats.size === 0 || stats.size > maxBytes + ? null + : (readExact(fd, stats.size)?.toString('utf8') ?? null) + ) + } catch { + return null + } +} + +function hasAppImagePackageEvidence(runtimeRoot: string, resourcesPath: string): boolean { + const packageType = readPayloadMarker( + runtimeRoot, + join(resourcesPath, 'package-type'), + PACKAGE_TYPE_MARKER_MAX_BYTES + ) + if (packageType === 'AppImage') { + return true + } + + const content = readPayloadMarker( + runtimeRoot, + join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json'), + ORCA_PACKAGE_MARKER_MAX_BYTES + ) + try { + const marker: unknown = JSON.parse(content ?? '') + return ( + typeof marker === 'object' && + marker !== null && + 'name' in marker && + marker.name === 'orca-compiled-output' && + 'type' in marker && + marker.type === 'commonjs' + ) + } catch { + return false + } +} + +export function hasAppImageRuntimeEnvironment( + environment: NodeJS.ProcessEnv = process.env +): boolean { + return Boolean(environment.APPIMAGE || environment.APPDIR) +} + +export function resolveAppImageRuntimeIdentity( + input: AppImageRuntimeIdentityInput = {} +): AppImageRuntimeIdentity | null { + if ((input.platform ?? process.platform) !== 'linux') { + return null + } + + const environment = input.environment ?? process.env + const appImagePath = environment.APPIMAGE + const appDirPath = environment.APPDIR + const execPath = input.execPath ?? process.execPath + const resourcesPath = input.resourcesPath ?? process.resourcesPath + if ( + !isAbsolutePath(appImagePath) || + !isAbsolutePath(appDirPath) || + !isAbsolutePath(execPath) || + !isAbsolutePath(resourcesPath) + ) { + return null + } + + const runtimeRoot = resolve(appDirPath) + if ( + resolve(dirname(execPath)) !== runtimeRoot || + resolve(resourcesPath) !== resolve(join(runtimeRoot, 'resources')) || + !hasAppImageHeader(appImagePath) + ) { + return null + } + + try { + const realRuntimeRoot = realpathSync(runtimeRoot) + if ( + realpathSync(resourcesPath) !== join(realRuntimeRoot, 'resources') || + !isExecutablePayloadFile(realRuntimeRoot, execPath) || + !isExecutablePayloadFile(realRuntimeRoot, join(runtimeRoot, 'AppRun')) || + !hasAppImagePackageEvidence(realRuntimeRoot, resourcesPath) + ) { + return null + } + } catch { + return null + } + + return { appImagePath, appDirPath: runtimeRoot } +} diff --git a/src/main/cli/appimage-cache-layout.ts b/src/main/cli/appimage-cache-layout.ts new file mode 100644 index 00000000000..4c692a94a9d --- /dev/null +++ b/src/main/cli/appimage-cache-layout.ts @@ -0,0 +1,34 @@ +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' + +const CACHE_KEY_PATTERN = /^[0-9a-f]{24}$/u + +export function isAppImageCacheKey(value: string): boolean { + return CACHE_KEY_PATTERN.test(value) +} + +export function resolveCachedAppImagePayloadRoot( + cacheRootPath: string, + candidatePath: string, + launcherName = LINUX_CLI_COMMAND_NAME +): string | null { + if (!isAbsolute(candidatePath)) { + return null + } + const resolvedCacheRoot = resolve(cacheRootPath) + const segments = relative(resolvedCacheRoot, resolve(candidatePath)).split(sep) + const [namespaceKey, generationKey, resources, bin, candidateLauncherName] = segments + if ( + segments.length !== 5 || + !namespaceKey || + !generationKey || + !isAppImageCacheKey(namespaceKey) || + !isAppImageCacheKey(generationKey) || + resources !== 'resources' || + bin !== 'bin' || + candidateLauncherName !== launcherName + ) { + return null + } + return join(resolvedCacheRoot, namespaceKey, generationKey) +} diff --git a/src/main/cli/appimage-extracted-root.test.ts b/src/main/cli/appimage-extracted-root.test.ts new file mode 100644 index 00000000000..3d50e0f6687 --- /dev/null +++ b/src/main/cli/appimage-extracted-root.test.ts @@ -0,0 +1,363 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + ensureAppImageExtractedRoot, + getAppImageCacheRootPath, + isAppImageExtractedLauncherPath, + isAppImageExtractionComplete, + isAppImageInstalledLauncherOwnedBySibling, + resolveAppImageCacheKey, + resolveAppImageExtractedRoot +} from './appimage-extracted-root' +import { getAppImageActiveExtractionPath } from './appimage-extraction-pruning' +import { + publishAppImageLauncherEndpoint, + resolveAppImageStableLauncherPath +} from './appimage-stable-launcher' + +const created: string[] = [] + +afterEach(async () => { + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function makeFixture(): Promise<{ + root: string + appImagePath: string + cacheRootPath: string +}> { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-extract-')) + created.push(root) + const appImagePath = join(root, 'Orca.AppImage') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + return { root, appImagePath, cacheRootPath: join(root, 'cache') } +} + +/** Stands in for the AppImage runtime, which writes ./squashfs-root under cwd. */ +async function writePayload(cwd: string, content = ''): Promise { + const launcherDir = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDir, { recursive: true }) + await writeFile(join(launcherDir, 'orca-ide'), content, { encoding: 'utf8', mode: 0o755 }) +} + +describe('appimage extracted root', () => { + it('derives the cache root from XDG_CACHE_HOME when set', () => { + const previous = process.env.XDG_CACHE_HOME + process.env.XDG_CACHE_HOME = '/xdg-cache' + try { + expect(getAppImageCacheRootPath('/home/u')).toBe(join('/xdg-cache', 'orca', 'appimage')) + } finally { + if (previous === undefined) { + delete process.env.XDG_CACHE_HOME + } else { + process.env.XDG_CACHE_HOME = previous + } + } + }) + + it('ignores a relative XDG_CACHE_HOME', () => { + const previous = process.env.XDG_CACHE_HOME + process.env.XDG_CACHE_HOME = 'relative-cache' + try { + expect(getAppImageCacheRootPath('/home/u')).toBe( + join('/home/u', '.cache', 'orca', 'appimage') + ) + } finally { + if (previous === undefined) { + delete process.env.XDG_CACHE_HOME + } else { + process.env.XDG_CACHE_HOME = previous + } + } + }) + + it('extracts once and reuses the payload on the next call', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + let extractCount = 0 + const runExtract = async (_path: string, cwd: string): Promise => { + extractCount += 1 + await writePayload(cwd) + } + + const first = await ensureAppImageExtractedRoot({ appImagePath, cacheRootPath, runExtract }) + const second = await ensureAppImageExtractedRoot({ appImagePath, cacheRootPath, runExtract }) + + expect(extractCount).toBe(1) + expect(second?.stableLauncherPath).toBe(first?.stableLauncherPath) + expect(isAppImageExtractionComplete(first!)).toBe(true) + }) + + // Why: an update replaces the file in place, so stat identity must change the key or the command + // would keep resolving through the previous version's payload. + it('keys the payload on file identity and metadata, not just path', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const before = resolveAppImageCacheKey(appImagePath) + await writeFile(appImagePath, '#!/usr/bin/env bash\n# newer\n', { + encoding: 'utf8', + mode: 0o755 + }) + + expect(resolveAppImageCacheKey(appImagePath)).not.toBe(before) + expect(resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })?.rootPath).toContain( + resolveAppImageCacheKey(appImagePath) as string + ) + }) + + // Why: a crashed extraction must not leave a directory that later reads treat + // as a usable payload — the command would exec a path that does not exist. + it('publishes nothing when extraction fails partway', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + await mkdir(join(cwd, 'squashfs-root'), { recursive: true }) + throw new Error('extraction interrupted') + } + }) + + expect(result).toBeNull() + await expect(readdir(cacheRootPath)).resolves.toEqual([]) + }) + + it('reports failure when the payload has no launcher', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + await mkdir(join(cwd, 'squashfs-root'), { recursive: true }) + } + }) + + expect(result).toBeNull() + }) + + it('retains one retry payload when a foreign stable launcher blocks publication', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + let extractionCount = 0 + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\nprintf foreign\n', { mode: 0o755 }) + + const extract = async (_path: string, cwd: string): Promise => { + extractionCount += 1 + await writePayload(cwd) + } + const options = { appImagePath, cacheRootPath, runExtract: extract } + + await expect(ensureAppImageExtractedRoot(options)).resolves.toBeNull() + await expect(ensureAppImageExtractedRoot(options)).resolves.toBeNull() + expect(extractionCount).toBe(1) + expect(existsSync(root.rootPath)).toBe(true) + expect(existsSync(getAppImageActiveExtractionPath(root.rootPath))).toBe(false) + await expect(readFile(launcherPath, 'utf8')).resolves.toContain('foreign') + }) + + it.each(['directory', 'non-executable'] as const)( + 'rejects a %s launcher entry', + async (entryKind) => { + const { appImagePath, cacheRootPath } = await makeFixture() + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + const launcherPath = join(cwd, 'squashfs-root', 'resources', 'bin', 'orca-ide') + if (entryKind === 'directory') { + await mkdir(launcherPath, { recursive: true }) + } else { + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o644 }) + } + } + }) + + expect(result).toBeNull() + } + ) + + it.skipIf(process.platform === 'win32')( + 'rejects a launcher symlink even when its target is executable', + async () => { + const { root, appImagePath, cacheRootPath } = await makeFixture() + const executable = join(root, 'foreign-launcher') + await writeFile(executable, '#!/usr/bin/env bash\n', { mode: 0o755 }) + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + const launcherPath = join(cwd, 'squashfs-root', 'resources', 'bin', 'orca-ide') + await mkdir(dirname(launcherPath), { recursive: true }) + await symlink(executable, launcherPath) + } + }) + + expect(result).toBeNull() + } + ) + + it('replaces an incomplete exact extraction root', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const partialPath = join(root.rootPath, 'partial') + await mkdir(root.rootPath, { recursive: true }) + await writeFile(partialPath, 'interrupted') + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => writePayload(cwd, 'recovered') + }) + + expect(result).toEqual(root) + await expect(readFile(root.payloadLauncherPath, 'utf8')).resolves.toBe('recovered') + expect(existsSync(partialPath)).toBe(false) + }) + + it('preserves the winner when concurrent calls repair an incomplete root', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + await mkdir(root.rootPath, { recursive: true }) + await writeFile(join(root.rootPath, 'partial'), 'interrupted') + let readyCount = 0 + let release!: () => void + const bothReady = new Promise((resolve) => { + release = resolve + }) + const runExtract = async (_path: string, cwd: string): Promise => { + readyCount += 1 + await writePayload(cwd, `winner-${readyCount}`) + if (readyCount === 2) { + release() + } + await bothReady + } + + const results = await Promise.all([ + ensureAppImageExtractedRoot({ appImagePath, cacheRootPath, runExtract }), + ensureAppImageExtractedRoot({ appImagePath, cacheRootPath, runExtract }) + ]) + + expect(results).toEqual([root, root]) + expect(['winner-1', 'winner-2']).toContain(await readFile(root.payloadLauncherPath, 'utf8')) + }) + + it('retries with the current generation when the AppImage changes during extraction', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const initialRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + let extractCount = 0 + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + extractCount += 1 + await writePayload(cwd, `generation-${extractCount}`) + if (extractCount === 1) { + await writeFile(appImagePath, '#!/usr/bin/env bash\n# replaced during extraction\n', { + encoding: 'utf8', + mode: 0o755 + }) + } + } + }) + + const currentRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + expect(extractCount).toBe(2) + expect(result).toEqual(currentRoot) + expect(result?.rootPath).not.toBe(initialRoot.rootPath) + await expect(readFile(currentRoot.payloadLauncherPath, 'utf8')).resolves.toBe('generation-2') + expect(existsSync(initialRoot.rootPath)).toBe(false) + }) + + it('bounds retries when every extraction changes the AppImage generation', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + let extractCount = 0 + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + extractCount += 1 + await writePayload(cwd) + await writeFile(appImagePath, '#'.repeat(extractCount + 1), { mode: 0o755 }) + } + }) + + expect(result).toBeNull() + expect(extractCount).toBe(2) + }) + + it('returns null for an AppImage that is not there', async () => { + const { root, cacheRootPath } = await makeFixture() + const missing = join(root, 'Absent.AppImage') + + expect(resolveAppImageCacheKey(missing)).toBeNull() + expect(resolveAppImageExtractedRoot({ appImagePath: missing, cacheRootPath })).toBeNull() + }) + + it('recognizes managed launchers across AppImage path namespaces', async () => { + const { root, appImagePath, cacheRootPath } = await makeFixture() + const current = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const previousGeneration = join( + dirname(current.rootPath), + 'a'.repeat(24), + 'resources', + 'bin', + 'orca-ide' + ) + const otherAppImagePath = join(root, 'Other.AppImage') + await writeFile(otherAppImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + const other = resolveAppImageExtractedRoot({ + appImagePath: otherAppImagePath, + cacheRootPath + })! + + expect( + isAppImageExtractedLauncherPath({ appImagePath, cacheRootPath }, current.stableLauncherPath) + ).toBe(true) + expect( + isAppImageExtractedLauncherPath({ appImagePath, cacheRootPath }, previousGeneration) + ).toBe(true) + expect( + isAppImageExtractedLauncherPath({ appImagePath, cacheRootPath }, other.stableLauncherPath) + ).toBe(true) + expect( + isAppImageExtractedLauncherPath({ appImagePath, cacheRootPath }, other.payloadLauncherPath) + ).toBe(true) + expect( + isAppImageExtractedLauncherPath( + { appImagePath, cacheRootPath }, + join(root, 'foreign', 'resources', 'bin', 'orca-ide') + ) + ).toBe(false) + }) + + it('requires a sibling installed endpoint to target an executable payload', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const siblingLauncher = join( + cacheRootPath, + 'a'.repeat(24), + 'b'.repeat(24), + 'resources', + 'bin', + 'orca-ide' + ) + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', siblingLauncher) + const options = { appImagePath, cacheRootPath } + + expect(isAppImageInstalledLauncherOwnedBySibling(options)).toBe(false) + + await mkdir(dirname(siblingLauncher), { recursive: true }) + await writeFile(siblingLauncher, '#!/usr/bin/env bash\n', { mode: 0o755 }) + expect(isAppImageInstalledLauncherOwnedBySibling(options)).toBe(true) + }) +}) diff --git a/src/main/cli/appimage-extracted-root.ts b/src/main/cli/appimage-extracted-root.ts new file mode 100644 index 00000000000..ae37ff8a34f --- /dev/null +++ b/src/main/cli/appimage-extracted-root.ts @@ -0,0 +1,267 @@ +import { createHash } from 'node:crypto' +import { lstatSync, readlinkSync, statSync } from 'node:fs' +import { mkdir, mkdtemp, rename, rm, rmdir, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { resolveCachedAppImagePayloadRoot } from './appimage-cache-layout' +import { LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' +import { + APPIMAGE_EXTRACTION_TIMEOUT_MS, + getAppImageActiveExtractionPath, + pruneAppImageExtractedRoots, + trackAppImageExtraction +} from './appimage-extraction-pruning' +import { + isAppImageStableLauncherReady, + publishAppImageLauncherEndpoint, + resolveAppImageLauncherEndpointPath, + resolveAppImageStableLauncherPath +} from './appimage-stable-launcher' + +const CACHE_DIR_SEGMENTS = ['orca', 'appimage'] as const +const EXTRACT_OUTPUT_DIR = 'squashfs-root' +const MAX_GENERATION_ATTEMPTS = 2 +const EXTRACTION_STAGING_PREFIX = '.extract-' + +export type AppImageExtractedRoot = { + rootPath: string + payloadLauncherPath: string + stableLauncherPath: string +} + +export type AppImageExtractionOptions = { + appImagePath: string + cacheRootPath?: string + runExtract?: (appImagePath: string, cwd: string) => Promise +} + +export function getAppImageCacheRootPath(homePath = homedir()): string { + const xdgCacheHome = process.env.XDG_CACHE_HOME + const cacheHome = + xdgCacheHome && isAbsolute(xdgCacheHome) ? xdgCacheHome : join(homePath, '.cache') + return join(cacheHome, ...CACHE_DIR_SEGMENTS) +} + +export function resolveAppImageCacheKey(appImagePath: string): string | null { + try { + const stats = statSync(appImagePath) + return digest(`${stats.dev}\0${stats.ino}\0${stats.size}\0${stats.mtimeMs}\0${stats.ctimeMs}`) + } catch { + return null + } +} + +export function resolveAppImageExtractedRoot( + options: AppImageExtractionOptions +): AppImageExtractedRoot | null { + const cacheKey = resolveAppImageCacheKey(options.appImagePath) + if (!cacheKey) { + return null + } + const cacheRootPath = resolveAppImageCacheRootPath(options) + const rootPath = join(resolveAppImageNamespacePath(options), cacheKey) + return extractedRootAt(rootPath, cacheRootPath) +} + +export function isAppImageExtractedLauncherPath( + options: AppImageExtractionOptions, + candidatePath: string, + launcherName = LINUX_CLI_COMMAND_NAME +): boolean { + if (!isAbsolute(candidatePath)) { + return false + } + const cacheRootPath = resolveAppImageCacheRootPath(options) + if ( + launcherName === LINUX_CLI_COMMAND_NAME && + resolve(candidatePath) === resolveAppImageStableLauncherPath(cacheRootPath) + ) { + return true + } + + return resolveCachedAppImagePayloadRoot(cacheRootPath, candidatePath, launcherName) !== null +} + +export function isAppImageExtractionComplete(root: AppImageExtractedRoot): boolean { + return hasPayloadLauncher(root.rootPath) +} + +export function isAppImageInstalledLauncherCurrent(options: AppImageExtractionOptions): boolean { + const root = resolveAppImageExtractedRoot(options) + const cacheRootPath = resolveAppImageCacheRootPath(options) + if ( + !root || + !isAppImageStableLauncherReady(cacheRootPath) || + !hasPayloadLauncher(root.rootPath) + ) { + return false + } + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + try { + return resolve(dirname(endpointPath), readlinkSync(endpointPath)) === root.payloadLauncherPath + } catch { + return false + } +} + +export function isAppImageInstalledLauncherOwnedBySibling( + options: AppImageExtractionOptions +): boolean { + const cacheRootPath = resolveAppImageCacheRootPath(options) + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + try { + const targetPath = resolve(dirname(endpointPath), readlinkSync(endpointPath)) + const targetRoot = resolveCachedAppImagePayloadRoot(cacheRootPath, targetPath) + return ( + targetRoot !== null && + hasPayloadLauncher(targetRoot) && + dirname(targetRoot) !== resolveAppImageNamespacePath(options) + ) + } catch { + return false + } +} + +export async function ensureAppImageExtractedRoot( + options: AppImageExtractionOptions +): Promise { + for (let attempt = 0; attempt < MAX_GENERATION_ATTEMPTS; attempt += 1) { + const root = resolveAppImageExtractedRoot(options) + if (!root) { + return null + } + const complete = + isAppImageExtractionComplete(root) || (await extractAppImageGeneration(options, root)) + if (isCurrentGeneration(options, root)) { + if (!complete || !isAppImageExtractionComplete(root)) { + await cleanFailedEndpointPublication(root) + continue + } + const launcherPath = publishAppImageLauncherEndpoint( + resolveAppImageCacheRootPath(options), + 'installed', + root.payloadLauncherPath + ) + if (launcherPath === root.stableLauncherPath) { + await rm(getAppImageActiveExtractionPath(root.rootPath), { force: true }).catch(() => {}) + return root + } + } + await cleanFailedEndpointPublication(root) + } + return null +} + +async function cleanFailedEndpointPublication(root: AppImageExtractedRoot): Promise { + await rm(getAppImageActiveExtractionPath(root.rootPath), { force: true }).catch(() => {}) + await pruneAppImageExtractedRoots(root.rootPath) +} + +async function extractAppImageGeneration( + options: AppImageExtractionOptions, + root: AppImageExtractedRoot +): Promise { + const namespacePath = dirname(root.rootPath) + await mkdir(namespacePath, { recursive: true }) + const stagingPath = await mkdtemp(join(namespacePath, EXTRACTION_STAGING_PREFIX)) + const stopTracking = trackAppImageExtraction(stagingPath) + try { + await (options.runExtract ?? runAppImageExtract)(options.appImagePath, stagingPath) + const extractedPath = join(stagingPath, EXTRACT_OUTPUT_DIR) + if (!hasPayloadLauncher(extractedPath) || !isCurrentGeneration(options, root)) { + return false + } + await writeFile(getAppImageActiveExtractionPath(root.rootPath), '') + return await publishExtractedRoot(extractedPath, root) + } catch { + // A concurrent extractor may have published the same payload first. + return isAppImageExtractionComplete(root) + } finally { + stopTracking() + await rm(stagingPath, { recursive: true, force: true }).catch(() => {}) + await rmdir(namespacePath).catch(() => {}) + } +} + +function digest(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 24) +} + +export function resolveAppImageNamespacePath(options: AppImageExtractionOptions): string { + return join(resolveAppImageCacheRootPath(options), digest(options.appImagePath)) +} + +export function resolveAppImageCacheRootPath(options: AppImageExtractionOptions): string { + return resolve(options.cacheRootPath ?? getAppImageCacheRootPath()) +} + +function extractedRootAt(rootPath: string, cacheRootPath: string): AppImageExtractedRoot { + return { + rootPath, + payloadLauncherPath: join(rootPath, 'resources', 'bin', LINUX_CLI_COMMAND_NAME), + stableLauncherPath: resolveAppImageStableLauncherPath(cacheRootPath) + } +} + +function isCurrentGeneration( + options: AppImageExtractionOptions, + root: AppImageExtractedRoot +): boolean { + return resolveAppImageExtractedRoot(options)?.rootPath === root.rootPath +} + +async function publishExtractedRoot( + extractedPath: string, + root: AppImageExtractedRoot +): Promise { + if ((await renameRoot(extractedPath, root.rootPath)) || isAppImageExtractionComplete(root)) { + return true + } + + // Claim the destination atomically so a raced complete winner can be restored. + const displacedPath = `${extractedPath}.displaced` + if (!(await renameRoot(root.rootPath, displacedPath))) { + return (await renameRoot(extractedPath, root.rootPath)) || isAppImageExtractionComplete(root) + } + if (hasPayloadLauncher(displacedPath)) { + return (await renameRoot(displacedPath, root.rootPath)) || isAppImageExtractionComplete(root) + } + await rm(displacedPath, { recursive: true, force: true }) + return (await renameRoot(extractedPath, root.rootPath)) || isAppImageExtractionComplete(root) +} + +function hasPayloadLauncher(rootPath: string): boolean { + try { + const stats = lstatSync(join(rootPath, 'resources', 'bin', LINUX_CLI_COMMAND_NAME)) + return stats.isFile() && (stats.mode & 0o111) !== 0 + } catch { + return false + } +} + +async function renameRoot(sourcePath: string, destinationPath: string): Promise { + try { + await rename(sourcePath, destinationPath) + return true + } catch { + return false + } +} + +async function runAppImageExtract(appImagePath: string, cwd: string): Promise { + const result = await runProcess({ + program: appImagePath, + args: ['--appimage-extract'], + cwd, + timeoutMs: APPIMAGE_EXTRACTION_TIMEOUT_MS, + maxOutputBytes: 1024 * 1024, + terminationBarrier: true + }) + if (result.timedOut) { + throw new Error('AppImage extraction timed out.') + } + if (result.code !== 0) { + throw new Error(result.stderr.trim() || `AppImage extraction exited ${result.code ?? 'early'}.`) + } +} diff --git a/src/main/cli/appimage-extraction-pruning.test.ts b/src/main/cli/appimage-extraction-pruning.test.ts new file mode 100644 index 00000000000..4b5745d3619 --- /dev/null +++ b/src/main/cli/appimage-extraction-pruning.test.ts @@ -0,0 +1,221 @@ +import { existsSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readlink, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const publicationRace = vi.hoisted(() => ({ endpointPath: '', replacementTarget: '' })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + rename: async (source: string, destination: string) => { + if (source === publicationRace.endpointPath && publicationRace.replacementTarget) { + await actual.unlink(source) + await actual.symlink(publicationRace.replacementTarget, source) + publicationRace.replacementTarget = '' + } + return actual.rename(source, destination) + } + } +}) + +import { + ensureAppImageExtractedRoot, + resolveAppImageExtractedRoot +} from './appimage-extracted-root' +import { + getAppImageActiveExtractionPath, + pruneAppImageExtractedRoots, + removeAppImageInstalledPayloads +} from './appimage-extraction-pruning' +import { + publishAppImageLauncherEndpoint, + resolveAppImageLauncherEndpointPath +} from './appimage-stable-launcher' + +const created: string[] = [] + +afterEach(async () => { + publicationRace.endpointPath = '' + publicationRace.replacementTarget = '' + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function writePayload(rootPath: string, content = '#!/usr/bin/env bash\n'): Promise { + const launcherPath = join(rootPath, 'resources', 'bin', 'orca-ide') + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, content, { mode: 0o755 }) + return launcherPath +} + +async function makeExtractionFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-pruning-')) + created.push(root) + const appImagePath = join(root, 'Orca.AppImage') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + return { root, appImagePath, cacheRootPath: join(root, 'cache') } +} + +function cacheKeyApartFrom(...excluded: string[]): string { + return ( + ['a', 'b', 'c'].map((value) => value.repeat(24)).find((key) => !excluded.includes(key)) ?? + 'd'.repeat(24) + ) +} + +describe('AppImage extraction pruning', () => { + it('prunes stale generations without touching sibling namespaces', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const keepRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const keepKey = basename(keepRoot.rootPath) + const staleRoot = join(dirname(keepRoot.rootPath), cacheKeyApartFrom(keepKey)) + const siblingRoot = join( + cacheRootPath, + cacheKeyApartFrom(basename(dirname(keepRoot.rootPath))), + 'd'.repeat(24) + ) + await Promise.all([ + mkdir(keepRoot.rootPath, { recursive: true }), + mkdir(staleRoot, { recursive: true }), + mkdir(siblingRoot, { recursive: true }) + ]) + + await pruneAppImageExtractedRoots(keepRoot.rootPath) + + expect(existsSync(keepRoot.rootPath)).toBe(true) + expect(existsSync(staleRoot)).toBe(false) + expect(existsSync(siblingRoot)).toBe(true) + }) + + it('a sibling installed endpoint does not displace the owner generation', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const ownerRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const siblingRoot = join( + dirname(ownerRoot.rootPath), + cacheKeyApartFrom(basename(ownerRoot.rootPath)) + ) + const [ownerLauncher, siblingLauncher] = await Promise.all([ + writePayload(ownerRoot.rootPath, 'owner'), + writePayload(siblingRoot, 'installed') + ]) + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', siblingLauncher) + + await pruneAppImageExtractedRoots(ownerRoot.rootPath) + + await expect(readFile(ownerLauncher, 'utf8')).resolves.toBe('owner') + await expect(readFile(siblingLauncher, 'utf8')).resolves.toBe('installed') + await expect( + readlink(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed')) + ).resolves.toBe(siblingLauncher) + }) + + it('preserves an active extraction during pruning', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + let reportStarted!: (stagingPath: string) => void + let releaseExtraction!: () => void + const started = new Promise((resolve) => { + reportStarted = resolve + }) + const release = new Promise((resolve) => { + releaseExtraction = resolve + }) + const extraction = ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + reportStarted(cwd) + await release + await writePayload(join(cwd, 'squashfs-root'), '') + } + }) + const stagingPath = await started + + try { + await pruneAppImageExtractedRoots(root.rootPath) + expect(existsSync(stagingPath)).toBe(true) + } finally { + releaseExtraction() + } + await expect(extraction).resolves.toEqual(root) + }) + + it('retains recent cross-process staging and reclaims stale staging', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const namespacePath = dirname(root.rootPath) + const recentStaging = join(namespacePath, '.extract-recent') + const staleStaging = join(namespacePath, '.extract-stale') + await Promise.all([ + mkdir(root.rootPath, { recursive: true }), + mkdir(recentStaging, { recursive: true }), + mkdir(staleStaging, { recursive: true }) + ]) + await utimes(staleStaging, 0, 0) + + await pruneAppImageExtractedRoots(root.rootPath) + + expect(existsSync(recentStaging)).toBe(true) + expect(existsSync(staleStaging)).toBe(false) + }) + + it('preserves a recent active generation marker and reclaims a stale marker', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const keepRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const keepKey = basename(keepRoot.rootPath) + const recentRoot = join(dirname(keepRoot.rootPath), cacheKeyApartFrom(keepKey)) + const staleRoot = join( + dirname(keepRoot.rootPath), + cacheKeyApartFrom(keepKey, basename(recentRoot)) + ) + const recentMarker = getAppImageActiveExtractionPath(recentRoot) + const staleMarker = getAppImageActiveExtractionPath(staleRoot) + await Promise.all([ + mkdir(keepRoot.rootPath, { recursive: true }), + mkdir(recentRoot, { recursive: true }), + mkdir(staleRoot, { recursive: true }) + ]) + await Promise.all([writeFile(recentMarker, ''), writeFile(staleMarker, '')]) + await utimes(staleMarker, 0, 0) + + await pruneAppImageExtractedRoots(keepRoot.rootPath) + + expect(existsSync(recentRoot)).toBe(true) + expect(existsSync(recentMarker)).toBe(true) + expect(existsSync(staleRoot)).toBe(false) + expect(existsSync(staleMarker)).toBe(false) + }) + + it('tolerates a missing cache namespace', async () => { + const { root } = await makeExtractionFixture() + + await expect( + pruneAppImageExtractedRoots(join(root, 'never-made', 'a'.repeat(24), 'b'.repeat(24))) + ).resolves.toBeUndefined() + }) + + it.skipIf(process.platform === 'win32')( + 'restores a sibling endpoint that wins the uninstall rename race', + async () => { + const cacheRootPath = await mkdtemp(join(tmpdir(), 'orca-appimage-pruning-')) + created.push(cacheRootPath) + const ownerNamespace = join(cacheRootPath, 'a'.repeat(24)) + const siblingNamespace = join(cacheRootPath, 'b'.repeat(24)) + const ownerLauncher = await writePayload(join(ownerNamespace, 'c'.repeat(24))) + const siblingLauncher = await writePayload(join(siblingNamespace, 'd'.repeat(24))) + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', ownerLauncher) + publicationRace.endpointPath = endpointPath + publicationRace.replacementTarget = siblingLauncher + + await removeAppImageInstalledPayloads(ownerNamespace) + + await expect(readlink(endpointPath)).resolves.toBe(siblingLauncher) + expect(existsSync(ownerNamespace)).toBe(false) + expect(existsSync(siblingLauncher)).toBe(true) + } + ) +}) diff --git a/src/main/cli/appimage-extraction-pruning.ts b/src/main/cli/appimage-extraction-pruning.ts new file mode 100644 index 00000000000..be88301d51b --- /dev/null +++ b/src/main/cli/appimage-extraction-pruning.ts @@ -0,0 +1,142 @@ +import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import type { Dirent } from 'node:fs' +import { lstat, readlink, readdir, rename, rm, rmdir, symlink, unlink } from 'node:fs/promises' +import { basename, dirname, join, resolve } from 'node:path' +import { isAppImageCacheKey, resolveCachedAppImagePayloadRoot } from './appimage-cache-layout' +import { resolveAppImageLauncherEndpointPath } from './appimage-stable-launcher' + +const EXTRACTION_STAGING_PREFIX = '.extract-' +const ACTIVE_EXTRACTION_PREFIX = '.active-' +export const APPIMAGE_EXTRACTION_TIMEOUT_MS = 300_000 +// Cross-process extractors are bounded at five minutes; retain a second window before cleanup. +const STALE_EXTRACTION_GRACE_MS = APPIMAGE_EXTRACTION_TIMEOUT_MS * 2 + +const activeExtractionPaths = new Set() + +export function trackAppImageExtraction(stagingPath: string): () => void { + activeExtractionPaths.add(stagingPath) + return () => activeExtractionPaths.delete(stagingPath) +} + +export function getAppImageActiveExtractionPath(rootPath: string): string { + return join(dirname(rootPath), `${ACTIVE_EXTRACTION_PREFIX}${basename(rootPath)}`) +} + +export async function pruneAppImageExtractedRoots(keepRootPath: string): Promise { + const resolvedKeepRoot = resolve(keepRootPath) + const namespacePath = dirname(resolvedKeepRoot) + const cacheRootPath = dirname(namespacePath) + const protectedRoots = new Set([resolvedKeepRoot]) + const installedRoot = await resolveInstalledRoot(cacheRootPath) + if (installedRoot && dirname(installedRoot) === namespacePath) { + protectedRoots.add(installedRoot) + } + await pruneNamespace(namespacePath, protectedRoots) + await rmdir(namespacePath).catch(() => {}) +} + +export async function removeAppImageInstalledPayloads(namespacePath: string): Promise { + const resolvedNamespace = resolve(namespacePath) + const cacheRootPath = dirname(resolvedNamespace) + if (await removeInstalledEndpoint(cacheRootPath, resolvedNamespace)) { + await pruneNamespace(resolvedNamespace, new Set()) + await rmdir(resolvedNamespace).catch(() => {}) + } +} + +async function resolveInstalledRoot(cacheRootPath: string): Promise { + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + try { + const targetPath = resolve(dirname(endpointPath), await readlink(endpointPath)) + return resolveCachedAppImagePayloadRoot(cacheRootPath, targetPath) + } catch { + return null + } +} + +async function removeInstalledEndpoint( + cacheRootPath: string, + namespacePath: string +): Promise { + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + if (!(await endpointTargetsNamespace(cacheRootPath, endpointPath, namespacePath))) { + return true + } + + const displacedPath = join( + dirname(endpointPath), + `.orca-preserved-installed-${process.pid}-${randomUUID()}` + ) + try { + await rename(endpointPath, displacedPath) + } catch { + return !(await endpointTargetsNamespace(cacheRootPath, endpointPath, namespacePath)) + } + + if (await endpointTargetsNamespace(cacheRootPath, displacedPath, namespacePath)) { + await unlink(displacedPath).catch(() => {}) + return true + } + + try { + await symlink(await readlink(displacedPath), endpointPath) + await unlink(displacedPath) + } catch {} + return !(await endpointTargetsNamespace(cacheRootPath, endpointPath, namespacePath)) +} + +async function endpointTargetsNamespace( + cacheRootPath: string, + endpointPath: string, + namespacePath: string +): Promise { + try { + const targetPath = resolve(dirname(endpointPath), await readlink(endpointPath)) + const targetRoot = resolveCachedAppImagePayloadRoot(cacheRootPath, targetPath) + return targetRoot !== null && dirname(targetRoot) === namespacePath + } catch { + return false + } +} + +async function pruneNamespace( + namespacePath: string, + protectedRoots: ReadonlySet +): Promise { + let entries: Dirent[] + try { + entries = await readdir(namespacePath, { withFileTypes: true }) + } catch { + return + } + + for (const entry of entries) { + const entryPath = join(namespacePath, entry.name) + if ( + entry.name.startsWith(EXTRACTION_STAGING_PREFIX) || + entry.name.startsWith(ACTIVE_EXTRACTION_PREFIX) + ) { + if (activeExtractionPaths.has(entryPath) || !(await isOlderThanGrace(entryPath))) { + continue + } + } else if (!isAppImageCacheKey(entry.name)) { + continue + } else if ( + protectedRoots.has(resolve(entryPath)) || + (existsSync(getAppImageActiveExtractionPath(entryPath)) && + !(await isOlderThanGrace(getAppImageActiveExtractionPath(entryPath)))) + ) { + continue + } + await rm(entryPath, { recursive: true, force: true }).catch(() => {}) + } +} + +async function isOlderThanGrace(candidatePath: string): Promise { + try { + return Date.now() - (await lstat(candidatePath)).mtimeMs >= STALE_EXTRACTION_GRACE_MS + } catch { + return true + } +} diff --git a/src/main/cli/appimage-registration-lock.ts b/src/main/cli/appimage-registration-lock.ts new file mode 100644 index 00000000000..c14573a4d24 --- /dev/null +++ b/src/main/cli/appimage-registration-lock.ts @@ -0,0 +1,32 @@ +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { lock } from 'proper-lockfile' +import { APPIMAGE_EXTRACTION_TIMEOUT_MS } from './appimage-extraction-pruning' + +const LOCK_TARGET_NAME = '.cli-registration' +const LOCK_STALE_MS = APPIMAGE_EXTRACTION_TIMEOUT_MS * 3 +const LOCK_RETRIES = { + retries: 1_000, + factor: 1.2, + minTimeout: 25, + maxTimeout: 1_000, + randomize: true +} + +export async function withAppImageRegistrationLock( + cacheRootPath: string, + operation: () => Promise +): Promise { + await mkdir(cacheRootPath, { recursive: true, mode: 0o700 }) + const release = await lock(join(cacheRootPath, LOCK_TARGET_NAME), { + realpath: false, + retries: LOCK_RETRIES, + stale: LOCK_STALE_MS, + update: APPIMAGE_EXTRACTION_TIMEOUT_MS / 10 + }) + try { + return await operation() + } finally { + await release() + } +} diff --git a/src/main/cli/appimage-stable-launcher.test.ts b/src/main/cli/appimage-stable-launcher.test.ts new file mode 100644 index 00000000000..a009aee5611 --- /dev/null +++ b/src/main/cli/appimage-stable-launcher.test.ts @@ -0,0 +1,185 @@ +import { existsSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' + +const { filePublicationFailures } = vi.hoisted(() => ({ + filePublicationFailures: { copy: 0, link: 0, replaceBeforeRename: '' } +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + copyFileSync: (...args: Parameters) => { + if (filePublicationFailures.copy > 0) { + filePublicationFailures.copy -= 1 + throw Object.assign(new Error('copy unsupported'), { code: 'ENOTSUP' }) + } + return actual.copyFileSync(...args) + }, + linkSync: (...args: Parameters) => { + if (filePublicationFailures.link > 0) { + filePublicationFailures.link -= 1 + throw Object.assign(new Error('link unsupported'), { code: 'ENOTSUP' }) + } + return actual.linkSync(...args) + }, + renameSync: (...args: Parameters) => { + if (filePublicationFailures.replaceBeforeRename) { + actual.writeFileSync(args[0], filePublicationFailures.replaceBeforeRename, { mode: 0o755 }) + filePublicationFailures.replaceBeforeRename = '' + } + return actual.renameSync(...args) + } + } +}) +import { + publishAppImageLauncherEndpoint, + resolveAppImageLauncherEndpointPath, + resolveAppImageStableLauncherPath +} from './appimage-stable-launcher' + +const created: string[] = [] + +afterEach(async () => { + filePublicationFailures.copy = 0 + filePublicationFailures.link = 0 + filePublicationFailures.replaceBeforeRename = '' + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function makeFixture(): Promise { + const cacheRootPath = await mkdtemp(join(tmpdir(), 'orca-stable-appimage-launcher-')) + created.push(cacheRootPath) + return cacheRootPath +} + +async function writeLauncher(path: string, output: string): Promise { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `#!/usr/bin/env bash\nprintf '${output}'`, { mode: 0o755 }) +} + +describe('AppImage stable launcher', () => { + it('prefers the live mount and falls back to the installed payload', async () => { + const cacheRootPath = await makeFixture() + const livePath = join(cacheRootPath, 'payloads', 'live') + const installedPath = join(cacheRootPath, 'payloads', 'installed') + await Promise.all([writeLauncher(livePath, 'live'), writeLauncher(installedPath, 'installed')]) + + expect( + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', installedPath) + ).not.toBeNull() + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'live', livePath)! + await expect( + runProcess({ program: launcherPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'live' }) + + await rm(livePath) + await expect( + runProcess({ program: launcherPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'installed' }) + }) + + it('observes an endpoint published after the wrapper starts', async () => { + const cacheRootPath = await makeFixture() + const missingPath = join(cacheRootPath, 'payloads', 'missing') + const readyPath = join(cacheRootPath, 'payloads', 'ready') + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'live', missingPath)! + const invocation = runProcess({ program: launcherPath, args: [], timeoutMs: 3_000 }) + setTimeout(() => { + void writeLauncher(readyPath, 'ready').then(() => { + publishAppImageLauncherEndpoint(cacheRootPath, 'live', readyPath) + }) + }, 100) + + await expect(invocation).resolves.toMatchObject({ code: 0, stdout: 'ready' }) + }) + + it('atomically upgrades a stale marker-owned launcher', async () => { + const cacheRootPath = await makeFixture() + const targetPath = join(cacheRootPath, 'payloads', 'target') + await writeLauncher(targetPath, 'target') + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'live', targetPath)! + const marker = (await readFile(launcherPath, 'utf8')).split('\n')[1] + await writeFile(launcherPath, `#!/usr/bin/env bash\n${marker}\nprintf stale`, { mode: 0o755 }) + + expect(publishAppImageLauncherEndpoint(cacheRootPath, 'live', targetPath)).toBe(launcherPath) + expect(await readFile(launcherPath, 'utf8')).toContain('launcher_dir=') + await expect( + runProcess({ program: launcherPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'target' }) + }) + + it('publishes on a cache filesystem without hard-link support', async () => { + const cacheRootPath = await makeFixture() + const targetPath = join(cacheRootPath, 'payloads', 'target') + await writeLauncher(targetPath, 'target') + filePublicationFailures.link = 1 + + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'live', targetPath) + + expect(launcherPath).toBe(resolveAppImageStableLauncherPath(cacheRootPath)) + await expect(readFile(launcherPath!, 'utf8')).resolves.toContain('launcher_dir=') + }) + + it('restores a displaced owned launcher when its replacement cannot be published', async () => { + const cacheRootPath = await makeFixture() + const targetPath = join(cacheRootPath, 'payloads', 'target') + await writeLauncher(targetPath, 'target') + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'live', targetPath)! + const marker = (await readFile(launcherPath, 'utf8')).split('\n')[1] + const staleContent = `#!/usr/bin/env bash\n${marker}\nprintf stale` + await writeFile(launcherPath, staleContent, { mode: 0o755 }) + filePublicationFailures.link = 2 + filePublicationFailures.copy = 2 + + expect(publishAppImageLauncherEndpoint(cacheRootPath, 'live', targetPath)).toBeNull() + await expect(readFile(launcherPath, 'utf8')).resolves.toBe(staleContent) + }) + + it('restores a displaced foreign launcher when hard links are unsupported', async () => { + const cacheRootPath = await makeFixture() + const targetPath = join(cacheRootPath, 'payloads', 'target') + await writeLauncher(targetPath, 'target') + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'live', targetPath)! + const marker = (await readFile(launcherPath, 'utf8')).split('\n')[1] + await writeFile(launcherPath, `#!/usr/bin/env bash\n${marker}\nprintf stale`, { mode: 0o755 }) + const foreignContent = '#!/usr/bin/env bash\nprintf foreign' + filePublicationFailures.replaceBeforeRename = foreignContent + filePublicationFailures.link = 2 + + expect(publishAppImageLauncherEndpoint(cacheRootPath, 'live', targetPath)).toBeNull() + await expect(readFile(launcherPath, 'utf8')).resolves.toBe(foreignContent) + }) + + it('preserves a foreign launcher and declines endpoint publication', async () => { + const cacheRootPath = await makeFixture() + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'live') + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\nprintf foreign', { mode: 0o755 }) + + expect( + publishAppImageLauncherEndpoint(cacheRootPath, 'live', join(cacheRootPath, 'target')) + ).toBeNull() + await expect(readFile(launcherPath, 'utf8')).resolves.toContain('foreign') + expect(existsSync(endpointPath)).toBe(false) + }) + + it('preserves an oversized foreign launcher without treating its marker as ownership', async () => { + const cacheRootPath = await makeFixture() + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + const content = `#!/usr/bin/env bash\n# orca-appimage-stable-launcher\n${'x'.repeat(20_000)}` + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, content, { mode: 0o755 }) + + expect( + publishAppImageLauncherEndpoint(cacheRootPath, 'live', join(cacheRootPath, 'target')) + ).toBeNull() + await expect(readFile(launcherPath, 'utf8')).resolves.toBe(content) + }) +}) diff --git a/src/main/cli/appimage-stable-launcher.ts b/src/main/cli/appimage-stable-launcher.ts new file mode 100644 index 00000000000..880869ef513 --- /dev/null +++ b/src/main/cli/appimage-stable-launcher.ts @@ -0,0 +1,226 @@ +import { randomUUID } from 'node:crypto' +import { + closeSync, + constants, + copyFileSync, + fstatSync, + linkSync, + mkdirSync, + openSync, + readSync, + renameSync, + symlinkSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' +import { quoteShell } from './cli-install-path-format' + +const LAUNCHER_DIRECTORY_NAME = 'launcher' +const LIVE_ENDPOINT_NAME = 'live' +const INSTALLED_ENDPOINT_NAME = 'installed' +const LAUNCHER_MARKER = '# orca-appimage-stable-launcher' +const LAUNCHER_WAIT_SECONDS = 5 +const LAUNCHER_MAX_BYTES = 16 * 1024 + +export type AppImageLauncherEndpoint = 'live' | 'installed' + +export function resolveAppImageStableLauncherPath(cacheRootPath: string): string { + return join(resolve(cacheRootPath), LAUNCHER_DIRECTORY_NAME, LINUX_CLI_COMMAND_NAME) +} + +export function resolveAppImageLauncherEndpointPath( + cacheRootPath: string, + endpoint: AppImageLauncherEndpoint +): string { + return join( + dirname(resolveAppImageStableLauncherPath(cacheRootPath)), + endpoint === 'live' ? LIVE_ENDPOINT_NAME : INSTALLED_ENDPOINT_NAME + ) +} + +export function isAppImageStableLauncherReady(cacheRootPath: string): boolean { + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + return isExactExecutableLauncher(launcherPath, buildStableLauncherScript(cacheRootPath)) +} + +export function publishAppImageLauncherEndpoint( + cacheRootPath: string, + endpoint: AppImageLauncherEndpoint, + targetPath: string +): string | null { + const launcherPath = ensureAppImageStableLauncher(cacheRootPath) + if (!launcherPath) { + return null + } + + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, endpoint) + const temporaryPath = join(dirname(endpointPath), `.${endpoint}-${process.pid}-${randomUUID()}`) + try { + symlinkSync(targetPath, temporaryPath) + renameSync(temporaryPath, endpointPath) + return launcherPath + } catch { + return null + } finally { + try { + unlinkSync(temporaryPath) + } catch {} + } +} + +function ensureAppImageStableLauncher(cacheRootPath: string): string | null { + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + const content = buildStableLauncherScript(cacheRootPath) + try { + mkdirSync(dirname(launcherPath), { recursive: true }) + if (!installLauncher(launcherPath, content)) { + return null + } + return launcherPath + } catch { + return null + } +} + +function installLauncher(launcherPath: string, content: string): boolean { + if (isExactExecutableLauncher(launcherPath, content)) { + return true + } + const temporaryPath = join( + dirname(launcherPath), + `.${LINUX_CLI_COMMAND_NAME}-${process.pid}-${randomUUID()}` + ) + try { + writeFileSync(temporaryPath, content, { encoding: 'utf8', flag: 'wx', mode: 0o755 }) + if (publishLauncherIfVacant(temporaryPath, launcherPath)) { + return true + } + if (!isOwnedLauncher(launcherPath)) { + return false + } + return replaceOwnedLauncher(launcherPath, temporaryPath, content) + } finally { + unlinkIfPresent(temporaryPath) + } +} + +function replaceOwnedLauncher( + launcherPath: string, + replacementPath: string, + replacementContent: string +): boolean { + const displacedPath = join( + dirname(launcherPath), + `.orca-preserved-launcher-${process.pid}-${randomUUID()}` + ) + try { + renameSync(launcherPath, displacedPath) + } catch { + return isExactExecutableLauncher(launcherPath, replacementContent) + } + + if (!isOwnedLauncher(displacedPath)) { + restoreForeignLauncher(displacedPath, launcherPath) + return false + } + + if ( + publishLauncherIfVacant(replacementPath, launcherPath) || + isExactExecutableLauncher(launcherPath, replacementContent) + ) { + unlinkIfPresent(displacedPath) + return true + } + + if (publishLauncherIfVacant(displacedPath, launcherPath)) { + unlinkIfPresent(displacedPath) + } + return isExactExecutableLauncher(launcherPath, replacementContent) +} + +function restoreForeignLauncher(displacedPath: string, launcherPath: string): void { + if (publishLauncherIfVacant(displacedPath, launcherPath)) { + unlinkIfPresent(displacedPath) + } +} + +function publishLauncherIfVacant(sourcePath: string, destinationPath: string): boolean { + try { + linkSync(sourcePath, destinationPath) + return true + } catch {} + try { + copyFileSync(sourcePath, destinationPath, constants.COPYFILE_EXCL) + return true + } catch { + return false + } +} + +function isExactExecutableLauncher(launcherPath: string, content: string): boolean { + const launcher = readLauncherFile(launcherPath) + return launcher?.executable === true && launcher.content === content +} + +function isOwnedLauncher(launcherPath: string): boolean { + return readLauncherFile(launcherPath)?.content.split('\n')[1] === LAUNCHER_MARKER +} + +function readLauncherFile(launcherPath: string): { content: string; executable: boolean } | null { + let fd: number | undefined + try { + fd = openSync(launcherPath, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW) + const before = fstatSync(fd) + if (!before.isFile() || before.size > LAUNCHER_MAX_BYTES) { + return null + } + const bytes = Buffer.alloc(before.size) + let offset = 0 + while (offset < bytes.length) { + const count = readSync(fd, bytes, offset, bytes.length - offset, offset) + if (count === 0) { + return null + } + offset += count + } + const after = fstatSync(fd) + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + return null + } + return { content: bytes.toString('utf8'), executable: (before.mode & 0o111) !== 0 } + } catch { + return null + } finally { + if (fd !== undefined) { + closeSync(fd) + } + } +} + +function unlinkIfPresent(candidatePath: string): void { + try { + unlinkSync(candidatePath) + } catch {} +} + +function buildStableLauncherScript(cacheRootPath: string): string { + const launcherDirectory = dirname(resolveAppImageStableLauncherPath(cacheRootPath)) + return `#!/usr/bin/env bash +${LAUNCHER_MARKER} +shopt -s execfail +launcher_dir=${quoteShell(launcherDirectory)} +deadline=$((SECONDS + ${LAUNCHER_WAIT_SECONDS})) +while (( SECONDS <= deadline )); do + for launcher in "$launcher_dir/${LIVE_ENDPOINT_NAME}" "$launcher_dir/${INSTALLED_ENDPOINT_NAME}"; do + if [[ -f "$launcher" && -x "$launcher" ]]; then + exec "$launcher" "$@" + fi + done + sleep 0.1 +done +printf 'Orca CLI is not ready; reopen Orca or register the CLI again.\\n' >&2 +exit 1 +` +} diff --git a/src/main/cli/cli-command-filesystem-transaction.ts b/src/main/cli/cli-command-filesystem-transaction.ts new file mode 100644 index 00000000000..7e464f52368 --- /dev/null +++ b/src/main/cli/cli-command-filesystem-transaction.ts @@ -0,0 +1,209 @@ +import { createHash, randomUUID } from 'node:crypto' +import { lstat, mkdir, readFile, readlink, rename, rmdir } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import type { CliInstallStatus } from '../../shared/cli-install-types' +import { isMissingError } from './cli-install-errors' +import { quoteShell } from './cli-install-path-format' + +export type EntryIdentity = { + dev: bigint + ino: bigint + mode: bigint + size: bigint + ctimeNs: bigint +} + +export type EntrySnapshot = { identity: EntryIdentity; isSymbolicLink: boolean } +export type CommandQuarantine = { + directoryPath: string + heldPath: string + snapshot: EntrySnapshot | null +} +export type StableCommandInspection = { + fileSha256: string | null + rawSymlinkTarget: string | null + snapshot: EntrySnapshot | null + status: CliInstallStatus +} + +const STABLE_INSPECTION_ATTEMPTS = 3 + +export async function readEntrySnapshot(path: string): Promise { + try { + const stats = await lstat(path, { bigint: true }) + return { + identity: { + dev: stats.dev, + ino: stats.ino, + mode: stats.mode, + size: stats.size, + ctimeNs: stats.ctimeNs + }, + isSymbolicLink: stats.isSymbolicLink() + } + } catch (error) { + if (isMissingError(error)) { + return null + } + throw error + } +} + +export function hasSameIdentity(left: EntryIdentity | null, right: EntryIdentity | null): boolean { + return ( + left === right || + (left !== null && right !== null && left.dev === right.dev && left.ino === right.ino) + ) +} + +export function hasSameSnapshot(left: EntrySnapshot | null, right: EntrySnapshot | null): boolean { + return ( + left === right || + (left !== null && + right !== null && + hasSameIdentity(left.identity, right.identity) && + left.identity.mode === right.identity.mode && + left.identity.size === right.identity.size && + left.identity.ctimeNs === right.identity.ctimeNs) + ) +} + +export async function hashCommandFile(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') +} + +export async function inspectStableCommand( + commandPath: string, + inspect: () => Promise +): Promise { + for (let attempt = 0; attempt < STABLE_INSPECTION_ATTEMPTS; attempt += 1) { + const before = await readEntrySnapshot(commandPath) + const status = await inspect() + const afterInspection = await readEntrySnapshot(commandPath) + if ( + !hasSameSnapshot(before, afterInspection) || + (afterInspection === null) !== (status.state === 'not_installed') + ) { + continue + } + let fileSha256: string | null = null + let rawSymlinkTarget: string | null = null + try { + if (afterInspection?.isSymbolicLink) { + rawSymlinkTarget = await readlink(commandPath) + } else if (afterInspection && status.state !== 'conflict') { + fileSha256 = await hashCommandFile(commandPath) + } + } catch { + continue + } + const afterEvidence = await readEntrySnapshot(commandPath) + if (hasSameSnapshot(afterInspection, afterEvidence)) { + return { fileSha256, rawSymlinkTarget, snapshot: afterEvidence, status } + } + } + throw new Error(`The command at ${commandPath} changed while Orca inspected it.`) +} + +export async function quarantineCommandPath(commandPath: string): Promise { + const commandDirectory = dirname(commandPath) + const directoryPath = join(commandDirectory, `.orca-cli-${process.pid}-${randomUUID()}`) + const heldPath = join(directoryPath, basename(commandPath)) + await mkdir(commandDirectory, { recursive: true }) + await mkdir(directoryPath, { mode: 0o700 }) + try { + await rename(commandPath, heldPath) + } catch (error) { + if (!isMissingError(error)) { + await rmdir(directoryPath).catch(() => undefined) + throw error + } + } + return { directoryPath, heldPath, snapshot: await readEntrySnapshot(heldPath) } +} + +export async function capturedExpectedEntry( + quarantine: CommandQuarantine, + inspected: Pick +): Promise { + if (!quarantine.snapshot) { + return true + } + if ( + !inspected.snapshot || + quarantine.snapshot.isSymbolicLink !== inspected.snapshot.isSymbolicLink || + !hasSameIdentity(quarantine.snapshot.identity, inspected.snapshot.identity) + ) { + return false + } + if (inspected.rawSymlinkTarget !== null) { + try { + return (await readlink(quarantine.heldPath)) === inspected.rawSymlinkTarget + } catch { + return false + } + } + if (!inspected.fileSha256) { + return true + } + try { + return (await hashCommandFile(quarantine.heldPath)) === inspected.fileSha256 + } catch { + return false + } +} + +type MacPrivilegedSymlinkTransaction = { + commandPath: string + expected: EntryIdentity | null + expectedFileSha256: string | null + expectedRawSymlinkTarget: string | null +} & ({ action: 'install'; launcherPath: string } | { action: 'remove' }) + +export function buildMacPrivilegedSymlinkTransaction( + args: MacPrivilegedSymlinkTransaction +): string { + const commandDirectory = dirname(args.commandPath) + const transactionDirectory = join(commandDirectory, `.orca-cli-${process.pid}-${randomUUID()}`) + const heldPath = join(transactionDirectory, basename(args.commandPath)) + const publishDirectory = join(transactionDirectory, 'publish') + const publishPath = join(publishDirectory, basename(args.commandPath)) + const recoveryMessage = quoteShell(`The displaced entry is preserved at ${heldPath}.`) + const restore = + `/bin/ln -P ${quoteShell(heldPath)} ${quoteShell(commandDirectory)} && ` + + `/bin/rm ${quoteShell(heldPath)} && /bin/rmdir ${quoteShell(transactionDirectory)}` + const restoreOrPreserve = `if ${restore}; then :; else echo ${recoveryMessage} >&2; exit 74; fi` + const fileMismatch = args.expectedFileSha256 + ? ` || [ "$(/usr/bin/shasum -a 256 ${quoteShell(heldPath)} | /usr/bin/awk '{print $1}')" != ${quoteShell(args.expectedFileSha256)} ]` + : '' + const symlinkMismatch = args.expectedRawSymlinkTarget + ? ` || [ "$(/usr/bin/readlink -n ${quoteShell(heldPath)}; /usr/bin/printf x)" != ${quoteShell(`${args.expectedRawSymlinkTarget}x`)} ]` + : '' + const rejectCaptured = args.expected + ? `if [ "$captured" -eq 1 ] && { [ "$(/usr/bin/stat -f '%d:%i' ${quoteShell(heldPath)})" != ${quoteShell(`${args.expected.dev}:${args.expected.ino}`)} ]${fileMismatch}${symlinkMismatch}; }; then ${restoreOrPreserve}; exit 73; fi` + : `if [ "$captured" -eq 1 ]; then ${restoreOrPreserve}; exit 73; fi` + const capture = + `umask 077; /bin/mkdir -p ${quoteShell(commandDirectory)} || exit $?; ` + + `/bin/mkdir ${quoteShell(transactionDirectory)} || exit $?; captured=0; ` + + `if [ -e ${quoteShell(args.commandPath)} ] || [ -L ${quoteShell(args.commandPath)} ]; then ` + + `/bin/mv ${quoteShell(args.commandPath)} ${quoteShell(heldPath)} && captured=1 || exit $?; fi; ` + + `${rejectCaptured}; ` + + if (args.action === 'remove') { + return `${capture}if [ "$captured" -eq 1 ]; then /bin/rm ${quoteShell(heldPath)}; fi; /bin/rmdir ${quoteShell(transactionDirectory)}` + } + + const rollback = + `/bin/rm ${quoteShell(publishPath)}; /bin/rmdir ${quoteShell(publishDirectory)}; ` + + `if [ "$captured" -eq 1 ]; then ${restoreOrPreserve}; else /bin/rmdir ${quoteShell(transactionDirectory)}; fi; exit 73` + return ( + `${capture}/bin/mkdir ${quoteShell(publishDirectory)} || exit $?; ` + + `/bin/ln -s ${quoteShell(args.launcherPath)} ${quoteShell(publishPath)} || exit $?; ` + + `if /bin/ln -P ${quoteShell(publishPath)} ${quoteShell(commandDirectory)}; then ` + + `/bin/rm ${quoteShell(publishPath)}; /bin/rmdir ${quoteShell(publishDirectory)}; ` + + `if [ "$captured" -eq 1 ]; then /bin/rm ${quoteShell(heldPath)}; fi; ` + + `/bin/rmdir ${quoteShell(transactionDirectory)}; else ${rollback}; fi` + ) +} diff --git a/src/main/cli/cli-command-inspection.ts b/src/main/cli/cli-command-inspection.ts index 93712201fc5..cbb63ac4c31 100644 --- a/src/main/cli/cli-command-inspection.ts +++ b/src/main/cli/cli-command-inspection.ts @@ -1,62 +1,16 @@ +import { existsSync } from 'node:fs' import { lstat, readFile, readlink } from 'node:fs/promises' import { basename, dirname, resolve } from 'node:path' import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types' -import { buildAppImageCliWrapper } from './appimage-cli-wrapper' +import { isAppImageExtractedLauncherPath } from './appimage-extracted-root' import { DEV_COMMAND_NAME, DEV_LAUNCHER_DIR } from './cli-install-constants' import { buildWindowsForwarder, extractManagedUnixLauncherTarget } from './cli-dev-launcher' import { isMissingError } from './cli-install-errors' import { CliInstallLocation } from './cli-install-location' import { isPathInsideOrEqual, samePathEntry } from './cli-install-path-format' +import { extractLegacyAppImageCliWrapperTarget } from './legacy-appimage-cli-wrapper' export class CliCommandInspection extends CliInstallLocation { - protected async inspectAppImageWrapper( - commandPath: string, - appImagePath: string - ): Promise { - try { - const stats = await lstat(commandPath) - if (!stats.isFile()) { - return this.buildStatus({ - commandPath, - launcherPath: appImagePath, - installMethod: 'wrapper', - supported: true, - state: 'conflict', - currentTarget: null, - detail: `${commandPath} exists but is not an Orca launcher script.` - }) - } - - const currentContent = await readFile(commandPath, 'utf8') - const expectedContent = buildAppImageCliWrapper(appImagePath) - return this.buildStatus({ - commandPath, - launcherPath: appImagePath, - installMethod: 'wrapper', - supported: true, - state: currentContent === expectedContent ? 'installed' : 'stale', - currentTarget: appImagePath, - detail: - currentContent === expectedContent - ? `Registered at ${commandPath}.` - : `${commandPath} points to a different launcher.` - }) - } catch (error) { - if (isMissingError(error)) { - return this.buildStatus({ - commandPath, - launcherPath: appImagePath, - installMethod: 'wrapper', - supported: true, - state: 'not_installed', - currentTarget: null, - detail: `Register ${commandPath} to use Orca from the terminal.` - }) - } - throw error - } - } - protected async inspectSymlink( commandPath: string, launcherPath: string @@ -66,7 +20,9 @@ export class CliCommandInspection extends CliInstallLocation { if (!stats.isSymbolicLink()) { if (stats.isFile()) { const currentContent = await readFile(commandPath, 'utf8') - const managedTarget = extractManagedUnixLauncherTarget(currentContent) + const managedTarget = + extractManagedUnixLauncherTarget(currentContent) ?? + extractLegacyAppImageCliWrapperTarget(currentContent) if (managedTarget) { return this.buildStatus({ commandPath, @@ -94,9 +50,11 @@ export class CliCommandInspection extends CliInstallLocation { const currentTarget = await readlink(commandPath) const resolvedCurrentTarget = resolve(dirname(commandPath), currentTarget) const resolvedLauncher = resolve(launcherPath) - const isInstalled = resolvedCurrentTarget === resolvedLauncher + const isInstalled = resolvedCurrentTarget === resolvedLauncher && existsSync(resolvedLauncher) const isManagedStaleTarget = - !isInstalled && this.isManagedSymlinkTarget(resolvedCurrentTarget, launcherPath) + !isInstalled && + (resolvedCurrentTarget === resolvedLauncher || + this.isManagedSymlinkTarget(resolvedCurrentTarget, launcherPath)) return this.buildStatus({ commandPath, launcherPath, @@ -149,7 +107,10 @@ export class CliCommandInspection extends CliInstallLocation { } if (this.platform === 'linux') { - return /(?:^|[/\\])resources[/\\]bin[/\\][^/\\]+$/.test(resolvedTarget) + const extractionOptions = this.appImageExtractionOptions() + return extractionOptions + ? isAppImageExtractedLauncherPath(extractionOptions, resolvedTarget) + : false } return false diff --git a/src/main/cli/cli-command-installation-races.test.ts b/src/main/cli/cli-command-installation-races.test.ts new file mode 100644 index 00000000000..34c7f6dfe7d --- /dev/null +++ b/src/main/cli/cli-command-installation-races.test.ts @@ -0,0 +1,386 @@ +import { + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + readlink, + rm, + symlink, + unlink, + writeFile +} from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const legacyReadlinkRace = vi.hoisted(() => ({ commandPath: '', replacementTarget: '' })) +const reusedIdentity = vi.hoisted(() => ({ + path: '', + dev: null as bigint | null, + ino: null as bigint | null +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + lstat: async (...args: Parameters) => { + const stats = await actual.lstat(...args) + if ( + args[0] !== reusedIdentity.path || + reusedIdentity.dev === null || + reusedIdentity.ino === null + ) { + return stats + } + return Object.create(stats, { + dev: { value: reusedIdentity.dev }, + ino: { value: reusedIdentity.ino } + }) as typeof stats + }, + readlink: async (...args: Parameters) => { + const [path] = args + if (path === legacyReadlinkRace.commandPath && legacyReadlinkRace.replacementTarget) { + const replacementTarget = legacyReadlinkRace.replacementTarget + legacyReadlinkRace.commandPath = '' + legacyReadlinkRace.replacementTarget = '' + await actual.unlink(path) + await actual.symlink(replacementTarget, path) + throw Object.assign(new Error('link vanished during inspection'), { code: 'ENOENT' }) + } + return actual.readlink(...args) + } + } +}) + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => tmpdir(), + getAppPath: () => tmpdir() + } +})) + +import { CliInstaller } from './cli-installer' +import type { CommandQuarantine } from './cli-command-filesystem-transaction' + +const createdRoots: string[] = [] + +afterEach(async () => { + legacyReadlinkRace.commandPath = '' + legacyReadlinkRace.replacementTarget = '' + reusedIdentity.path = '' + reusedIdentity.dev = null + reusedIdentity.ino = null + await Promise.all( + createdRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +async function createMacCommandFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-command-race-')) + createdRoots.push(root) + const commandDirectory = join(root, 'bin') + const commandPath = join(commandDirectory, 'orca') + const resourcesPath = join(root, 'Current.app', 'Contents', 'Resources') + const launcherPath = join(resourcesPath, 'bin', 'orca') + const staleLauncherPath = join(root, 'Old.app', 'Contents', 'Resources', 'bin', 'orca') + await mkdir(commandDirectory, { recursive: true }) + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + return { root, commandDirectory, commandPath, resourcesPath, launcherPath, staleLauncherPath } +} + +function createMacInstaller( + fixture: Awaited>, + hooks: { + quarantine?: (commandPath: string) => Promise + afterQuarantine?: (quarantine: CommandQuarantine) => void + link?: (heldPath: string, commandPath: string) => Promise + } = {} +): CliInstaller { + class RaceInjectedInstaller extends CliInstaller { + protected override async quarantineCommandPath(commandPath: string) { + await hooks.quarantine?.(commandPath) + const quarantine = await super.quarantineCommandPath(commandPath) + hooks.afterQuarantine?.(quarantine) + return quarantine + } + + protected override async linkQuarantinedCommand( + heldPath: string, + commandPath: string + ): Promise { + await hooks.link?.(heldPath, commandPath) + return super.linkQuarantinedCommand(heldPath, commandPath) + } + } + + return new RaceInjectedInstaller({ + platform: 'darwin', + isPackaged: true, + userDataPath: join(fixture.root, 'user-data'), + resourcesPath: fixture.resourcesPath, + execPath: join(fixture.root, 'Current.app', 'Contents', 'MacOS', 'Orca'), + appPath: join(fixture.root, 'Current.app', 'Contents', 'Resources', 'app.asar'), + homePath: join(fixture.root, 'home'), + commandPathOverride: fixture.commandPath, + processPathEnv: fixture.commandDirectory + }) +} + +async function recoveryPath(commandDirectory: string): Promise { + const transactionName = (await readdir(commandDirectory)).find((name) => + name.startsWith('.orca-cli-') + ) + if (!transactionName) { + throw new Error('Expected a preserved CLI command transaction.') + } + return join(commandDirectory, transactionName, 'orca') +} + +async function rejectionFrom(operation: Promise): Promise { + try { + await operation + } catch (error) { + if (error instanceof Error) { + return error + } + throw error + } + throw new Error('Expected the operation to reject.') +} + +describe.skipIf(process.platform === 'win32')('CLI command filesystem races', () => { + it('replaces and removes an unchanged stale symlink through the real filesystem', async () => { + const fixture = await createMacCommandFixture() + await symlink(fixture.staleLauncherPath, fixture.commandPath) + const installer = createMacInstaller(fixture) + + await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) + await expect(readlink(fixture.commandPath)).resolves.toBe(fixture.launcherPath) + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + await expect(lstat(fixture.commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('restores a foreign symlink whose quarantined inode identity is reused', async () => { + const fixture = await createMacCommandFixture() + const foreignTarget = join(fixture.root, 'foreign-command') + await symlink(fixture.staleLauncherPath, fixture.commandPath) + const original = await lstat(fixture.commandPath, { bigint: true }) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await unlink(commandPath) + await symlink(foreignTarget, commandPath) + }, + afterQuarantine: (quarantine) => { + if (quarantine.snapshot) { + reusedIdentity.path = quarantine.heldPath + reusedIdentity.dev = original.dev + reusedIdentity.ino = original.ino + quarantine.snapshot.identity = { + ...quarantine.snapshot.identity, + dev: original.dev, + ino: original.ino + } + } + } + }) + + await expect(installer.install()).rejects.toThrow('Refusing to replace non-Orca command') + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + expect( + (await readdir(fixture.commandDirectory)).some((name) => name.startsWith('.orca-cli-')) + ).toBe(false) + }) + + it('preserves a managed file changed in place after its final inspection', async () => { + const fixture = await createMacCommandFixture() + const oldCliPath = join(fixture.root, 'old', 'out', 'cli', 'index.js') + await writeFile( + fixture.commandPath, + [ + '#!/usr/bin/env bash', + `CLI='${oldCliPath}'`, + 'export ORCA_NODE_OPTIONS="${NODE_OPTIONS-}"', + 'export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}"', + 'ELECTRON_RUN_AS_NODE=1 exec electron "$CLI" "$@"' + ].join('\n') + ) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await writeFile(commandPath, 'foreign command written into the inspected inode') + } + }) + + await expect(installer.install()).rejects.toThrow('Refusing to replace non-Orca command') + await expect(readFile(fixture.commandPath, 'utf8')).resolves.toBe( + 'foreign command written into the inspected inode' + ) + }) + + it('restores a foreign symlink raced into command removal', async () => { + const fixture = await createMacCommandFixture() + const foreignTarget = join(fixture.root, 'foreign-command') + await symlink(fixture.launcherPath, fixture.commandPath) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await unlink(commandPath) + await symlink(foreignTarget, commandPath) + } + }) + + await expect(installer.remove()).rejects.toThrow('Refusing to remove non-Orca command') + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + }) + + it('preserves a raced foreign directory at the reported recovery path', async () => { + const fixture = await createMacCommandFixture() + await symlink(fixture.staleLauncherPath, fixture.commandPath) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await unlink(commandPath) + await mkdir(commandPath) + await writeFile(join(commandPath, 'user-data'), 'preserved') + } + }) + + const error = await rejectionFrom(installer.install()) + const heldPath = await recoveryPath(fixture.commandDirectory) + expect(error.message).toContain(heldPath) + await expect(readFile(join(heldPath, 'user-data'), 'utf8')).resolves.toBe('preserved') + await expect(lstat(fixture.commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('preserves both entries when the original name is reclaimed during restoration', async () => { + const fixture = await createMacCommandFixture() + const contenderTarget = join(fixture.root, 'contender-command') + await symlink(fixture.staleLauncherPath, fixture.commandPath) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await unlink(commandPath) + await writeFile(commandPath, 'foreign command') + }, + link: async (_heldPath, commandPath) => { + await symlink(contenderTarget, commandPath) + } + }) + + const error = await rejectionFrom(installer.install()) + const heldPath = await recoveryPath(fixture.commandDirectory) + expect(error.message).toContain(heldPath) + await expect(readFile(heldPath, 'utf8')).resolves.toBe('foreign command') + await expect(readlink(fixture.commandPath)).resolves.toBe(contenderTarget) + }) + + it('keeps a foreign legacy Linux command when readlink loses the inspection race', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-legacy-race-')) + createdRoots.push(root) + const homePath = join(root, 'home') + const commandDirectory = join(homePath, '.local', 'bin') + const resourcesPath = join(root, 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca-ide') + const legacyPath = join(commandDirectory, 'orca') + const managedLegacyTarget = join(resourcesPath, 'bin', 'orca') + const foreignTarget = join(root, 'foreign-orca') + await mkdir(commandDirectory, { recursive: true }) + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + await symlink(managedLegacyTarget, legacyPath) + + legacyReadlinkRace.commandPath = legacyPath + legacyReadlinkRace.replacementTarget = foreignTarget + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(root, 'user-data'), + resourcesPath, + execPath: join(root, 'orca-ide'), + appPath: join(root, 'resources', 'app.asar'), + homePath, + processPathEnv: commandDirectory + }) + + await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) + await expect(readlink(legacyPath)).resolves.toBe(foreignTarget) + }) + + it('keeps a foreign legacy Linux command whose quarantined inode is reused', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-legacy-identity-race-')) + createdRoots.push(root) + const homePath = join(root, 'home') + const commandDirectory = join(homePath, '.local', 'bin') + const resourcesPath = join(root, 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca-ide') + const legacyPath = join(commandDirectory, 'orca') + const managedTarget = join(resourcesPath, 'bin', 'orca') + const foreignTarget = join(root, 'foreign-orca') + await mkdir(commandDirectory, { recursive: true }) + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + await symlink(managedTarget, legacyPath) + const original = await lstat(legacyPath, { bigint: true }) + + class LegacyRaceInstaller extends CliInstaller { + protected override async quarantineCommandPath(commandPath: string) { + if (commandPath === legacyPath) { + await unlink(commandPath) + await symlink(foreignTarget, commandPath) + } + const quarantine = await super.quarantineCommandPath(commandPath) + if (commandPath === legacyPath && quarantine.snapshot) { + reusedIdentity.path = quarantine.heldPath + reusedIdentity.dev = original.dev + reusedIdentity.ino = original.ino + quarantine.snapshot.identity = { + ...quarantine.snapshot.identity, + dev: original.dev, + ino: original.ino + } + } + return quarantine + } + } + + const installer = new LegacyRaceInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(root, 'user-data'), + resourcesPath, + execPath: join(root, 'orca-ide'), + appPath: join(root, 'resources', 'app.asar'), + homePath, + processPathEnv: commandDirectory + }) + + await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) + await expect(readlink(legacyPath)).resolves.toBe(foreignTarget) + }) +}) diff --git a/src/main/cli/cli-command-installation.ts b/src/main/cli/cli-command-installation.ts index a3f38778197..282fda4aa22 100644 --- a/src/main/cli/cli-command-installation.ts +++ b/src/main/cli/cli-command-installation.ts @@ -1,49 +1,98 @@ -import { lstat, mkdir, readlink, symlink, unlink, writeFile } from 'node:fs/promises' +import { link, readlink, rmdir, symlink, unlink, writeFile } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' import type { CliInstallStatus } from '../../shared/cli-install-types' -import { buildAppImageCliWrapper } from './appimage-cli-wrapper' +import { + ensureAppImageExtractedRoot, + isAppImageExtractedLauncherPath, + type AppImageExtractedRoot +} from './appimage-extracted-root' import { CliCommandInspection } from './cli-command-inspection' +import { + buildMacPrivilegedSymlinkTransaction, + capturedExpectedEntry, + hasSameIdentity, + hasSameSnapshot, + inspectStableCommand, + quarantineCommandPath, + readEntrySnapshot, + type CommandQuarantine, + type StableCommandInspection +} from './cli-command-filesystem-transaction' import { DEV_LAUNCHER_DIR, LEGACY_LINUX_COMMAND_NAME } from './cli-install-constants' import { buildWindowsForwarder } from './cli-dev-launcher' import { isMissingError, isPermissionError } from './cli-install-errors' -import { quoteShell } from './cli-install-path-format' + +const STABLE_LEGACY_INSPECTION_ATTEMPTS = 3 export class CliCommandInstallation extends CliCommandInspection { protected async installSymlink(status: CliInstallStatus): Promise { + const commandPath = status.commandPath + const launcherPath = status.launcherPath + if (!commandPath || !launcherPath || status.state === 'installed') { + return + } + + const inspected = await this.inspectStableSymlink(commandPath, launcherPath) + if (inspected.status.state === 'conflict') { + throw new Error(`Refusing to replace non-Orca command at ${commandPath}.`) + } + if (inspected.status.state === 'installed') { + return + } + + let quarantine: CommandQuarantine try { - if (status.state === 'installed') { - return - } - if (status.state === 'stale') { - await unlink(status.commandPath as string) - } - // Why: mkdir stays here (not install()) so an EACCES falls into the privileged-runner catch below. - await mkdir(dirname(status.commandPath as string), { recursive: true }) - await symlink(status.launcherPath as string, status.commandPath as string) + quarantine = await this.quarantineCommandPath(commandPath) } catch (error) { if (this.platform !== 'darwin' || !isPermissionError(error)) { throw error } - - // Why: fall back to an elevated shell to place the /usr/local/bin symlink (VS Code-style) when direct write is denied. - await this.privilegedRunner( - `mkdir -p ${quoteShell(dirname(status.commandPath as string))} && ` + - `ln -sfn ${quoteShell(status.launcherPath as string)} ${quoteShell(status.commandPath as string)}` - ) + await this.installSymlinkWithPrivileges(commandPath, launcherPath, inspected) + return } + + if (!(await capturedExpectedEntry(quarantine, inspected))) { + await this.restoreQuarantinedCommand(quarantine, commandPath) + throw new Error(`Refusing to replace non-Orca command at ${commandPath}.`) + } + + try { + await symlink(launcherPath, commandPath) + } catch (error) { + await this.restoreQuarantinedCommand(quarantine, commandPath) + throw error + } + await this.discardQuarantinedCommand(quarantine) } protected async removeSymlink(commandPath: string): Promise { + const launcherPath = await this.resolveLauncherPath() + if (!launcherPath) { + throw new Error('The Orca CLI launcher is no longer available.') + } + const inspected = await this.inspectStableSymlink(commandPath, launcherPath) + if (inspected.status.state === 'not_installed') { + return + } + if (inspected.status.state === 'conflict') { + throw new Error(`Refusing to remove non-Orca command at ${commandPath}.`) + } + + let quarantine: CommandQuarantine try { - await unlink(commandPath) + quarantine = await this.quarantineCommandPath(commandPath) } catch (error) { if (this.platform !== 'darwin' || !isPermissionError(error)) { throw error } - await this.privilegedRunner( - `if [ -L ${quoteShell(commandPath)} ]; then rm ${quoteShell(commandPath)}; fi` - ) + await this.removeSymlinkWithPrivileges(commandPath, inspected) + return } + if (!(await capturedExpectedEntry(quarantine, inspected))) { + await this.restoreQuarantinedCommand(quarantine, commandPath) + throw new Error(`Refusing to remove non-Orca command at ${commandPath}.`) + } + await this.discardQuarantinedCommand(quarantine) } protected async removeLegacyLinuxCommandIfManaged(launcherPath: string | null): Promise { @@ -51,27 +100,25 @@ export class CliCommandInstallation extends CliCommandInspection { return } - const legacyCommandPath = join(this.homePath, '.local', 'bin', LEGACY_LINUX_COMMAND_NAME) - try { - const stats = await lstat(legacyCommandPath) - if (!stats.isSymbolicLink()) { - return - } - - const currentTarget = await readlink(legacyCommandPath) - const resolvedCurrentTarget = resolve(dirname(legacyCommandPath), currentTarget) - if (!this.isManagedLegacyLinuxTarget(resolvedCurrentTarget, launcherPath)) { - return - } - - // Why: after the Linux command rename, the old `orca` symlink would keep shadowing GNOME Orca. - await unlink(legacyCommandPath) - } catch (error) { - if (isMissingError(error)) { - return - } - throw error + const commandPath = join(this.homePath, '.local', 'bin', LEGACY_LINUX_COMMAND_NAME) + const inspected = await this.inspectStableLegacyCommand(commandPath, launcherPath) + if (!inspected?.managed) { + return } + const quarantine = await this.quarantineCommandPath(commandPath) + if (!(await capturedExpectedEntry(quarantine, inspected))) { + await this.restoreQuarantinedCommand(quarantine, commandPath) + return + } + await this.discardQuarantinedCommand(quarantine) + } + + protected async quarantineCommandPath(commandPath: string): Promise { + return quarantineCommandPath(commandPath) + } + + protected async linkQuarantinedCommand(heldPath: string, commandPath: string): Promise { + await link(heldPath, commandPath) } protected isManagedLegacyLinuxTarget(resolvedTarget: string, launcherPath: string): boolean { @@ -90,20 +137,174 @@ export class CliCommandInstallation extends CliCommandInspection { return true } - // Why: AppImage upgrades can strand a legacy symlink into a now-gone FUSE mount that isn't a sibling of the stable path. - return /(?:^|[/\\])resources[/\\]bin[/\\]orca$/.test(resolvedTarget) + const extractionOptions = this.appImageExtractionOptions() + return extractionOptions + ? isAppImageExtractedLauncherPath( + extractionOptions, + resolvedTarget, + LEGACY_LINUX_COMMAND_NAME + ) + : false } protected async installWindowsWrapper(commandPath: string, launcherPath: string): Promise { await writeFile(commandPath, buildWindowsForwarder(launcherPath), 'utf8') } - protected async installAppImageWrapper(commandPath: string, appImagePath: string): Promise { - // Why: the AppImage command dir is user-writable, so create it before writing the wrapper. - await mkdir(dirname(commandPath), { recursive: true }) - await writeFile(commandPath, buildAppImageCliWrapper(appImagePath), { - encoding: 'utf8', - mode: 0o755 - }) + protected async ensureLinuxAppImagePayload(): Promise { + const extractionOptions = this.appImageExtractionOptions() + if (!this.isLinuxAppImage() || !extractionOptions) { + return null + } + const extractedRoot = await ensureAppImageExtractedRoot(extractionOptions) + if (!extractedRoot) { + throw new Error( + `Could not extract the Orca AppImage at ${this.appImagePath}. Check that it is executable and that ${this.appImageCacheRootPath} has free space.` + ) + } + return extractedRoot + } + + private async inspectStableSymlink( + commandPath: string, + launcherPath: string + ): Promise { + return inspectStableCommand(commandPath, () => this.inspectSymlink(commandPath, launcherPath)) + } + + private async inspectStableLegacyCommand( + commandPath: string, + launcherPath: string + ): Promise< + | (Pick & { + snapshot: NonNullable + managed: boolean + }) + | null + > { + for (let attempt = 0; attempt < STABLE_LEGACY_INSPECTION_ATTEMPTS; attempt += 1) { + const before = await readEntrySnapshot(commandPath) + if (!before) { + return null + } + let target: string | null = null + try { + target = before.isSymbolicLink ? await readlink(commandPath) : null + } catch (error) { + if (isMissingError(error)) { + continue + } + throw error + } + const after = await readEntrySnapshot(commandPath) + if (after && hasSameSnapshot(before, after)) { + const resolvedTarget = target ? resolve(dirname(commandPath), target) : null + return { + fileSha256: null, + rawSymlinkTarget: target, + snapshot: after, + managed: Boolean( + resolvedTarget && this.isManagedLegacyLinuxTarget(resolvedTarget, launcherPath) + ) + } + } + } + throw new Error(`The command at ${commandPath} changed while Orca inspected it.`) + } + + private async restoreQuarantinedCommand( + quarantine: CommandQuarantine, + commandPath: string + ): Promise { + if (!quarantine.snapshot) { + await rmdir(quarantine.directoryPath) + return + } + await this.assertHeldIdentity(quarantine) + try { + await (quarantine.snapshot.isSymbolicLink + ? symlink(await readlink(quarantine.heldPath), commandPath) + : this.linkQuarantinedCommand(quarantine.heldPath, commandPath)) + const restored = await readEntrySnapshot(commandPath) + const restoredSymlink = + restored?.isSymbolicLink && quarantine.snapshot.isSymbolicLink + ? (await readlink(commandPath)) === (await readlink(quarantine.heldPath)) + : false + if ( + !restored || + (!restoredSymlink && !hasSameIdentity(restored.identity, quarantine.snapshot.identity)) + ) { + throw new Error('The restored command identity could not be verified.') + } + await this.discardQuarantinedCommand(quarantine, quarantine.snapshot.isSymbolicLink) + } catch (error) { + throw new Error( + `The displaced entry is preserved at ${quarantine.heldPath}; ${commandPath} could not be restored without overwriting another entry.`, + { cause: error } + ) + } + } + + private async discardQuarantinedCommand( + quarantine: CommandQuarantine, + requireStableMetadata = true + ): Promise { + if (quarantine.snapshot) { + await this.assertHeldIdentity(quarantine, requireStableMetadata) + await unlink(quarantine.heldPath) + } + await rmdir(quarantine.directoryPath) + } + + private async assertHeldIdentity( + quarantine: CommandQuarantine, + requireStableMetadata = true + ): Promise { + const current = await readEntrySnapshot(quarantine.heldPath) + if ( + !current || + !quarantine.snapshot || + !(requireStableMetadata + ? hasSameSnapshot(current, quarantine.snapshot) + : hasSameIdentity(current.identity, quarantine.snapshot.identity)) + ) { + throw new Error(`The quarantined command changed at ${quarantine.heldPath}.`) + } + } + + private async installSymlinkWithPrivileges( + commandPath: string, + launcherPath: string, + inspected: StableCommandInspection + ): Promise { + await this.privilegedRunner( + buildMacPrivilegedSymlinkTransaction({ + action: 'install', + commandPath, + launcherPath, + expected: inspected.snapshot?.identity ?? null, + expectedFileSha256: inspected.fileSha256, + expectedRawSymlinkTarget: inspected.rawSymlinkTarget + }) + ) + const installed = await this.inspectStableSymlink(commandPath, launcherPath) + if (installed.status.state !== 'installed') { + throw new Error(`Could not register the Orca command at ${commandPath}.`) + } + } + + private async removeSymlinkWithPrivileges( + commandPath: string, + inspected: StableCommandInspection + ): Promise { + await this.privilegedRunner( + buildMacPrivilegedSymlinkTransaction({ + action: 'remove', + commandPath, + expected: inspected.snapshot?.identity ?? null, + expectedFileSha256: inspected.fileSha256, + expectedRawSymlinkTarget: inspected.rawSymlinkTarget + }) + ) } } diff --git a/src/main/cli/cli-command-privileged-transaction.test.ts b/src/main/cli/cli-command-privileged-transaction.test.ts new file mode 100644 index 00000000000..2cad192bc11 --- /dev/null +++ b/src/main/cli/cli-command-privileged-transaction.test.ts @@ -0,0 +1,167 @@ +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + readlink, + readdir, + rm, + symlink, + unlink, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => tmpdir(), + getAppPath: () => tmpdir() + } +})) + +import { CliInstaller } from './cli-installer' +import { buildUnixDevLauncher } from './cli-dev-launcher' + +const createdRoots: string[] = [] +const protectedDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(protectedDirectories.splice(0).map((path) => chmod(path, 0o700))) + await Promise.all( + createdRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +async function createPrivilegedFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-privileged-transaction-')) + createdRoots.push(root) + const protectedDirectory = join(root, 'protected') + protectedDirectories.push(protectedDirectory) + const commandPath = join(protectedDirectory, 'orca') + const userDataPath = join(root, 'user-data') + const appPath = join(root, 'app') + await mkdir(protectedDirectory) + await mkdir(join(appPath, 'out', 'cli'), { recursive: true }) + await writeFile(join(appPath, 'out', 'cli', 'index.js'), 'console.log("orca")\n') + return { root, protectedDirectory, commandPath, userDataPath, appPath } +} + +async function executePrivilegedShell(command: string): Promise { + const result = await runProcess({ program: '/bin/sh', args: ['-c', command] }) + if (result.code !== 0) { + const error = new Error( + result.stderr || result.stdout || `Privileged shell exited ${result.code}.` + ) + Object.assign(error, { code: result.code, stderr: result.stderr }) + throw error + } +} + +function fixtureInstallerOptions(fixture: Awaited>) { + return { + platform: 'darwin' as const, + isPackaged: false, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + commandPathOverride: fixture.commandPath, + processPathEnv: fixture.protectedDirectory + } +} + +describe.skipIf(process.platform !== 'darwin' || process.getuid?.() === 0)( + 'macOS privileged CLI command transaction', + () => { + it('installs and removes through the generated no-overwrite shell transaction', async () => { + const fixture = await createPrivilegedFixture() + const commands: string[] = [] + const installer = new CliInstaller({ + ...fixtureInstallerOptions(fixture), + privilegedRunner: async (command) => { + commands.push(command) + await chmod(fixture.protectedDirectory, 0o700) + await executePrivilegedShell(command) + } + }) + + await chmod(fixture.protectedDirectory, 0o500) + const installed = await installer.install() + expect(installed.state).toBe('installed') + await expect(readlink(fixture.commandPath)).resolves.toBe(installed.launcherPath) + + await chmod(fixture.protectedDirectory, 0o500) + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + expect(commands).toHaveLength(2) + expect(commands.every((command) => command.includes('/bin/ln -P'))).toBe(true) + expect(commands.every((command) => !command.includes('mv -f'))).toBe(true) + }) + + it('restores a trailing-newline symlink inserted after privileged inspection', async () => { + const fixture = await createPrivilegedFixture() + const staleTarget = join(fixture.userDataPath, 'cli', 'bin', 'old', 'orca') + const foreignTarget = `${staleTarget}\n` + await symlink(staleTarget, fixture.commandPath) + const original = await lstat(fixture.commandPath, { bigint: true }) + let raced = false + const installer = new CliInstaller({ + ...fixtureInstallerOptions(fixture), + privilegedRunner: async (command) => { + await chmod(fixture.protectedDirectory, 0o700) + if (!raced) { + raced = true + await unlink(fixture.commandPath) + await symlink(foreignTarget, fixture.commandPath) + } + const replacement = await lstat(fixture.commandPath, { bigint: true }) + await executePrivilegedShell( + command.replace( + `${original.dev}:${original.ino}`, + `${replacement.dev}:${replacement.ino}` + ) + ) + } + }) + + await chmod(fixture.protectedDirectory, 0o500) + await expect(installer.install()).rejects.toThrow() + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + expect( + (await readdir(fixture.protectedDirectory)).some((name) => name.startsWith('.orca-cli-')) + ).toBe(false) + }) + + it('restores a managed file changed in place after privileged inspection', async () => { + const fixture = await createPrivilegedFixture() + const oldCliPath = join(fixture.root, 'old', 'out', 'cli', 'index.js') + await writeFile( + fixture.commandPath, + buildUnixDevLauncher('/Applications/Old.app/Contents/MacOS/Orca', oldCliPath, 'user-data') + ) + const foreignContent = 'foreign command written into the inspected inode' + let raced = false + const installer = new CliInstaller({ + ...fixtureInstallerOptions(fixture), + privilegedRunner: async (command) => { + await chmod(fixture.protectedDirectory, 0o700) + if (!raced) { + raced = true + await writeFile(fixture.commandPath, foreignContent) + } + await executePrivilegedShell(command) + } + }) + + await chmod(fixture.protectedDirectory, 0o500) + await expect(installer.install()).rejects.toThrow() + await expect(readFile(fixture.commandPath, 'utf8')).resolves.toBe(foreignContent) + expect( + (await readdir(fixture.protectedDirectory)).some((name) => name.startsWith('.orca-cli-')) + ).toBe(false) + }) + } +) diff --git a/src/main/cli/cli-install-location.ts b/src/main/cli/cli-install-location.ts index 0d0321df34a..e65c17cc1cd 100644 --- a/src/main/cli/cli-install-location.ts +++ b/src/main/cli/cli-install-location.ts @@ -3,6 +3,16 @@ import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import type { CliInstallStatus } from '../../shared/cli-install-types' +import { + hasAppImageRuntimeEnvironment, + resolveAppImageRuntimeIdentity +} from '../appimage-runtime-identity' +import { + getAppImageCacheRootPath, + resolveAppImageExtractedRoot, + type AppImageExtractionOptions +} from './appimage-extracted-root' +import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' import { DEFAULT_MAC_COMMAND_PATH, DEV_COMMAND_NAME } from './cli-install-constants' import { ensureDevLauncher } from './cli-dev-launcher' import type { CliInstallerOptions, InstallSpec } from './cli-installer-contracts' @@ -13,7 +23,6 @@ import { uniquePathEntries } from './cli-install-path-format' import { runMacPrivilegedCommand, writeWindowsUserPath } from './cli-privileged-processes' -import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' import { invalidateWindowsUserPathRegistryCache, readFreshWindowsUserPathRegistry, @@ -46,6 +55,9 @@ export abstract class CliInstallLocation { protected readonly userPathCacheInvalidator: () => void protected readonly windowsEnvironment: NodeJS.ProcessEnv protected readonly appImagePath: string | null + protected readonly hasUnverifiedAppImageRuntime: boolean + protected readonly appImageCacheRootPath: string + protected readonly appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise protected get commandName(): string { if (!this.isPackaged && !this.commandPathOverride) { @@ -84,10 +96,27 @@ export abstract class CliInstallLocation { this.userPathCacheInvalidator = options.userPathCacheInvalidator ?? invalidateWindowsUserPathRegistryCache this.windowsEnvironment = options.windowsEnvironment ?? process.env + const hasExplicitAppImagePath = Object.hasOwn(options, 'appImagePath') + const runtimeAppImageIdentity = resolveAppImageRuntimeIdentity({ + platform: this.platform, + execPath: this.execPathValue, + resourcesPath: this.resourcesPath + }) + this.hasUnverifiedAppImageRuntime = + this.platform === 'linux' && + this.isPackaged && + !hasExplicitAppImagePath && + hasAppImageRuntimeEnvironment() && + !runtimeAppImageIdentity this.appImagePath = this.platform === 'linux' && this.isPackaged - ? (options.appImagePath ?? process.env.APPIMAGE ?? null) + ? hasExplicitAppImagePath + ? (options.appImagePath ?? null) + : (runtimeAppImageIdentity?.appImagePath ?? null) : null + this.appImageCacheRootPath = + options.appImageCacheRootPath ?? getAppImageCacheRootPath(this.homePath) + this.appImageExtractRunner = options.appImageExtractRunner } protected resolveInstallSpec(): InstallSpec | null { @@ -99,7 +128,7 @@ export abstract class CliInstallLocation { if (this.platform === 'darwin' || this.platform === 'linux') { return { commandPath, - installMethod: this.isLinuxAppImage() ? 'wrapper' : 'symlink' + installMethod: 'symlink' } } @@ -212,8 +241,18 @@ export abstract class CliInstallLocation { return null } + if (this.hasUnverifiedAppImageRuntime) { + return null + } + if (this.isLinuxAppImage()) { - return this.appImagePath && existsSync(this.appImagePath) ? this.appImagePath : null + if (!this.appImagePath || !existsSync(this.appImagePath)) { + return null + } + const extractionOptions = this.appImageExtractionOptions() + return extractionOptions + ? (resolveAppImageExtractedRoot(extractionOptions)?.stableLauncherPath ?? null) + : null } if (this.isPackaged) { @@ -229,4 +268,14 @@ export abstract class CliInstallLocation { commandName: this.commandName }) } + + protected appImageExtractionOptions(): AppImageExtractionOptions | null { + return this.appImagePath + ? { + appImagePath: this.appImagePath, + cacheRootPath: this.appImageCacheRootPath, + runExtract: this.appImageExtractRunner + } + : null + } } diff --git a/src/main/cli/cli-installer-appimage-ownership.test.ts b/src/main/cli/cli-installer-appimage-ownership.test.ts new file mode 100644 index 00000000000..92fb7325ec8 --- /dev/null +++ b/src/main/cli/cli-installer-appimage-ownership.test.ts @@ -0,0 +1,229 @@ +import { + lstat, + mkdir, + mkdtemp, + readlink, + readdir, + rename, + rm, + symlink, + unlink, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CliInstallStatus } from '../../shared/cli-install-types' +import { resolveAppImageExtractedRoot, type AppImageExtractedRoot } from './appimage-extracted-root' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => tmpdir(), + getAppPath: () => tmpdir() + } +})) + +import { CliInstaller } from './cli-installer' + +const created: string[] = [] + +type Fixture = Awaited> + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(created.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function makeFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-appimage-ownership-')) + created.push(root) + const appImagePath = join(root, 'Orca.AppImage') + const cacheRootPath = join(root, 'cache') + const commandDirectory = join(root, 'home', '.local', 'bin') + const commandPath = join(commandDirectory, 'orca-ide') + await mkdir(commandDirectory, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + return { root, appImagePath, cacheRootPath, commandDirectory, commandPath } +} + +async function extractPayload(_appImagePath: string, cwd: string): Promise { + const launcherDirectory = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDirectory, { recursive: true }) + await writeFile(join(launcherDirectory, 'orca-ide'), '#!/usr/bin/env bash\n', { mode: 0o755 }) +} + +function installerOptions(fixture: Fixture) { + return { + platform: 'linux' as const, + isPackaged: true, + userDataPath: join(fixture.root, 'user-data'), + resourcesPath: join(fixture.root, 'mount', 'resources'), + execPath: join(fixture.root, 'mount', 'orca-ide'), + appPath: join(fixture.root, 'mount', 'resources', 'app.asar'), + homePath: join(fixture.root, 'home'), + processPathEnv: fixture.commandDirectory, + appImagePath: fixture.appImagePath, + appImageCacheRootPath: fixture.cacheRootPath, + appImageExtractRunner: extractPayload + } +} + +describe.skipIf(process.platform === 'win32')('AppImage CLI ownership', () => { + it('ignores inherited APPIMAGE without the matching runtime identity', async () => { + const fixture = await makeFixture() + const resourcesPath = join(fixture.root, 'installed', 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca-ide') + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + vi.stubEnv('APPIMAGE', fixture.appImagePath) + vi.stubEnv('APPDIR', '') + const extract = vi.fn(extractPayload) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(fixture.root, 'user-data'), + resourcesPath, + execPath: join(fixture.root, 'installed', 'orca-ide'), + appPath: join(resourcesPath, 'app.asar'), + homePath: join(fixture.root, 'home'), + processPathEnv: fixture.commandDirectory, + appImageCacheRootPath: fixture.cacheRootPath, + appImageExtractRunner: extract + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'unsupported', + launcherPath: null, + detail: expect.stringContaining('could not verify') + }) + await expect(installer.install()).rejects.toThrow('could not verify') + expect(extract).not.toHaveBeenCalled() + }) + + it('refuses an arbitrary resources/bin/orca-ide symlink', async () => { + const fixture = await makeFixture() + const foreignTarget = join(fixture.root, 'foreign', 'resources', 'bin', 'orca-ide') + await symlink(foreignTarget, fixture.commandPath) + const extract = vi.fn(extractPayload) + const installer = new CliInstaller({ + ...installerOptions(fixture), + appImageExtractRunner: extract + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ state: 'conflict' }) + await expect(installer.install()).rejects.toThrow('Refusing to replace non-Orca command') + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + expect(extract).not.toHaveBeenCalled() + }) + + it('leaves a foreign legacy resources/bin/orca symlink untouched', async () => { + const fixture = await makeFixture() + const legacyCommandPath = join(fixture.commandDirectory, 'orca') + const foreignTarget = join(fixture.root, 'foreign', 'resources', 'bin', 'orca') + await symlink(foreignTarget, legacyCommandPath) + + await expect(new CliInstaller(installerOptions(fixture)).install()).resolves.toMatchObject({ + state: 'installed' + }) + await expect(readlink(legacyCommandPath)).resolves.toBe(foreignTarget) + }) + + it('keeps installation bound to the extracted generation', async () => { + const fixture = await makeFixture() + let extractedRoot: AppImageExtractedRoot | null = null + class ReplacingAppImageInstaller extends CliInstaller { + protected override async ensureLinuxAppImagePayload(): Promise { + extractedRoot = await super.ensureLinuxAppImagePayload() + await writeFile(fixture.appImagePath, '#!/usr/bin/env bash\n# replacement generation\n', { + mode: 0o755 + }) + return extractedRoot + } + } + + const installer = new ReplacingAppImageInstaller(installerOptions(fixture)) + const installed = await installer.install() + const capturedRoot = extractedRoot as AppImageExtractedRoot | null + + expect(capturedRoot).not.toBeNull() + expect(installed).toMatchObject({ + state: 'stale', + launcherPath: capturedRoot!.stableLauncherPath + }) + await expect(readlink(fixture.commandPath)).resolves.toBe(capturedRoot!.stableLauncherPath) + await expect(lstat(capturedRoot!.stableLauncherPath)).resolves.toBeDefined() + await expect(installer.getStatus()).resolves.toMatchObject({ state: 'stale' }) + }) + + it('repairs the stable endpoint without reclaiming the prior path owner', async () => { + const fixture = await makeFixture() + const firstInstaller = new CliInstaller(installerOptions(fixture)) + const first = await firstInstaller.install() + const firstRoot = resolveAppImageExtractedRoot({ + appImagePath: fixture.appImagePath, + cacheRootPath: fixture.cacheRootPath + })! + const relocatedPath = join(fixture.root, 'downloads', 'Orca.AppImage') + await mkdir(dirname(relocatedPath), { recursive: true }) + await rename(fixture.appImagePath, relocatedPath) + const relocatedFixture = { ...fixture, appImagePath: relocatedPath } + const relocatedInstaller = new CliInstaller(installerOptions(relocatedFixture)) + + await expect(relocatedInstaller.getStatus()).resolves.toMatchObject({ state: 'stale' }) + const repaired = await relocatedInstaller.install() + const relocatedRoot = resolveAppImageExtractedRoot({ + appImagePath: relocatedPath, + cacheRootPath: fixture.cacheRootPath + })! + + expect(repaired).toMatchObject({ state: 'installed', launcherPath: first.launcherPath }) + await expect(readlink(fixture.commandPath)).resolves.toBe(first.launcherPath) + await expect(lstat(relocatedRoot.payloadLauncherPath)).resolves.toBeDefined() + // A path namespace is an ownership boundary; the relocated process cannot prove that no + // sibling AppImage still owns the prior namespace. + await expect(lstat(firstRoot.rootPath)).resolves.toBeDefined() + }) + + it('preserves a foreign command that appears at the final ownership fence', async () => { + const fixture = await makeFixture() + const predictedRoot = resolveAppImageExtractedRoot({ + appImagePath: fixture.appImagePath, + cacheRootPath: fixture.cacheRootPath + })! + const ownedOldTarget = join( + dirname(predictedRoot.rootPath), + 'a'.repeat(24), + 'resources', + 'bin', + 'orca-ide' + ) + const foreignTarget = join(fixture.root, 'foreign', 'orca-ide') + await symlink(ownedOldTarget, fixture.commandPath) + + class RacedInstaller extends CliInstaller { + private inspectionCount = 0 + + protected override async inspectSymlink( + commandPath: string, + launcherPath: string + ): Promise { + this.inspectionCount += 1 + if (this.inspectionCount === 3) { + await unlink(commandPath) + await symlink(foreignTarget, commandPath) + } + return super.inspectSymlink(commandPath, launcherPath) + } + } + + await expect(new RacedInstaller(installerOptions(fixture)).install()).rejects.toThrow( + 'Refusing to replace non-Orca command' + ) + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + expect((await readdir(fixture.commandDirectory)).some((name) => name.includes('.orca-'))).toBe( + false + ) + }) +}) diff --git a/src/main/cli/cli-installer-appimage-removal.test.ts b/src/main/cli/cli-installer-appimage-removal.test.ts new file mode 100644 index 00000000000..eea1e1d4ab1 --- /dev/null +++ b/src/main/cli/cli-installer-appimage-removal.test.ts @@ -0,0 +1,190 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readlink, rm, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' + +vi.mock('electron', () => ({ + app: { isPackaged: false, getPath: () => tmpdir(), getAppPath: () => tmpdir() } +})) + +import { resolveAppImageExtractedRoot } from './appimage-extracted-root' +import { + publishAppImageLauncherEndpoint, + resolveAppImageLauncherEndpointPath, + resolveAppImageStableLauncherPath +} from './appimage-stable-launcher' +import { CliInstaller } from './cli-installer' +import type { CliInstallerOptions } from './cli-installer-contracts' + +const created: string[] = [] + +afterEach(async () => { + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +describe.skipIf(process.platform === 'win32')('AppImage CLI removal', () => { + it.each([false, true])( + 'removes installed payloads while preserving the live PTY launcher (command missing: %s)', + async (removeCommandFirst) => { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-remove-')) + created.push(root) + const appImagePath = join(root, 'Orca.AppImage') + const cacheRootPath = join(root, 'cache') + const commandPath = join(root, 'home', '.local', 'bin', 'orca-ide') + const resourcesPath = join(root, 'mount', 'resources') + const liveLauncherPath = join(resourcesPath, 'bin', 'orca-ide') + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + await writeFile(liveLauncherPath, '#!/usr/bin/env bash\nprintf live', { mode: 0o755 }) + publishAppImageLauncherEndpoint(cacheRootPath, 'live', liveLauncherPath) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(root, 'user-data'), + resourcesPath, + execPath: join(root, 'mount', 'orca-ide'), + appPath: join(resourcesPath, 'app.asar'), + homePath: join(root, 'home'), + processPathEnv: join(root, 'home', '.local', 'bin'), + commandPathOverride: commandPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_path, cwd) => { + const payloadDirectory = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(payloadDirectory, { recursive: true }) + await writeFile( + join(payloadDirectory, 'orca-ide'), + '#!/usr/bin/env bash\nprintf installed', + { + mode: 0o755 + } + ) + } + }) + + const installed = await installer.install() + const extractedRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + expect(await readlink(commandPath)).toBe(installed.launcherPath) + expect(existsSync(extractedRoot.rootPath)).toBe(true) + if (removeCommandFirst) { + await unlink(commandPath) + } + + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + + expect(existsSync(commandPath)).toBe(false) + expect(existsSync(extractedRoot.rootPath)).toBe(false) + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed'))).toBe( + false + ) + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(true) + const stableLauncherPath = resolveAppImageStableLauncherPath(cacheRootPath) + expect(existsSync(stableLauncherPath)).toBe(true) + await expect( + runProcess({ program: stableLauncherPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'live' }) + } + ) + + it('does not remove a sibling AppImage registration or payload', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-siblings-')) + created.push(root) + const cacheRootPath = join(root, 'cache') + const commandPath = join(root, 'home', '.local', 'bin', 'orca-ide') + const resourcesPath = join(root, 'mount', 'resources') + const firstAppImagePath = join(root, 'Orca-stable.AppImage') + const secondAppImagePath = join(root, 'Orca-nightly.AppImage') + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await Promise.all([ + writeFile(firstAppImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }), + writeFile(secondAppImagePath, '#!/usr/bin/env bash\n# nightly\n', { mode: 0o755 }) + ]) + const installerOptions = (appImagePath: string, content: string): CliInstallerOptions => ({ + platform: 'linux', + isPackaged: true, + userDataPath: join(root, 'user-data'), + resourcesPath, + execPath: join(root, 'mount', 'orca-ide'), + appPath: join(resourcesPath, 'app.asar'), + homePath: join(root, 'home'), + processPathEnv: join(root, 'home', '.local', 'bin'), + commandPathOverride: commandPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_path, cwd) => { + const payloadDirectory = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(payloadDirectory, { recursive: true }) + await writeFile(join(payloadDirectory, 'orca-ide'), content, { mode: 0o755 }) + } + }) + class HookedInstaller extends CliInstaller { + afterNextStatus: (() => Promise) | null = null + + override async getStatus() { + const status = await super.getStatus() + const hook = this.afterNextStatus + this.afterNextStatus = null + await hook?.() + return status + } + } + let siblingStatusReads = 0 + class TrackingInstaller extends CliInstaller { + override async getStatus() { + siblingStatusReads += 1 + return super.getStatus() + } + } + const firstInstaller = new HookedInstaller(installerOptions(firstAppImagePath, 'stable')) + const secondInstaller = new TrackingInstaller(installerOptions(secondAppImagePath, 'nightly')) + + await firstInstaller.install() + const firstRoot = resolveAppImageExtractedRoot({ + appImagePath: firstAppImagePath, + cacheRootPath + })! + await secondInstaller.install() + const secondRoot = resolveAppImageExtractedRoot({ + appImagePath: secondAppImagePath, + cacheRootPath + })! + + const siblingStatus = await firstInstaller.getStatus() + expect(firstInstaller.isAppImageRegistrationOwnedBySibling(siblingStatus)).toBe(true) + await rm(resolveAppImageStableLauncherPath(cacheRootPath)) + const brokenLauncherStatus = await firstInstaller.getStatus() + expect(firstInstaller.isAppImageRegistrationOwnedBySibling(brokenLauncherStatus)).toBe(false) + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', secondRoot.payloadLauncherPath) + + await firstInstaller.remove() + + expect(existsSync(firstRoot.rootPath)).toBe(false) + expect(existsSync(secondRoot.rootPath)).toBe(true) + expect(existsSync(commandPath)).toBe(true) + await expect( + readlink(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed')) + ).resolves.toBe(secondRoot.payloadLauncherPath) + + await firstInstaller.install() + let siblingInstall: Promise | null = null + siblingStatusReads = 0 + firstInstaller.afterNextStatus = async () => { + siblingInstall = secondInstaller.install() + await Promise.resolve() + expect(siblingStatusReads).toBe(0) + } + await firstInstaller.remove() + expect(siblingInstall).not.toBeNull() + await siblingInstall + + expect(siblingStatusReads).toBeGreaterThan(0) + expect(existsSync(commandPath)).toBe(true) + expect(existsSync(secondRoot.rootPath)).toBe(true) + await expect( + readlink(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed')) + ).resolves.toBe(secondRoot.payloadLauncherPath) + }) +}) diff --git a/src/main/cli/cli-installer-contracts.ts b/src/main/cli/cli-installer-contracts.ts index 5bb3bb99d7e..40a75cb984f 100644 --- a/src/main/cli/cli-installer-contracts.ts +++ b/src/main/cli/cli-installer-contracts.ts @@ -20,8 +20,10 @@ export type CliInstallerOptions = { userPathWriter?: (value: string) => Promise userPathCacheInvalidator?: () => void windowsEnvironment?: NodeJS.ProcessEnv - /** Why: AppImage reports a stable outer file path via $APPIMAGE while bundled resources live in an ephemeral FUSE mount. */ + /** Trusted caller override; production discovers AppImage only from a complete runtime identity. */ appImagePath?: string | null + appImageCacheRootPath?: string + appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise } export type InstallSpec = { diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 7a2e86afb24..1a5279644e9 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -1,6 +1,17 @@ -import { chmod, lstat, mkdir, readFile, readlink, symlink, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { + chmod, + lstat, + mkdir, + readFile, + readdir, + readlink, + rm, + symlink, + writeFile +} from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { dirname, join, relative, sep } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const execFileMock = vi.hoisted(() => vi.fn()) @@ -18,8 +29,20 @@ vi.mock('node:child_process', () => ({ })) import { CliInstaller } from './cli-installer' -import { buildAppImageCliWrapper } from './appimage-cli-wrapper' import { makeFixture } from './cli-installer-test-fixtures' +import { resolveAppImageExtractedRoot } from './appimage-extracted-root' +import { buildLegacyAppImageCliWrapper } from './legacy-appimage-cli-wrapper' + +// Stands in for the AppImage runtime's `--appimage-extract`, which writes the +// payload to ./squashfs-root relative to cwd. +async function fakeAppImageExtractRunner(_appImagePath: string, cwd: string): Promise { + const launcherDir = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDir, { recursive: true }) + await writeFile(join(launcherDir, 'orca-ide'), '#!/usr/bin/env bash\n', { + encoding: 'utf8', + mode: 0o755 + }) +} describe('CliInstaller', () => { beforeEach(() => { @@ -127,15 +150,18 @@ describe('CliInstaller', () => { } ) - // Why: AppImage resources live under a per-launch FUSE mount, so the - // installed shell command must be a stable wrapper rather than a symlink. + // Why: an AppImage's payload is only reachable through a FUSE mount that its + // own AppRun sets up, and AppRun prepends `--no-sandbox` into node mode on + // userns-restricted hosts (#11609). Extracting once gives the command the + // same plain launcher a deb install ships, so registration is a symlink. it.skipIf(process.platform === 'win32')( - 'creates an AppImage wrapper under the linux command path', + 'symlinks the linux command at the extracted AppImage launcher', async () => { const fixture = await makeFixture() const commandDir = join(fixture.root, '.local', 'bin') const installPath = join(commandDir, 'orca-ide') const appImagePath = join(fixture.root, 'Orca.AppImage') + const cacheRootPath = join(fixture.root, 'cache') await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 @@ -144,77 +170,134 @@ describe('CliInstaller', () => { const installer = new CliInstaller({ platform: 'linux', isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, commandPathOverride: installPath, processPathEnv: commandDir }) const initial = await installer.getStatus() - expect(initial).toMatchObject({ - state: 'not_installed', - installMethod: 'wrapper', - launcherPath: appImagePath - }) + expect(initial).toMatchObject({ state: 'not_installed', installMethod: 'symlink' }) const installed = await installer.install() expect(installed).toMatchObject({ state: 'installed', commandName: 'orca-ide', - installMethod: 'wrapper', - launcherPath: appImagePath, - currentTarget: appImagePath, + installMethod: 'symlink', pathConfigured: true }) + // The command target remains stable while its cache endpoint advances generations. + expect(relative(cacheRootPath, installed.launcherPath as string).split(sep)).toEqual([ + 'launcher', + 'orca-ide' + ]) + expect(installed.currentTarget).toBe(installed.launcherPath) + await expect(readlink(installPath)).resolves.toBe(installed.launcherPath) - const commandStats = await lstat(installPath) - expect(commandStats.isFile()).toBe(true) - expect(commandStats.mode & 0o111).not.toBe(0) - await expect(readlink(installPath)).rejects.toMatchObject({ code: 'EINVAL' }) - await expect(readFile(installPath, 'utf8')).resolves.toBe( - buildAppImageCliWrapper(appImagePath) - ) + const extractedRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + await rm(extractedRoot.rootPath, { recursive: true, force: true }) + await expect(installer.getStatus()).resolves.toMatchObject({ state: 'stale' }) + const repaired = await installer.install() + expect(repaired.state).toBe('installed') const removed = await installer.remove() expect(removed.state).toBe('not_installed') + await expect(lstat(repaired.launcherPath as string)).resolves.toBeDefined() + expect( + existsSync(resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })!.rootPath) + ).toBe(false) } ) it.skipIf(process.platform === 'win32')( - 'reports a stale AppImage wrapper when the AppImage path changes', + 're-extracts and re-points the command when the AppImage is replaced', async () => { const fixture = await makeFixture() const commandDir = join(fixture.root, '.local', 'bin') const installPath = join(commandDir, 'orca-ide') - const oldAppImagePath = join(fixture.root, 'Old-Orca.AppImage') - const newAppImagePath = join(fixture.root, 'Orca.AppImage') - await mkdir(commandDir, { recursive: true }) - await writeFile(installPath, buildAppImageCliWrapper(oldAppImagePath), { + const appImagePath = join(fixture.root, 'Orca.AppImage') + const cacheRootPath = join(fixture.root, 'cache') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) - await writeFile(newAppImagePath, '#!/usr/bin/env bash\n', { + const makeInstaller = (): CliInstaller => + new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, + commandPathOverride: installPath, + processPathEnv: commandDir + }) + + const first = await makeInstaller().install() + expect(first.state).toBe('installed') + + // An update replaces the file in place, so size and mtime both change. + await writeFile(appImagePath, '#!/usr/bin/env bash\n# next version\n', { encoding: 'utf8', mode: 0o755 }) + await expect(makeInstaller().getStatus()).resolves.toMatchObject({ state: 'stale' }) + + const second = await makeInstaller().install() + expect(second.state).toBe('installed') + expect(second.launcherPath).toBe(first.launcherPath) + await expect(readlink(installPath)).resolves.toBe(second.launcherPath) + // Only the live payload survives the upgrade. + const liveRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })!.rootPath + await expect(readdir(dirname(liveRoot))).resolves.toHaveLength(1) + } + ) + + it.skipIf(process.platform === 'win32')( + 'replaces and removes the legacy AppImage wrapper', + async () => { + const fixture = await makeFixture() + const commandDir = join(fixture.root, '.local', 'bin') + const installPath = join(commandDir, 'orca-ide') + const appImagePath = join(fixture.root, "Orca's AppImage.AppImage") + const cacheRootPath = join(fixture.root, 'cache') + await mkdir(commandDir, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + await writeFile(installPath, buildLegacyAppImageCliWrapper(appImagePath), { + encoding: 'utf8', + mode: 0o755 + }) const installer = new CliInstaller({ platform: 'linux', isPackaged: true, - appImagePath: newAppImagePath, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, commandPathOverride: installPath, processPathEnv: commandDir }) await expect(installer.getStatus()).resolves.toMatchObject({ state: 'stale', - installMethod: 'wrapper', - currentTarget: newAppImagePath + currentTarget: appImagePath }) - await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) - await expect(readFile(installPath, 'utf8')).resolves.toBe( - buildAppImageCliWrapper(newAppImagePath) - ) + await expect(readlink(installPath)).resolves.toContain(cacheRootPath) + + await installer.remove() + await writeFile(installPath, buildLegacyAppImageCliWrapper(appImagePath), { + encoding: 'utf8', + mode: 0o755 + }) + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + await expect(lstat(installPath)).rejects.toMatchObject({ code: 'ENOENT' }) } ) @@ -239,6 +322,8 @@ describe('CliInstaller', () => { const installer = new CliInstaller({ platform: 'linux', isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, resourcesPath, homePath, processPathEnv: commandDir @@ -251,24 +336,30 @@ describe('CliInstaller', () => { ) it.skipIf(process.platform === 'win32')( - 'removes a legacy linux orca symlink when installing an AppImage wrapper', + 'removes a legacy linux orca symlink when registering from an AppImage', async () => { const fixture = await makeFixture() const homePath = join(fixture.root, 'home') const commandDir = join(homePath, '.local', 'bin') const legacyCommandPath = join(commandDir, 'orca') const appImagePath = join(fixture.root, 'Orca.AppImage') + const cacheRootPath = join(fixture.root, 'cache') await mkdir(commandDir, { recursive: true }) await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) - await symlink(join('/tmp', '.mount_Orca1234', 'resources', 'bin', 'orca'), legacyCommandPath) + const extractedRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + await symlink(join(dirname(extractedRoot.payloadLauncherPath), 'orca'), legacyCommandPath) const installer = new CliInstaller({ platform: 'linux', isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, homePath, processPathEnv: commandDir }) @@ -315,7 +406,9 @@ describe('CliInstaller', () => { expect(installed.pathConfigured).toBe(true) expect(privilegedCommands).toHaveLength(1) expect(privilegedCommands[0]).toContain('mkdir -p') - expect(privilegedCommands[0]).toContain('ln -sfn') + expect(privilegedCommands[0]).toContain('ln -s') + expect(privilegedCommands[0]).toContain('/bin/ln -P') + expect(privilegedCommands[0]).not.toContain('mv -f') await expect(readlink(installPath)).resolves.toBe(installed.launcherPath) } finally { await chmod(protectedDir, 0o700).catch(() => undefined) diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index bc1f8c452b2..c6fba042216 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -1,10 +1,34 @@ import { mkdir, unlink } from 'node:fs/promises' import { dirname } from 'node:path' import type { CliInstallStatus } from '../../shared/cli-install-types' +import { + pruneAppImageExtractedRoots, + removeAppImageInstalledPayloads +} from './appimage-extraction-pruning' +import { withAppImageRegistrationLock } from './appimage-registration-lock' +import { + isAppImageInstalledLauncherCurrent, + isAppImageInstalledLauncherOwnedBySibling, + resolveAppImageNamespacePath +} from './appimage-extracted-root' import { getBundledLauncherPath } from './bundled-cli-launcher-path' +import { isAppImageStableLauncherReady } from './appimage-stable-launcher' import { CliPathRegistration } from './cli-path-registration' export class CliInstaller extends CliPathRegistration { + isAppImageRegistrationOwnedBySibling(status: CliInstallStatus): boolean { + if ( + status.currentTarget !== status.launcherPath || + !isAppImageStableLauncherReady(this.appImageCacheRootPath) + ) { + return false + } + const extractionOptions = this.appImageExtractionOptions() + return Boolean( + extractionOptions && isAppImageInstalledLauncherOwnedBySibling(extractionOptions) + ) + } + async getStatus(): Promise { const defaultSpec = this.resolveInstallSpec() if (!defaultSpec) { @@ -26,8 +50,9 @@ export class CliInstaller extends CliPathRegistration { const launcherPath = await this.resolveLauncherPath() if (!launcherPath) { - const detail = - this.isLinuxAppImage() && this.appImagePath + const detail = this.hasUnverifiedAppImageRuntime + ? 'Orca could not verify the inherited AppImage runtime identity, so CLI registration is unavailable.' + : this.isLinuxAppImage() && this.appImagePath ? `The AppImage file at ${this.appImagePath} is missing. Move it back or re-run CLI registration from the current AppImage location.` : this.isPackaged ? 'The bundled CLI launcher is missing from this Orca build.' @@ -48,20 +73,56 @@ export class CliInstaller extends CliPathRegistration { } } + return this.getStatusForLauncher(launcherPath) + } + + private async getStatusForLauncher(launcherPath: string): Promise { + const defaultSpec = this.resolveInstallSpec() + if (!defaultSpec) { + throw new Error('CLI registration is not implemented on this platform.') + } const spec = await this.resolveActiveInstallSpec(defaultSpec, launcherPath) - const baseStatus = + const inspectedStatus = spec.installMethod === 'symlink' ? await this.inspectSymlink(spec.commandPath, launcherPath) - : this.isLinuxAppImage() - ? await this.inspectAppImageWrapper(spec.commandPath, launcherPath) - : await this.inspectWindowsWrapper(spec.commandPath, launcherPath) + : await this.inspectWindowsWrapper(spec.commandPath, launcherPath) + const extractionOptions = this.appImageExtractionOptions() + const baseStatus = + inspectedStatus.state === 'installed' && + extractionOptions && + !isAppImageInstalledLauncherCurrent(extractionOptions) + ? { + ...inspectedStatus, + state: 'stale' as const, + detail: `${spec.commandPath} does not point to the current Orca AppImage payload.` + } + : inspectedStatus const pathDirectory = dirname(spec.commandPath) const pathProbe = await this.probePathConfiguration(pathDirectory) return this.withPathInfo(baseStatus, pathDirectory, pathProbe) } async install(): Promise { - const status = await this.getStatus() + return this.runAppImageRegistrationOperation(() => this.installUnlocked()) + } + + private async installUnlocked(): Promise { + const initialStatus = await this.getStatus() + if ( + !initialStatus.supported || + !initialStatus.commandPath || + !initialStatus.launcherPath || + !initialStatus.installMethod + ) { + throw new Error(initialStatus.detail ?? 'CLI registration is unavailable on this build.') + } + if (initialStatus.state === 'conflict') { + throw new Error(`Refusing to replace non-Orca command at ${initialStatus.commandPath}.`) + } + const extractedRoot = await this.ensureLinuxAppImagePayload() + const status = extractedRoot + ? await this.getStatusForLauncher(extractedRoot.stableLauncherPath) + : initialStatus if (!status.supported || !status.commandPath || !status.launcherPath || !status.installMethod) { throw new Error(status.detail ?? 'CLI registration is unavailable on this build.') } @@ -73,9 +134,6 @@ export class CliInstaller extends CliPathRegistration { if (status.installMethod === 'symlink') { await this.installSymlink(status) await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) - } else if (this.isLinuxAppImage()) { - await this.installAppImageWrapper(status.commandPath, status.launcherPath) - await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) } else if (this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath)) { // Why: packaged Windows already ships resources/bin/orca.exe; registration only owns the PATH entry. } else { @@ -88,13 +146,23 @@ export class CliInstaller extends CliPathRegistration { // Why: Windows shells find commands via user PATH, so the installer owns that entry, not the desktop installer. await this.ensureWindowsPathEntry(dirname(status.commandPath)) } + if (extractedRoot) { + await pruneAppImageExtractedRoots(extractedRoot.rootPath) + } - return this.getStatus() + return extractedRoot + ? this.getStatusForLauncher(extractedRoot.stableLauncherPath) + : this.getStatus() } async remove(): Promise { + return this.runAppImageRegistrationOperation(() => this.removeUnlocked()) + } + + private async removeUnlocked(): Promise { const status = await this.getStatus() if (!status.supported || !status.commandPath || !status.launcherPath || !status.installMethod) { + await this.removeLinuxAppImagePayloads() return status } if (status.state === 'not_installed') { @@ -103,15 +171,21 @@ export class CliInstaller extends CliPathRegistration { await this.removeWindowsPathEntry(dirname(status.commandPath)) return this.getStatus() } + await this.removeLinuxAppImagePayloads() return status } if (status.state === 'conflict') { throw new Error(`Refusing to remove non-Orca command at ${status.commandPath}.`) } - if (status.state === 'stale') { + if (status.state === 'stale' && status.installMethod !== 'symlink') { throw new Error(`Refusing to remove a command not owned by Orca at ${status.commandPath}.`) } + if (status.state === 'stale' && this.isAppImageRegistrationOwnedBySibling(status)) { + await this.removeLinuxAppImagePayloads() + return this.getStatus() + } + if (status.installMethod === 'symlink') { await this.removeSymlink(status.commandPath) await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) @@ -122,8 +196,22 @@ export class CliInstaller extends CliPathRegistration { await this.removeWindowsPathEntry(dirname(status.commandPath)) } + await this.removeLinuxAppImagePayloads() return this.getStatus() } + + private async removeLinuxAppImagePayloads(): Promise { + const extractionOptions = this.appImageExtractionOptions() + if (this.isLinuxAppImage() && extractionOptions) { + await removeAppImageInstalledPayloads(resolveAppImageNamespacePath(extractionOptions)) + } + } + + private runAppImageRegistrationOperation(operation: () => Promise): Promise { + return this.isLinuxAppImage() + ? withAppImageRegistrationLock(this.appImageCacheRootPath, operation) + : operation() + } } export { getBundledLauncherPath } diff --git a/src/main/cli/appimage-cli-wrapper.ts b/src/main/cli/legacy-appimage-cli-wrapper.ts similarity index 61% rename from src/main/cli/appimage-cli-wrapper.ts rename to src/main/cli/legacy-appimage-cli-wrapper.ts index f66ecacfec2..4ac604505f8 100644 --- a/src/main/cli/appimage-cli-wrapper.ts +++ b/src/main/cli/legacy-appimage-cli-wrapper.ts @@ -1,3 +1,5 @@ +import { quoteShell } from './cli-install-path-format' + const APPIMAGE_CLI_SCRIPT = [ '(async()=>{', 'try{', @@ -12,9 +14,15 @@ const APPIMAGE_CLI_SCRIPT = [ '})();' ].join('') -export function buildAppImageCliWrapper(appImagePath: string): string { - // Why: AppImage mounts resources under a fresh FUSE path per launch, so the - // installed command must call the stable outer AppImage and resolve APPDIR. +export function extractLegacyAppImageCliWrapperTarget(content: string): string | null { + const assignment = /^APPIMAGE=(.+)$/mu.exec(content)?.[1] + const appImagePath = assignment ? unquoteShell(assignment) : null + return appImagePath && content === buildLegacyAppImageCliWrapper(appImagePath) + ? appImagePath + : null +} + +export function buildLegacyAppImageCliWrapper(appImagePath: string): string { return `#!/usr/bin/env bash set -euo pipefail APPIMAGE=${quoteShell(appImagePath)} @@ -32,6 +40,10 @@ ELECTRON_RUN_AS_NODE=1 exec "$APPIMAGE" -e ${quoteShell(APPIMAGE_CLI_SCRIPT)} -- ` } -export function quoteShell(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'` +function unquoteShell(value: string): string | null { + if (!value.startsWith("'") || !value.endsWith("'")) { + return null + } + const decoded = value.slice(1, -1).split(`'"'"'`).join("'") + return quoteShell(decoded) === value ? decoded : null } diff --git a/src/main/cli/linux-bare-orca-dispatcher.test.ts b/src/main/cli/linux-bare-orca-dispatcher.test.ts index b2bb40e558b..18a16d53f60 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.test.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.test.ts @@ -1,13 +1,63 @@ +import { existsSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join, relative, resolve, sep } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +const { filePublicationFailures } = vi.hoisted(() => ({ + filePublicationFailures: { link: 0, replaceBeforeRename: '' } +})) +const registrationLock = vi.hoisted(() => ({ + completed: null as ((cacheRootPath: string) => void) | null, + entered: null as ((cacheRootPath: string) => void) | null, + pause: null as Promise | null +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + link: async (...args: Parameters) => { + if (filePublicationFailures.link > 0) { + filePublicationFailures.link -= 1 + throw Object.assign(new Error('link unsupported'), { code: 'ENOTSUP' }) + } + return actual.link(...args) + }, + rename: async (...args: Parameters) => { + if (filePublicationFailures.replaceBeforeRename) { + await actual.writeFile(args[0], filePublicationFailures.replaceBeforeRename, { + mode: 0o755 + }) + filePublicationFailures.replaceBeforeRename = '' + } + return actual.rename(...args) + } + } +}) + vi.mock('electron', () => ({ app: { isPackaged: true } })) +vi.mock('./appimage-registration-lock', () => ({ + withAppImageRegistrationLock: async ( + cacheRootPath: string, + operation: () => Promise + ): Promise => { + registrationLock.entered?.(cacheRootPath) + const pause = registrationLock.pause + registrationLock.pause = null + await pause + const result = await operation() + registrationLock.completed?.(cacheRootPath) + return result + } +})) + import { installLinuxBareOrcaDispatcher } from './linux-bare-orca-dispatcher' +import { resolveAppImageExtractedRoot } from './appimage-extracted-root' const created: string[] = [] @@ -22,6 +72,11 @@ async function makeFixture(): Promise<{ homePath: string; resourcesPath: string } afterEach(async () => { + filePublicationFailures.link = 0 + filePublicationFailures.replaceBeforeRename = '' + registrationLock.completed = null + registrationLock.entered = null + registrationLock.pause = null await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) @@ -67,6 +122,39 @@ describe('installLinuxBareOrcaDispatcher', () => { expect(second.state).toBe('installed') }) + it('publishes when the home filesystem does not support hard links', async () => { + const { homePath, resourcesPath } = await makeFixture() + filePublicationFailures.link = 1 + + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath: null + }) + + expect(result.state).toBe('installed') + await expect(readFile(result.dispatcherPath, 'utf8')).resolves.toContain( + '# orca-serve-bare-orca-dispatcher' + ) + }) + + it('restores a displaced foreign dispatcher when hard links are unsupported', async () => { + const { homePath, resourcesPath } = await makeFixture() + await installLinuxBareOrcaDispatcher({ resourcesPath, homePath, appImagePath: null }) + const foreignContent = '#!/bin/sh\necho foreign\n' + filePublicationFailures.replaceBeforeRename = foreignContent + filePublicationFailures.link = 2 + + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath: null + }) + + expect(result.state).toBe('skipped-foreign') + await expect(readFile(result.dispatcherPath, 'utf8')).resolves.toBe(foreignContent) + }) + it('quotes a resources path containing spaces so the exec line cannot be split', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-space-')) created.push(root) @@ -84,36 +172,154 @@ describe('installLinuxBareOrcaDispatcher', () => { expect(content).toContain(`exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"`) }) - it('execs the stable AppImage (not the ephemeral mount) when running from an AppImage', async () => { - const { homePath, resourcesPath } = await makeFixture() - const appImagePath = join(homePath, 'Applications', 'Orca.AppImage') + // Why: this dispatcher must survive a restart, and an AppImage's resourcesPath + // is a mount that dies with the app. Point it at the extracted payload, which + // also keeps it clear of AppRun's `--no-sandbox` injection (#11609). + it.skipIf(process.platform === 'win32')( + 'execs the extracted payload (not the ephemeral mount) when running from an AppImage', + async () => { + const { homePath, resourcesPath } = await makeFixture() + const appImagePath = join(homePath, 'Orca.AppImage') + await mkdir(homePath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + const cacheRootPath = join(homePath, 'cache') - const result = await installLinuxBareOrcaDispatcher({ resourcesPath, homePath, appImagePath }) + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_appImagePath, cwd) => { + const launcherDir = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDir, { recursive: true }) + await writeFile(join(launcherDir, 'orca-ide'), '', { encoding: 'utf8', mode: 0o755 }) + } + }) - expect(result.state).toBe('installed') - expect(result.target).toBe(appImagePath) - const content = await readFile(result.dispatcherPath, 'utf8') - // The AppImage wrapper references the stable outer path, never resourcesPath. - expect(content).toContain(appImagePath) - expect(content).not.toContain(resourcesPath) - }) + expect(result.state).toBe('installed') + expect(relative(cacheRootPath, result.target as string).split(sep)).toEqual([ + 'launcher', + 'orca-ide' + ]) + const content = await readFile(result.dispatcherPath, 'utf8') + expect(content).toContain(result.target as string) + expect(content).not.toContain(resourcesPath) + expect(content).not.toContain(appImagePath) + } + ) it('skips (does not clobber) a user-owned orca already at ~/.local/bin', async () => { const { homePath, resourcesPath } = await makeFixture() const dispatcherPath = join(homePath, '.local', 'bin', 'orca') + const appImagePath = join(homePath, 'Orca.AppImage') await mkdir(join(homePath, '.local', 'bin'), { recursive: true }) await writeFile(dispatcherPath, '#!/bin/sh\necho my own orca\n', 'utf8') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', 'utf8') + const extract = vi.fn() const result = await installLinuxBareOrcaDispatcher({ resourcesPath, homePath, - appImagePath: null + appImagePath, + appImageExtractRunner: extract }) expect(result.state).toBe('skipped-foreign') + expect(result.target).toBeNull() + expect(extract).not.toHaveBeenCalled() expect(await readFile(dispatcherPath, 'utf8')).toBe('#!/bin/sh\necho my own orca\n') }) + it('preserves a foreign dispatcher created while AppImage extraction is in flight', async () => { + const { homePath, resourcesPath } = await makeFixture() + const appImagePath = join(homePath, 'Orca.AppImage') + const cacheRootPath = join(homePath, 'cache') + const dispatcherPath = join(homePath, '.local', 'bin', 'orca') + await mkdir(homePath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + let reportStarted!: () => void + let releaseExtraction!: () => void + const started = new Promise((resolve) => { + reportStarted = resolve + }) + const released = new Promise((resolve) => { + releaseExtraction = resolve + }) + + const installation = installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_path, cwd) => { + await writePayload(cwd) + reportStarted() + await released + } + }) + await started + await mkdir(dirname(dispatcherPath), { recursive: true }) + await writeFile(dispatcherPath, '#!/bin/sh\necho foreign\n', { mode: 0o755 }) + releaseExtraction() + + await expect(installation).resolves.toMatchObject({ + state: 'skipped-foreign', + target: null + }) + await expect(readFile(dispatcherPath, 'utf8')).resolves.toBe('#!/bin/sh\necho foreign\n') + }) + + it('prunes old owner generations without touching a sibling namespace', async () => { + const { homePath, resourcesPath } = await makeFixture() + const appImagePath = join(homePath, 'Orca.AppImage') + const cacheRootPath = join(homePath, 'cache', 'unused', '..') + await mkdir(homePath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + const events: string[] = [] + const options = { + resourcesPath, + homePath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_path: string, cwd: string) => { + events.push('extract') + await writePayload(cwd) + } + } + + await installLinuxBareOrcaDispatcher(options) + const previous = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const sibling = join(resolve(cacheRootPath), 'f'.repeat(24), 'e'.repeat(24)) + await mkdir(sibling, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n# next\n', { mode: 0o755 }) + const lockEntered = Promise.withResolvers() + const releaseLock = Promise.withResolvers() + events.length = 0 + registrationLock.pause = releaseLock.promise + registrationLock.entered = (rootPath) => { + events.push('lock-entered') + lockEntered.resolve(rootPath) + } + registrationLock.completed = () => { + events.push( + existsSync(previous.rootPath) ? 'lock-left-before-prune' : 'lock-left-after-prune' + ) + } + + const installation = installLinuxBareOrcaDispatcher(options) + await expect(lockEntered.promise).resolves.toBe(resolve(cacheRootPath)) + expect(events).toEqual(['lock-entered']) + expect(existsSync(previous.rootPath)).toBe(true) + releaseLock.resolve() + await installation + const current = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + + expect(events).toEqual(['lock-entered', 'extract', 'lock-left-after-prune']) + expect(existsSync(previous.rootPath)).toBe(false) + expect(existsSync(current.rootPath)).toBe(true) + expect(existsSync(sibling)).toBe(true) + }) + it('skips when the bundled orca-ide launcher is missing from the build', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-nolauncher-')) created.push(root) @@ -128,3 +334,9 @@ describe('installLinuxBareOrcaDispatcher', () => { expect(result.target).toBeNull() }) }) + +async function writePayload(cwd: string): Promise { + const launcherDirectory = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDirectory, { recursive: true }) + await writeFile(join(launcherDirectory, 'orca-ide'), '#!/usr/bin/env bash\n', { mode: 0o755 }) +} diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index 3dc8df2906c..780dca8b1f6 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -1,8 +1,19 @@ -import { existsSync } from 'node:fs' -import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { constants, existsSync } from 'node:fs' +import { copyFile, link, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import { buildAppImageCliWrapper, quoteShell } from './appimage-cli-wrapper' +import { + hasAppImageRuntimeEnvironment, + resolveAppImageRuntimeIdentity +} from '../appimage-runtime-identity' +import { + ensureAppImageExtractedRoot, + resolveAppImageCacheRootPath +} from './appimage-extracted-root' +import { pruneAppImageExtractedRoots } from './appimage-extraction-pruning' +import { withAppImageRegistrationLock } from './appimage-registration-lock' +import { quoteShell } from './cli-install-path-format' import { getBundledLauncherPath } from './cli-installer' // Why: marks a dispatcher this function wrote so repeat serve starts overwrite @@ -14,8 +25,12 @@ export type LinuxBareOrcaDispatcherOptions = { resourcesPath: string /** Test seam — defaults to the real home directory. */ homePath?: string - /** Test seam — defaults to $APPIMAGE (set only when running from an AppImage). */ + /** Trusted caller override; production requires the complete AppImage runtime identity. */ appImagePath?: string | null + /** Test seam — defaults to $XDG_CACHE_HOME/orca/appimage. */ + appImageCacheRootPath?: string + /** Test seam — defaults to running the AppImage's own `--appimage-extract`. */ + appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise } export type LinuxBareOrcaDispatcherState = @@ -26,7 +41,7 @@ export type LinuxBareOrcaDispatcherState = export type LinuxBareOrcaDispatcherResult = { state: LinuxBareOrcaDispatcherState dispatcherPath: string - /** What the dispatcher execs: the stable AppImage, or the bundled orca-ide. */ + /** The bundled `orca-ide` launcher the dispatcher execs. */ target: string | null } @@ -41,73 +56,176 @@ export async function installLinuxBareOrcaDispatcher( options: LinuxBareOrcaDispatcherOptions ): Promise { const dispatcherPath = join(options.homePath ?? homedir(), '.local', 'bin', 'orca') - const appImagePath = options.appImagePath ?? process.env.APPIMAGE ?? null + if (existsSync(dispatcherPath) && !(await isOwnedDispatcher(dispatcherPath))) { + return { state: 'skipped-foreign', dispatcherPath, target: null } + } - const resolved = resolveDispatcherScript(options.resourcesPath, appImagePath) - if (!resolved) { + const launcher = await resolveStableLauncherPath(options) + if (!launcher) { return { state: 'skipped-launcher-missing', dispatcherPath, target: null } } - // Why: only (re)write a dispatcher we previously created; leave a user's own - // `orca` untouched rather than silently clobbering it on every serve start. - if (existsSync(dispatcherPath) && !(await isOwnedDispatcher(dispatcherPath))) { - return { state: 'skipped-foreign', dispatcherPath, target: resolved.target } - } - - await mkdir(dirname(dispatcherPath), { recursive: true }) - await writeFile(dispatcherPath, resolved.script, 'utf8') - await chmod(dispatcherPath, 0o755) - return { state: 'installed', dispatcherPath, target: resolved.target } + const installed = await publishDispatcher( + dispatcherPath, + insertDispatcherMarker(buildBareOrcaCliScript(launcher)) + ) + return installed + ? { state: 'installed', dispatcherPath, target: launcher } + : { state: 'skipped-foreign', dispatcherPath, target: null } } -/** Bare-`orca` script that execs the Orca CLI: the stable AppImage when running - * from one, otherwise the bundled `orca-ide` launcher. Shared by the serve - * dispatcher and the managed-terminal PATH shim. */ -export function buildBareOrcaCliScript( - resourcesPath: string, - appImagePath: string | null -): { script: string; target: string } | null { - if (appImagePath) { - // Why: an AppImage mounts resources under an ephemeral FUSE path per launch, - // so the script must exec the stable outer AppImage — reuse the same - // wrapper CliInstaller installs for the AppImage command. - return { script: buildAppImageCliWrapper(appImagePath), target: appImagePath } - } +/** Bare-`orca` script that execs the one Linux CLI launcher. */ +export function buildBareOrcaCliScript(launcherPath: string): string { + return `#!/usr/bin/env bash\nexec ${quoteShell(launcherPath)} "$@"\n` +} - const launcher = getBundledLauncherPath('linux', resourcesPath) +/** + * The launcher path this dispatcher can still reach on a later boot. Under an + * AppImage `process.resourcesPath` is an ephemeral FUSE mount that dies with the + * app, so extract the payload once and point at that stable copy instead. + */ +async function resolveStableLauncherPath( + options: LinuxBareOrcaDispatcherOptions +): Promise { + const hasExplicitAppImagePath = Object.hasOwn(options, 'appImagePath') + const runtimeIdentity = resolveAppImageRuntimeIdentity({ resourcesPath: options.resourcesPath }) + if (!hasExplicitAppImagePath && hasAppImageRuntimeEnvironment() && !runtimeIdentity) { + return null + } + const appImagePath = hasExplicitAppImagePath + ? (options.appImagePath ?? null) + : (runtimeIdentity?.appImagePath ?? null) + if (appImagePath) { + const extractionOptions = { + appImagePath, + cacheRootPath: options.appImageCacheRootPath, + runExtract: options.appImageExtractRunner + } + return withAppImageRegistrationLock( + resolveAppImageCacheRootPath(extractionOptions), + async () => { + const extractedRoot = await ensureAppImageExtractedRoot(extractionOptions) + if (extractedRoot) { + await pruneAppImageExtractedRoots(extractedRoot.rootPath) + } + return extractedRoot?.stableLauncherPath ?? null + } + ) + } + const launcher = getBundledLauncherPath('linux', options.resourcesPath) // Why: getBundledLauncherPath only joins the path; guard existence so we never // write a script pointing at a missing launcher (which would fail at exec // time with a confusing error instead of the command-not-found we fix). - if (!launcher || !existsSync(launcher)) { - return null - } - return { - script: `#!/usr/bin/env bash\nexec ${quoteShell(launcher)} "$@"\n`, - target: launcher - } + return launcher && existsSync(launcher) ? launcher : null } -function resolveDispatcherScript( - resourcesPath: string, - appImagePath: string | null -): { script: string; target: string } | null { - const resolved = buildBareOrcaCliScript(resourcesPath, appImagePath) - return resolved && { script: withMarker(resolved.script), target: resolved.target } -} - -function withMarker(script: string): string { - const firstNewline = script.indexOf('\n') - if (firstNewline === -1) { - return `${script}\n${DISPATCHER_MARKER}\n` - } - // Keep the shebang on line 1; insert the marker immediately after it. - return `${script.slice(0, firstNewline + 1)}${DISPATCHER_MARKER}\n${script.slice(firstNewline + 1)}` +function insertDispatcherMarker(script: string): string { + return script.replace('\n', `\n${DISPATCHER_MARKER}\n`) } async function isOwnedDispatcher(dispatcherPath: string): Promise { try { - return (await readFile(dispatcherPath, 'utf8')).includes(DISPATCHER_MARKER) + return ( + (await lstat(dispatcherPath)).isFile() && + (await readFile(dispatcherPath, 'utf8')).split('\n')[1] === DISPATCHER_MARKER + ) } catch { return false } } + +async function publishDispatcher(dispatcherPath: string, content: string): Promise { + const directoryPath = dirname(dispatcherPath) + const temporaryPath = join(directoryPath, `.orca-dispatcher-${process.pid}-${randomUUID()}`) + await mkdir(directoryPath, { recursive: true }) + await writeFile(temporaryPath, content, { encoding: 'utf8', flag: 'wx', mode: 0o755 }) + try { + if (await publishIfVacant(temporaryPath, dispatcherPath)) { + return true + } + + const displacedPath = join( + directoryPath, + `.orca-preserved-dispatcher-${process.pid}-${randomUUID()}` + ) + try { + await rename(dispatcherPath, displacedPath) + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) { + throw error + } + return await publishIfVacant(temporaryPath, dispatcherPath) + } + + if (!(await isOwnedDispatcher(displacedPath))) { + await restoreDisplacedDispatcher(displacedPath, dispatcherPath) + return false + } + + try { + if ( + (await publishIfVacant(temporaryPath, dispatcherPath)) || + (await isExactExecutableDispatcher(dispatcherPath, content)) + ) { + await unlink(displacedPath) + return true + } + // A concurrently published foreign command owns the public path now. + await unlink(displacedPath) + return false + } catch (error) { + await restoreDisplacedDispatcher(displacedPath, dispatcherPath) + throw error + } + } finally { + await unlink(temporaryPath).catch(() => {}) + } +} + +async function publishIfVacant(sourcePath: string, destinationPath: string): Promise { + try { + await link(sourcePath, destinationPath) + return true + } catch (error) { + if (hasErrorCode(error, 'EEXIST')) { + return false + } + } + try { + await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL) + return true + } catch (error) { + if (hasErrorCode(error, 'EEXIST')) { + return false + } + throw error + } +} + +async function restoreDisplacedDispatcher( + displacedPath: string, + dispatcherPath: string +): Promise { + if (await publishIfVacant(displacedPath, dispatcherPath)) { + await unlink(displacedPath) + } +} + +async function isExactExecutableDispatcher( + dispatcherPath: string, + content: string +): Promise { + try { + const [actual, metadata] = await Promise.all([ + readFile(dispatcherPath, 'utf8'), + lstat(dispatcherPath) + ]) + return metadata.isFile() && (metadata.mode & 0o111) !== 0 && actual === content + } catch { + return false + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && 'code' in error && error.code === code +} diff --git a/src/main/cli/linux-terminal-orca-cli-shim.test.ts b/src/main/cli/linux-terminal-orca-cli-shim.test.ts index 81af17ba779..03f92b38960 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.test.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.test.ts @@ -1,13 +1,18 @@ -import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { chmodSync, mkdirSync, readFileSync, readlinkSync, statSync, writeFileSync } from 'node:fs' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' vi.mock('electron', () => ({ app: { isPackaged: true } })) +import { + resolveAppImageLauncherEndpointPath, + resolveAppImageStableLauncherPath +} from './appimage-stable-launcher' import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim' const created: string[] = [] @@ -44,7 +49,7 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { expect(mode & 0o111).not.toBe(0) }) - it('memoizes per userDataPath and re-asserts the exec bit for a stale shim', async () => { + it('reuses the shim path and re-asserts its exec bit', async () => { const { userDataPath, resourcesPath } = await makeFixture() const options = { userDataPath, resourcesPath, appImagePath: null } @@ -53,10 +58,9 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { const shimPath = join(first!, 'orca') chmodSync(shimPath, 0o644) - // A distinct userData path is not memoized, so ensure runs again and heals - // the exec bit lost above only when it actually processes that path. const second = ensureLinuxTerminalOrcaCliShimDir(options) expect(second).toBe(first) + expect(statSync(shimPath).mode & 0o111).not.toBe(0) const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-2-')) created.push(root) @@ -76,19 +80,103 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { expect(statSync(healedPath).mode & 0o111).not.toBe(0) }) - it('execs the stable AppImage (not the ephemeral mount) when running from an AppImage', async () => { + it('routes AppImage terminals through the stable cache without extracting', async () => { const { userDataPath, resourcesPath } = await makeFixture() - const appImagePath = join(userDataPath, 'Applications', 'Orca.AppImage') + const appImagePath = join(userDataPath, 'Orca.AppImage') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + const cacheRootPath = join(userDataPath, 'cache') + const liveLauncherPath = join(resourcesPath, 'bin', 'orca-ide') + writeFileSync(liveLauncherPath, '#!/usr/bin/env bash\nprintf live', 'utf8') + chmodSync(liveLauncherPath, 0o755) + const extract = vi.fn() const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath, resourcesPath, - appImagePath + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: extract }) - const content = readFileSync(join(shimDir!, 'orca'), 'utf8') - expect(content).toContain(appImagePath) + const shimPath = join(shimDir!, 'orca') + const stableLauncherPath = resolveAppImageStableLauncherPath(cacheRootPath) + const content = readFileSync(shimPath, 'utf8') + expect(content).toContain(stableLauncherPath) expect(content).not.toContain(resourcesPath) + expect(content).not.toContain(appImagePath) + await expect( + runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'live' }) + expect(extract).not.toHaveBeenCalled() + }) + + it('updates restored terminals to the current AppImage mount without rewriting the shim', async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Orca.AppImage') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + const cacheRootPath = join(userDataPath, 'cache') + const firstLauncher = join(resourcesPath, 'bin', 'orca-ide') + writeFileSync(firstLauncher, '#!/usr/bin/env bash\nprintf first', 'utf8') + chmodSync(firstLauncher, 0o755) + const extract = vi.fn() + const options = { + userDataPath, + resourcesPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: extract + } + const shimDir = ensureLinuxTerminalOrcaCliShimDir(options) + const shimPath = join(shimDir!, 'orca') + const originalShim = readFileSync(shimPath, 'utf8') + + const nextResourcesPath = join(userDataPath, 'next-mount', 'resources') + const nextLauncher = join(nextResourcesPath, 'bin', 'orca-ide') + await mkdir(join(nextResourcesPath, 'bin'), { recursive: true }) + await writeFile(nextLauncher, '#!/usr/bin/env bash\nprintf next', { mode: 0o755 }) + await rm(firstLauncher) + expect( + ensureLinuxTerminalOrcaCliShimDir({ ...options, resourcesPath: nextResourcesPath }) + ).toBe(shimDir) + + expect(readFileSync(shimPath, 'utf8')).toBe(originalShim) + expect(readlinkSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe( + nextLauncher + ) + await expect( + runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'next' }) + expect(extract).not.toHaveBeenCalled() + }) + + it('waits briefly for a temporarily unavailable live endpoint', async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Orca.AppImage') + const cacheRootPath = join(userDataPath, 'cache') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + const liveLauncher = join(resourcesPath, 'bin', 'orca-ide') + chmodSync(liveLauncher, 0o755) + const extract = vi.fn() + + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath, + resourcesPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: extract + }) + const shimPath = join(shimDir!, 'orca') + await rm(liveLauncher) + const invocation = runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + setTimeout(() => { + writeFileSync(liveLauncher, '#!/usr/bin/env bash\nprintf recovered', { mode: 0o755 }) + }, 100) + + await expect(invocation).resolves.toMatchObject({ code: 0, stdout: 'recovered' }) + expect(extract).not.toHaveBeenCalled() }) it('returns null (and does not memoize) when the bundled launcher is missing', async () => { diff --git a/src/main/cli/linux-terminal-orca-cli-shim.ts b/src/main/cli/linux-terminal-orca-cli-shim.ts index 56f38df15ce..c793f9288a7 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.ts @@ -1,20 +1,26 @@ -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import { + hasAppImageRuntimeEnvironment, + resolveAppImageRuntimeIdentity +} from '../appimage-runtime-identity' +import { getAppImageCacheRootPath } from './appimage-extracted-root' +import { publishAppImageLauncherEndpoint } from './appimage-stable-launcher' +import { getBundledLauncherPath } from './bundled-cli-launcher-path' import { buildBareOrcaCliScript } from './linux-bare-orca-dispatcher' const SHIM_DIR_NAME = 'linux-orca-cli-shim' -// Why: rewriting the shim on every PTY spawn is wasted fs work; the target only -// changes with the install itself, so one successful write per process is enough. -// Failures are NOT cached so a transient fs error retries on the next spawn. -const ensuredShimDirs = new Map() - export type LinuxTerminalOrcaCliShimOptions = { userDataPath: string /** Test seam — defaults to the packaged resources root. */ resourcesPath?: string | null - /** Test seam — defaults to $APPIMAGE (set only when running from an AppImage). */ + /** Trusted caller override; production requires the complete AppImage runtime identity. */ appImagePath?: string | null + /** Test seam — defaults to $XDG_CACHE_HOME/orca/appimage. */ + appImageCacheRootPath?: string + /** Regression seam: opening a PTY must never invoke AppImage extraction. */ + appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise } // Why: on Linux the CLI installs as `orca-ide` so it never shadows the GNOME @@ -27,37 +33,62 @@ export type LinuxTerminalOrcaCliShimOptions = { export function ensureLinuxTerminalOrcaCliShimDir( options: LinuxTerminalOrcaCliShimOptions ): string | null { - const cached = ensuredShimDirs.get(options.userDataPath) - if (cached !== undefined) { - return cached + const resourcesPath = + options.resourcesPath === undefined ? process.resourcesPath : options.resourcesPath + const hasExplicitAppImagePath = Object.hasOwn(options, 'appImagePath') + const runtimeIdentity = resolveAppImageRuntimeIdentity({ resourcesPath }) + if (!hasExplicitAppImagePath && hasAppImageRuntimeEnvironment() && !runtimeIdentity) { + return null + } + const appImagePath = hasExplicitAppImagePath + ? (options.appImagePath ?? null) + : (runtimeIdentity?.appImagePath ?? null) + if (appImagePath) { + return ensureAppImageShim(options, resourcesPath) } - const resourcesPath = options.resourcesPath ?? process.resourcesPath if (!resourcesPath) { return null } - const resolved = buildBareOrcaCliScript( - resourcesPath, - options.appImagePath ?? process.env.APPIMAGE ?? null - ) - if (!resolved) { + const launcherPath = getBundledLauncherPath('linux', resourcesPath) + return launcherPath && existsSync(launcherPath) + ? ensureShimForLauncher(options.userDataPath, launcherPath) + : null +} + +function ensureAppImageShim( + options: LinuxTerminalOrcaCliShimOptions, + resourcesPath: string | null +): string | null { + if (!resourcesPath) { return null } + const liveLauncherPath = getBundledLauncherPath('linux', resourcesPath) + if (!liveLauncherPath || !existsSync(liveLauncherPath)) { + return null + } + const stableLauncherPath = publishAppImageLauncherEndpoint( + options.appImageCacheRootPath ?? getAppImageCacheRootPath(), + 'live', + liveLauncherPath + ) + return stableLauncherPath ? ensureShimForLauncher(options.userDataPath, stableLauncherPath) : null +} - const shimDir = join(options.userDataPath, SHIM_DIR_NAME) +function ensureShimForLauncher(userDataPath: string, launcherPath: string): string | null { + const script = buildBareOrcaCliScript(launcherPath) + + const shimDir = join(userDataPath, SHIM_DIR_NAME) const shimPath = join(shimDir, 'orca') try { - if (readShim(shimPath) !== resolved.script) { + if (readShim(shimPath) !== script) { mkdirSync(shimDir, { recursive: true }) - writeFileSync(shimPath, resolved.script, 'utf8') + writeFileSync(shimPath, script, 'utf8') } - // Why: always re-assert the exec bit — a shim written by an older run (or - // restored from backup) with mode stripped would fail every agent CLI call. chmodSync(shimPath, 0o755) } catch { return null } - ensuredShimDirs.set(options.userDataPath, shimDir) return shimDir } diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index 9128f945ef9..d5c17123ec1 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -5,7 +5,6 @@ import { tmpdir } from 'node:os' import { dirname, join, sep } from 'node:path' import { promisify } from 'node:util' import { describe, expect, it } from 'vitest' -import { buildAppImageCliWrapper } from './appimage-cli-wrapper' const require = createRequire(import.meta.url) const execFileAsync = promisify(execFile) @@ -246,55 +245,47 @@ printf 'arg=%s\\n' "$@" } ) - itRunsUnixShell('runs the AppImage CLI wrapper through APPDIR at runtime', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-')) + // Why: registration on every Linux install method now points at this one + // launcher, so its env sanitation and argv passthrough are the contract the + // AppImage, deb, and extracted-tree commands all depend on. + itRunsUnixShell('sanitizes node env and forwards argv verbatim', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-linux-cli-env-')) try { - const appDir = join(root, 'Orca.AppDir') - const cliDir = join(appDir, 'resources', 'app.asar.unpacked', 'out', 'cli') + const appDir = join(root, 'Orca') + const resourcesDir = join(appDir, 'resources') + const launcherDir = join(resourcesDir, 'bin') + const cliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') + const launcherPath = join(launcherDir, 'orca-ide') const cliPath = join(cliDir, 'index.js') - const appImagePath = join(root, "Orca's AppImage.AppImage") - const commandPath = join(root, 'orca-ide') + + await mkdir(launcherDir, { recursive: true }) await mkdir(cliDir, { recursive: true }) + await copyFile(linuxLauncherAsset, launcherPath) + await writeFile(cliPath, '', 'utf8') await writeFile( - cliPath, - `exports.main = (argv) => { - console.log(JSON.stringify({ - argv, - appDir: process.env.APPDIR, - runAsNode: process.env.ELECTRON_RUN_AS_NODE, - nodeOptions: process.env.NODE_OPTIONS ?? null, - orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null, - nodeReplExternalModule: process.env.NODE_REPL_EXTERNAL_MODULE ?? null, - orcaNodeReplExternalModule: process.env.ORCA_NODE_REPL_EXTERNAL_MODULE ?? null - })) -} -`, - 'utf8' - ) - await writeFile( - appImagePath, + join(appDir, 'orca-ide'), `#!/usr/bin/env bash -export APPDIR="$FAKE_APPDIR" -exec node "$@" +node -e 'console.log(JSON.stringify({ + argv: process.argv.slice(1), + runAsNode: process.env.ELECTRON_RUN_AS_NODE, + nodeOptions: process.env.NODE_OPTIONS ?? null, + orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null, + nodeReplExternalModule: process.env.NODE_REPL_EXTERNAL_MODULE ?? null, + orcaNodeReplExternalModule: process.env.ORCA_NODE_REPL_EXTERNAL_MODULE ?? null +}))' -- "$@" `, { encoding: 'utf8', mode: 0o755 } ) - await writeFile(commandPath, buildAppImageCliWrapper(appImagePath), { - encoding: 'utf8', - mode: 0o755 - }) - const result = await execFileAsync(commandPath, ['--help', 'two words'], { + const result = await execFileAsync(launcherPath, ['--help', 'two words'], { env: { ...process.env, - FAKE_APPDIR: appDir, NODE_OPTIONS: '--trace-warnings', NODE_REPL_EXTERNAL_MODULE: 'external-loader' } }) const payload = JSON.parse(result.stdout) as { argv: string[] - appDir: string runAsNode: string nodeOptions: string | null orcaNodeOptions: string | null @@ -302,9 +293,10 @@ exec node "$@" orcaNodeReplExternalModule: string | null } - expect(payload.argv).toEqual(['--help', 'two words']) - expect(payload.appDir).toBe(appDir) + expect(payload.argv).toEqual([cliPath, '--help', 'two words']) expect(payload.runAsNode).toBe('1') + // Why: Electron's node bootstrap must not inherit these, but the CLI + // still needs to see what the user set. expect(payload.nodeOptions).toBeNull() expect(payload.orcaNodeOptions).toBe('--trace-warnings') expect(payload.nodeReplExternalModule).toBeNull() diff --git a/src/main/ipc/cli-appimage-stale-registration.test.ts b/src/main/ipc/cli-appimage-stale-registration.test.ts new file mode 100644 index 00000000000..ca50d7a7f90 --- /dev/null +++ b/src/main/ipc/cli-appimage-stale-registration.test.ts @@ -0,0 +1,382 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CliInstallState, CliInstallStatus } from '../../shared/cli-install-types' + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + handle: vi.fn(), + hydrateShellPath: vi.fn(), + install: vi.fn(), + isAppImageRegistrationOwnedBySibling: vi.fn(), + mergePathSegments: vi.fn(), + remove: vi.fn(), + resolveAppImageCacheKey: vi.fn(), + resolveAppImageRuntimeIdentity: vi.fn() +})) + +vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) + +vi.mock('../cli/cli-installer', () => ({ + CliInstaller: class { + getStatus = mocks.getStatus + install = mocks.install + isAppImageRegistrationOwnedBySibling = mocks.isAppImageRegistrationOwnedBySibling + remove = mocks.remove + } +})) + +vi.mock('../appimage-runtime-identity', () => ({ + resolveAppImageRuntimeIdentity: mocks.resolveAppImageRuntimeIdentity +})) + +vi.mock('../cli/appimage-extracted-root', () => ({ + resolveAppImageCacheKey: mocks.resolveAppImageCacheKey +})) + +vi.mock('../cli/wsl-cli-installer', () => ({ + WslCliInstaller: class { + getStatus = mocks.getStatus + install = mocks.install + remove = mocks.remove + } +})) + +vi.mock('../cli/wsl-cli-registration-registry', () => ({ + recordWslCliRegistrationInstalled: vi.fn(), + recordWslCliRegistrationRemoved: vi.fn() +})) + +vi.mock('../cli/wsl-cli-registration-operation', () => ({ + runSerializedWslCliRegistrationOperation: vi.fn() +})) + +vi.mock('../persistence', () => ({ getCanonicalUserDataPath: vi.fn() })) + +vi.mock('../startup/hydrate-shell-path', () => ({ + hydrateShellPath: mocks.hydrateShellPath, + mergePathSegments: mocks.mergePathSegments +})) + +vi.mock('../wsl', () => ({ getDefaultWslDistro: vi.fn() })) + +import { registerCliHandlers } from './cli' + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function status( + state: CliInstallState, + launcherPath = '/cache/current/resources/bin/orca-ide' +): CliInstallStatus { + return { + platform: 'linux', + commandName: 'orca-ide', + commandPath: '/home/me/.local/bin/orca-ide', + pathDirectory: '/home/me/.local/bin', + pathConfigured: true, + launcherPath, + installMethod: 'symlink', + supported: true, + state, + currentTarget: state === 'not_installed' ? null : '/cache/old/resources/bin/orca-ide', + unsupportedReason: null, + detail: null + } +} + +function installStatusHandler(): () => Promise { + registerCliHandlers() + return cliHandler('cli:getInstallStatus') +} + +function cliHandler(channelName: string): () => Promise { + const call = mocks.handle.mock.calls.find(([channel]) => channel === channelName) + expect(call).toBeTruthy() + return call![1] +} + +beforeEach(() => { + mocks.getStatus.mockReset() + mocks.handle.mockReset() + mocks.hydrateShellPath.mockReset().mockResolvedValue({ ok: false }) + mocks.install.mockReset() + mocks.isAppImageRegistrationOwnedBySibling.mockReset().mockReturnValue(false) + mocks.mergePathSegments.mockReset() + mocks.remove.mockReset() + mocks.resolveAppImageCacheKey.mockReset().mockReturnValue('generation-1') + mocks.resolveAppImageRuntimeIdentity.mockReset().mockImplementation(() => + process.platform === 'linux' + ? { + appImagePath: '/opt/Orca.AppImage', + appDirPath: '/tmp/.mount_Orca123' + } + : null + ) + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + vi.stubEnv('APPIMAGE', '/opt/Orca.AppImage') +}) + +afterEach(() => { + vi.unstubAllEnvs() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + vi.restoreAllMocks() +}) + +describe('AppImage CLI registration startup repair', () => { + it('repairs a managed stale registration and returns the installed status', async () => { + const stale = status('stale') + const installed = status('installed') + mocks.getStatus.mockResolvedValue(stale) + mocks.install.mockResolvedValue(installed) + + await expect(installStatusHandler()()).resolves.toBe(installed) + expect(mocks.install).toHaveBeenCalledOnce() + }) + + it('keeps the stale status when automatic repair fails', async () => { + const stale = status('stale') + mocks.getStatus.mockResolvedValue(stale) + mocks.install.mockRejectedValue(new Error('read-only filesystem')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect(installStatusHandler()()).resolves.toBe(stale) + expect(warn).toHaveBeenCalledWith( + '[cli] Failed to repair stale AppImage registration:', + 'read-only filesystem' + ) + }) + + it('retries a failed automatic repair after the cooldown', async () => { + const stale = status('stale') + const installed = status('installed') + let now = 1_000 + mocks.getStatus.mockResolvedValue(stale) + mocks.install + .mockRejectedValueOnce(new Error('read-only filesystem')) + .mockResolvedValueOnce(installed) + vi.spyOn(Date, 'now').mockImplementation(() => now) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const handler = installStatusHandler() + + await expect(handler()).resolves.toBe(stale) + await expect(handler()).resolves.toBe(stale) + expect(mocks.install).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledOnce() + + now += 30_000 + await expect(handler()).resolves.toBe(installed) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('shares one automatic repair across concurrent status polls', async () => { + const stale = status('stale') + const installed = status('installed') + let finishRepair: (result: CliInstallStatus) => void = () => {} + const repair = new Promise((resolve) => { + finishRepair = resolve + }) + mocks.getStatus.mockResolvedValue(stale) + mocks.install.mockReturnValue(repair) + const handler = installStatusHandler() + + const first = handler() + const second = handler() + await vi.waitFor(() => expect(mocks.install).toHaveBeenCalledOnce()) + finishRepair(installed) + + await expect(Promise.all([first, second])).resolves.toEqual([installed, installed]) + }) + + it('repairs a later AppImage generation after an earlier repair succeeds', async () => { + const firstStale = status('stale', '/cache/launcher/orca-ide') + const firstInstalled = status('installed', '/cache/launcher/orca-ide') + const nextStale = status('stale', '/cache/launcher/orca-ide') + const nextInstalled = status('installed', '/cache/launcher/orca-ide') + mocks.getStatus + .mockResolvedValueOnce(firstStale) + .mockResolvedValueOnce(firstStale) + .mockResolvedValueOnce(nextStale) + .mockResolvedValueOnce(nextStale) + mocks.install.mockResolvedValueOnce(firstInstalled).mockResolvedValueOnce(nextInstalled) + mocks.resolveAppImageCacheKey + .mockReturnValueOnce('generation-1') + .mockReturnValueOnce('generation-1') + .mockReturnValueOnce('generation-2') + .mockReturnValueOnce('generation-2') + const handler = installStatusHandler() + + await expect(handler()).resolves.toBe(firstInstalled) + await expect(handler()).resolves.toBe(nextInstalled) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('waits for the cooldown before repairing a newer AppImage generation', async () => { + const firstStale = status('stale', '/cache/launcher/orca-ide') + const nextStale = status('stale', '/cache/launcher/orca-ide') + const nextInstalled = status('installed', '/cache/launcher/orca-ide') + let now = 1_000 + mocks.getStatus + .mockResolvedValueOnce(firstStale) + .mockResolvedValueOnce(firstStale) + .mockResolvedValueOnce(nextStale) + .mockResolvedValueOnce(nextStale) + .mockResolvedValueOnce(nextStale) + mocks.install + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce(nextInstalled) + mocks.resolveAppImageCacheKey + .mockReturnValueOnce('generation-1') + .mockReturnValueOnce('generation-1') + .mockReturnValueOnce('generation-2') + .mockReturnValueOnce('generation-2') + .mockReturnValueOnce('generation-2') + vi.spyOn(Date, 'now').mockImplementation(() => now) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const handler = installStatusHandler() + + await expect(handler()).resolves.toBe(firstStale) + await expect(handler()).resolves.toBe(nextStale) + expect(mocks.install).toHaveBeenCalledOnce() + + now += 30_000 + await expect(handler()).resolves.toBe(nextInstalled) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('keeps the explicit install action retryable after automatic repair fails', async () => { + const stale = status('stale') + const installed = status('installed') + mocks.getStatus.mockResolvedValue(stale) + mocks.install + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce(installed) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + registerCliHandlers() + + await expect(cliHandler('cli:getInstallStatus')()).resolves.toBe(stale) + await expect(cliHandler('cli:install')()).resolves.toBe(installed) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('serializes concurrent explicit installs', async () => { + const installed = status('installed') + let finishFirstInstall: (result: CliInstallStatus) => void = () => {} + const firstInstall = new Promise((resolve) => { + finishFirstInstall = resolve + }) + mocks.install.mockReturnValueOnce(firstInstall).mockResolvedValueOnce(installed) + registerCliHandlers() + const handler = cliHandler('cli:install') + + const first = handler() + const second = handler() + await vi.waitFor(() => expect(mocks.install).toHaveBeenCalledOnce()) + + finishFirstInstall(installed) + await expect(Promise.all([first, second])).resolves.toEqual([installed, installed]) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('does not undo a queued removal with a stale automatic repair', async () => { + const stale = status('stale') + const notInstalled = status('not_installed') + let finishRemove: (result: CliInstallStatus) => void = () => {} + const removal = new Promise((resolve) => { + finishRemove = resolve + }) + mocks.getStatus.mockResolvedValueOnce(stale).mockResolvedValueOnce(notInstalled) + mocks.remove.mockReturnValue(removal) + registerCliHandlers() + + const remove = cliHandler('cli:remove')() + await vi.waitFor(() => expect(mocks.remove).toHaveBeenCalledOnce()) + const poll = cliHandler('cli:getInstallStatus')() + finishRemove(notInstalled) + + await expect(remove).resolves.toBe(notInstalled) + await expect(poll).resolves.toBe(notInstalled) + expect(mocks.install).not.toHaveBeenCalled() + }) + + it('keys a queued repair cooldown to the generation it rechecks', async () => { + const stale = status('stale') + const notInstalled = status('not_installed') + let finishRemove: (result: CliInstallStatus) => void = () => {} + const removal = new Promise((resolve) => { + finishRemove = resolve + }) + mocks.getStatus.mockResolvedValue(stale) + mocks.remove.mockReturnValue(removal) + mocks.install.mockRejectedValue(new Error('temporary failure')) + mocks.resolveAppImageCacheKey + .mockReturnValueOnce('generation-1') + .mockReturnValue('generation-2') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + registerCliHandlers() + + const remove = cliHandler('cli:remove')() + await vi.waitFor(() => expect(mocks.remove).toHaveBeenCalledOnce()) + const oldGenerationPoll = cliHandler('cli:getInstallStatus')() + finishRemove(notInstalled) + + await expect(remove).resolves.toBe(notInstalled) + await expect(oldGenerationPoll).resolves.toBe(stale) + await expect(cliHandler('cli:getInstallStatus')()).resolves.toBe(stale) + await expect(cliHandler('cli:getInstallStatus')()).resolves.toBe(stale) + expect(mocks.install).toHaveBeenCalledOnce() + }) + + it.each(['conflict', 'not_installed'] as const)( + 'does not mutate a %s registration', + async (state) => { + const current = status(state) + mocks.getStatus.mockResolvedValue(current) + + await expect(installStatusHandler()()).resolves.toBe(current) + expect(mocks.install).not.toHaveBeenCalled() + } + ) + + it('does not mutate a stale non-AppImage registration', async () => { + const stale = status('stale') + mocks.getStatus.mockResolvedValue(stale) + mocks.resolveAppImageRuntimeIdentity.mockReturnValue(null) + + await expect(installStatusHandler()()).resolves.toBe(stale) + expect(mocks.install).not.toHaveBeenCalled() + }) + + it('does not claim a sibling AppImage registration during a status poll', async () => { + const stale = { + ...status('stale'), + currentTarget: '/cache/current/resources/bin/orca-ide' + } + mocks.getStatus.mockResolvedValue(stale) + mocks.isAppImageRegistrationOwnedBySibling.mockReturnValue(true) + + await expect(installStatusHandler()()).resolves.toBe(stale) + expect(mocks.install).not.toHaveBeenCalled() + }) + + it('migrates a legacy AppImage target even when a sibling owns the stable endpoint', async () => { + const stale = status('stale') + const installed = status('installed') + mocks.getStatus.mockResolvedValue(stale) + mocks.install.mockResolvedValue(installed) + mocks.isAppImageRegistrationOwnedBySibling.mockImplementation( + (current: CliInstallStatus) => current.currentTarget === current.launcherPath + ) + + await expect(installStatusHandler()()).resolves.toBe(installed) + expect(mocks.install).toHaveBeenCalledOnce() + }) + + it('does not mutate a stale registration off Linux', async () => { + const stale = status('stale') + mocks.getStatus.mockResolvedValue(stale) + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + + await expect(installStatusHandler()()).resolves.toBe(stale) + expect(mocks.install).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/cli.ts b/src/main/ipc/cli.ts index 659c69d9c02..050a00f05cf 100644 --- a/src/main/ipc/cli.ts +++ b/src/main/ipc/cli.ts @@ -1,6 +1,8 @@ import { ipcMain } from 'electron' import type { CliInstallStatus } from '../../shared/cli-install-types' +import { resolveAppImageCacheKey } from '../cli/appimage-extracted-root' import { CliInstaller } from '../cli/cli-installer' +import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue' import { recordWslCliRegistrationInstalled, recordWslCliRegistrationRemoved @@ -10,6 +12,31 @@ import { runSerializedWslCliRegistrationOperation } from '../cli/wsl-cli-registr import { getCanonicalUserDataPath } from '../persistence' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' import { getDefaultWslDistro } from '../wsl' +import { resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity' + +const APPIMAGE_REPAIR_RETRY_MS = 30_000 +const localCliRegistrationQueues = new Map>() + +function runLocalCliRegistrationOperation(operation: () => Promise): Promise { + return runKeyedSerializedOperation(localCliRegistrationQueues, 'local', operation) +} + +function resolveStaleAppImageRepairKey(status: CliInstallStatus): string | null { + if (status.state !== 'stale') { + return null + } + const runtimeIdentity = resolveAppImageRuntimeIdentity() + if (!runtimeIdentity) { + return null + } + const cacheKey = resolveAppImageCacheKey(runtimeIdentity.appImagePath) + if (!cacheKey) { + return null + } + return [status.commandPath, status.launcherPath, runtimeIdentity.appImagePath, cacheKey].join( + '\0' + ) +} function normalizeWslCliDistro(args?: { distro?: string | null }): string | undefined { return args?.distro?.trim() || undefined @@ -57,19 +84,57 @@ async function hydrateLocalShellPathForCli(force = false): Promise { } export function registerCliHandlers(): void { + let staleAppImageRepairAttempt: { + promise: Promise + retryAfter: number + } | null = null + ipcMain.handle('cli:getInstallStatus', async (): Promise => { await hydrateLocalShellPathForCli() - return new CliInstaller().getStatus() + const installer = new CliInstaller() + const status = await installer.getStatus() + // Why: an AppImage update replaces the outer file while the managed symlink still targets the prior extracted payload. + const repairKey = resolveStaleAppImageRepairKey(status) + if (!repairKey || installer.isAppImageRegistrationOwnedBySibling(status)) { + return status + } + + if (!staleAppImageRepairAttempt || Date.now() >= staleAppImageRepairAttempt.retryAfter) { + const promise = runLocalCliRegistrationOperation(async () => { + const currentInstaller = new CliInstaller() + const currentStatus = await currentInstaller.getStatus() + return resolveStaleAppImageRepairKey(currentStatus) === repairKey && + !currentInstaller.isAppImageRegistrationOwnedBySibling(currentStatus) + ? currentInstaller.install() + : currentStatus + }).catch((error) => { + console.warn( + '[cli] Failed to repair stale AppImage registration:', + error instanceof Error ? error.message : String(error) + ) + return null + }) + staleAppImageRepairAttempt = { promise, retryAfter: Number.POSITIVE_INFINITY } + void promise.then((result) => { + if (staleAppImageRepairAttempt?.promise !== promise) { + return + } + staleAppImageRepairAttempt = result + ? null + : { ...staleAppImageRepairAttempt, retryAfter: Date.now() + APPIMAGE_REPAIR_RETRY_MS } + }) + } + return (await staleAppImageRepairAttempt.promise) ?? status }) ipcMain.handle('cli:install', async (): Promise => { await hydrateLocalShellPathForCli(true) - return new CliInstaller().install() + return runLocalCliRegistrationOperation(() => new CliInstaller().install()) }) ipcMain.handle('cli:remove', async (): Promise => { await hydrateLocalShellPathForCli() - return new CliInstaller().remove() + return runLocalCliRegistrationOperation(() => new CliInstaller().remove()) }) ipcMain.handle( diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index bf3ec10dc27..b411febfa5d 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -1,4 +1,3 @@ -import { join } from 'node:path' import { vi } from 'vitest' import type { Mock } from 'vitest' import type * as Wsl from '../wsl' @@ -17,6 +16,7 @@ export const mkdirSyncMock: Mock = vi.fn() export const readFileSyncMock: Mock = vi.fn() export const writeFileSyncMock: Mock = vi.fn() export const chmodSyncMock: Mock = vi.fn() +export const linuxCliShimMock: Mock = vi.fn() export const renameSyncMock: Mock = vi.fn() export const rmSyncMock: Mock = vi.fn() export const getPathMock: Mock = vi.fn() @@ -152,8 +152,7 @@ export const classifyErrorModuleMock = () => ({ // Why: the real ensure writes to process.resourcesPath (absent under vitest); env assembly only needs the returned dir path. export const linuxCliShimModuleMock = () => ({ - ensureLinuxTerminalOrcaCliShimDir: (options: { userDataPath: string }) => - join(options.userDataPath, 'linux-orca-cli-shim') + ensureLinuxTerminalOrcaCliShimDir: linuxCliShimMock }) export const ptyRegistryModuleMock = () => ({ diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index 95c7dfe3eea..d372ecb734b 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, vi } from 'vitest' import * as electron from 'electron' +import { join } from 'node:path' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { setPtyHostBindings } from './pty-host-bindings' import { testPtyIpcSurface } from './pty-ipc-test-surface' @@ -16,6 +17,7 @@ import { readFileSyncMock, writeFileSyncMock, chmodSyncMock, + linuxCliShimMock, getPathMock, loginPreflightExecFileMock, spawnMock, @@ -139,6 +141,10 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { readFileSyncMock.mockReset() writeFileSyncMock.mockReset() chmodSyncMock.mockReset() + linuxCliShimMock.mockReset() + linuxCliShimMock.mockImplementation((options: { userDataPath: string }) => + join(options.userDataPath, 'linux-orca-cli-shim') + ) getPathMock.mockReset() loginPreflightExecFileMock.mockReset() spawnMock.mockReset() diff --git a/src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts b/src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts new file mode 100644 index 00000000000..d42103dc518 --- /dev/null +++ b/src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { linuxCliShimMock } from './pty-ipc-mock-registry' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +import { registerPtyHandlers } from './pty' + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + +afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) +}) + +describe('restored AppImage CLI shim refresh', () => { + const { mainWindow } = setupPtyIpcSuite() + + it('refreshes the live launcher before any restored pane reattaches', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + + registerPtyHandlers(mainWindow as never) + + expect(linuxCliShimMock).toHaveBeenCalledOnce() + expect(linuxCliShimMock).toHaveBeenCalledWith({ userDataPath: '/tmp/orca-user-data' }) + }) + + it('does not publish a host launcher on a non-Linux host', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + registerPtyHandlers(mainWindow as never) + + expect(linuxCliShimMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 9c46c942383..9a6230ad82b 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -1,4 +1,5 @@ import type { BrowserWindow } from 'electron' +import { getAppEnvironment } from '../../../shared/app-environment' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' import type { GlobalSettings } from '../../../shared/global-settings-types' @@ -58,6 +59,7 @@ import { resolveCodexResumeLaunch, stripSequencedStartupResumeArgv } from './host-env/codex-resume' +import { ensureLinuxTerminalOrcaCliShimDir } from '../../cli/linux-terminal-orca-cli-shim' export function registerPtyHandlers( mainWindow: BrowserWindow, @@ -68,6 +70,12 @@ export function registerPtyHandlers( store?: Store, options?: PtyIpcSessionOptions ): void { + if (process.platform === 'linux') { + const appEnvironment = getAppEnvironment() + if (appEnvironment.isPackaged()) { + ensureLinuxTerminalOrcaCliShimDir({ userDataPath: appEnvironment.getPath('userData') }) + } + } const ipcMain = getPtyIpc() // Why first: the outgoing session owns the producer pauses, so its real reset must run // before the bridge is neutralized or a PTY paused during re-registration stays paused. From 4c24a28df021ad8713dc9bf31e038bb89c2fa7c9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:56:51 -0700 Subject: [PATCH 02/94] refactor(linux): trim AppImage CLI registration seams --- src/main/appimage-runtime-identity.test.ts | 3 +- src/main/appimage-runtime-identity.ts | 3 +- src/main/cli/cli-command-installation.ts | 32 ++++++++++++------- .../cli/legacy-appimage-cli-wrapper.test.ts | 21 ++++++++++++ src/main/cli/legacy-appimage-cli-wrapper.ts | 2 +- .../cli/linux-terminal-orca-cli-shim.test.ts | 17 ++-------- src/main/cli/linux-terminal-orca-cli-shim.ts | 2 -- .../cli-appimage-stale-registration.test.ts | 3 +- 8 files changed, 48 insertions(+), 35 deletions(-) create mode 100644 src/main/cli/legacy-appimage-cli-wrapper.test.ts diff --git a/src/main/appimage-runtime-identity.test.ts b/src/main/appimage-runtime-identity.test.ts index fe7d44dd5f5..903d67827f9 100644 --- a/src/main/appimage-runtime-identity.test.ts +++ b/src/main/appimage-runtime-identity.test.ts @@ -73,8 +73,7 @@ describe.skipIf(process.platform === 'win32')('resolveAppImageRuntimeIdentity', ])('accepts a complete %s AppImage runtime', (_architecture, machine, appDirName) => { const fixture = createFixture(appDirName, machine) expect(resolveAppImageRuntimeIdentity(fixture.identity)).toEqual({ - appImagePath: fixture.appImagePath, - appDirPath: fixture.appDirPath + appImagePath: fixture.appImagePath }) }) diff --git a/src/main/appimage-runtime-identity.ts b/src/main/appimage-runtime-identity.ts index 810be5f042a..860d6c83a63 100644 --- a/src/main/appimage-runtime-identity.ts +++ b/src/main/appimage-runtime-identity.ts @@ -16,7 +16,6 @@ const PACKAGE_TYPE_MARKER_MAX_BYTES = 32 export type AppImageRuntimeIdentity = { appImagePath: string - appDirPath: string } export type AppImageRuntimeIdentityInput = { @@ -194,5 +193,5 @@ export function resolveAppImageRuntimeIdentity( return null } - return { appImagePath, appDirPath: runtimeRoot } + return { appImagePath } } diff --git a/src/main/cli/cli-command-installation.ts b/src/main/cli/cli-command-installation.ts index 282fda4aa22..24798f35266 100644 --- a/src/main/cli/cli-command-installation.ts +++ b/src/main/cli/cli-command-installation.ts @@ -1,5 +1,5 @@ import { link, readlink, rmdir, symlink, unlink, writeFile } from 'node:fs/promises' -import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import type { CliInstallStatus } from '../../shared/cli-install-types' import { ensureAppImageExtractedRoot, @@ -21,6 +21,7 @@ import { import { DEV_LAUNCHER_DIR, LEGACY_LINUX_COMMAND_NAME } from './cli-install-constants' import { buildWindowsForwarder } from './cli-dev-launcher' import { isMissingError, isPermissionError } from './cli-install-errors' +import { isPathInsideOrEqual } from './cli-install-path-format' const STABLE_LEGACY_INSPECTION_ATTEMPTS = 3 @@ -101,16 +102,24 @@ export class CliCommandInstallation extends CliCommandInspection { } const commandPath = join(this.homePath, '.local', 'bin', LEGACY_LINUX_COMMAND_NAME) - const inspected = await this.inspectStableLegacyCommand(commandPath, launcherPath) - if (!inspected?.managed) { - return + try { + const inspected = await this.inspectStableLegacyCommand(commandPath, launcherPath) + if (!inspected?.managed) { + return + } + const quarantine = await this.quarantineCommandPath(commandPath) + if (!(await capturedExpectedEntry(quarantine, inspected))) { + await this.restoreQuarantinedCommand(quarantine, commandPath) + return + } + await this.discardQuarantinedCommand(quarantine) + } catch (error) { + // Why: the new command is already registered; leave legacy cleanup for a later attempt. + console.warn( + `[cli] Could not remove the legacy command at ${commandPath}:`, + error instanceof Error ? error.message : String(error) + ) } - const quarantine = await this.quarantineCommandPath(commandPath) - if (!(await capturedExpectedEntry(quarantine, inspected))) { - await this.restoreQuarantinedCommand(quarantine, commandPath) - return - } - await this.discardQuarantinedCommand(quarantine) } protected async quarantineCommandPath(commandPath: string): Promise { @@ -132,8 +141,7 @@ export class CliCommandInstallation extends CliCommandInspection { } const devLauncherDir = resolve(this.userDataPath, ...DEV_LAUNCHER_DIR) - const devRelative = relative(devLauncherDir, resolvedTarget) - if (devRelative && !devRelative.startsWith('..') && !isAbsolute(devRelative)) { + if (isPathInsideOrEqual(devLauncherDir, resolvedTarget)) { return true } diff --git a/src/main/cli/legacy-appimage-cli-wrapper.test.ts b/src/main/cli/legacy-appimage-cli-wrapper.test.ts new file mode 100644 index 00000000000..5c4a64f8f3a --- /dev/null +++ b/src/main/cli/legacy-appimage-cli-wrapper.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { + buildLegacyAppImageCliWrapper, + extractLegacyAppImageCliWrapperTarget +} from './legacy-appimage-cli-wrapper' + +describe('legacy AppImage CLI wrapper', () => { + it('recovers a path containing a newline', () => { + const appImagePath = "/tmp/Orca\nnightly's.AppImage" + expect(extractLegacyAppImageCliWrapperTarget(buildLegacyAppImageCliWrapper(appImagePath))).toBe( + appImagePath + ) + }) + + it('rejects a wrapper with a changed command body', () => { + const wrapper = buildLegacyAppImageCliWrapper('/tmp/Orca.AppImage') + expect( + extractLegacyAppImageCliWrapperTarget(wrapper.replace('set -euo pipefail', 'set -u')) + ).toBe(null) + }) +}) diff --git a/src/main/cli/legacy-appimage-cli-wrapper.ts b/src/main/cli/legacy-appimage-cli-wrapper.ts index 4ac604505f8..1e22b2dc577 100644 --- a/src/main/cli/legacy-appimage-cli-wrapper.ts +++ b/src/main/cli/legacy-appimage-cli-wrapper.ts @@ -15,7 +15,7 @@ const APPIMAGE_CLI_SCRIPT = [ ].join('') export function extractLegacyAppImageCliWrapperTarget(content: string): string | null { - const assignment = /^APPIMAGE=(.+)$/mu.exec(content)?.[1] + const assignment = /^APPIMAGE=([\s\S]+?)\nif \[ ! -f "\$APPIMAGE" \]; then/mu.exec(content)?.[1] const appImagePath = assignment ? unquoteShell(assignment) : null return appImagePath && content === buildLegacyAppImageCliWrapper(appImagePath) ? appImagePath diff --git a/src/main/cli/linux-terminal-orca-cli-shim.test.ts b/src/main/cli/linux-terminal-orca-cli-shim.test.ts index 03f92b38960..ded34e1d0a3 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.test.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.test.ts @@ -89,14 +89,11 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { const liveLauncherPath = join(resourcesPath, 'bin', 'orca-ide') writeFileSync(liveLauncherPath, '#!/usr/bin/env bash\nprintf live', 'utf8') chmodSync(liveLauncherPath, 0o755) - const extract = vi.fn() - const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath, resourcesPath, appImagePath, - appImageCacheRootPath: cacheRootPath, - appImageExtractRunner: extract + appImageCacheRootPath: cacheRootPath }) const shimPath = join(shimDir!, 'orca') @@ -108,7 +105,6 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { await expect( runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) ).resolves.toMatchObject({ code: 0, stdout: 'live' }) - expect(extract).not.toHaveBeenCalled() }) it('updates restored terminals to the current AppImage mount without rewriting the shim', async () => { @@ -120,13 +116,11 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { const firstLauncher = join(resourcesPath, 'bin', 'orca-ide') writeFileSync(firstLauncher, '#!/usr/bin/env bash\nprintf first', 'utf8') chmodSync(firstLauncher, 0o755) - const extract = vi.fn() const options = { userDataPath, resourcesPath, appImagePath, - appImageCacheRootPath: cacheRootPath, - appImageExtractRunner: extract + appImageCacheRootPath: cacheRootPath } const shimDir = ensureLinuxTerminalOrcaCliShimDir(options) const shimPath = join(shimDir!, 'orca') @@ -148,7 +142,6 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { await expect( runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) ).resolves.toMatchObject({ code: 0, stdout: 'next' }) - expect(extract).not.toHaveBeenCalled() }) it('waits briefly for a temporarily unavailable live endpoint', async () => { @@ -159,14 +152,11 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) const liveLauncher = join(resourcesPath, 'bin', 'orca-ide') chmodSync(liveLauncher, 0o755) - const extract = vi.fn() - const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath, resourcesPath, appImagePath, - appImageCacheRootPath: cacheRootPath, - appImageExtractRunner: extract + appImageCacheRootPath: cacheRootPath }) const shimPath = join(shimDir!, 'orca') await rm(liveLauncher) @@ -176,7 +166,6 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { }, 100) await expect(invocation).resolves.toMatchObject({ code: 0, stdout: 'recovered' }) - expect(extract).not.toHaveBeenCalled() }) it('returns null (and does not memoize) when the bundled launcher is missing', async () => { diff --git a/src/main/cli/linux-terminal-orca-cli-shim.ts b/src/main/cli/linux-terminal-orca-cli-shim.ts index c793f9288a7..cef4844450d 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.ts @@ -19,8 +19,6 @@ export type LinuxTerminalOrcaCliShimOptions = { appImagePath?: string | null /** Test seam — defaults to $XDG_CACHE_HOME/orca/appimage. */ appImageCacheRootPath?: string - /** Regression seam: opening a PTY must never invoke AppImage extraction. */ - appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise } // Why: on Linux the CLI installs as `orca-ide` so it never shadows the GNOME diff --git a/src/main/ipc/cli-appimage-stale-registration.test.ts b/src/main/ipc/cli-appimage-stale-registration.test.ts index ca50d7a7f90..927c4e4e1b9 100644 --- a/src/main/ipc/cli-appimage-stale-registration.test.ts +++ b/src/main/ipc/cli-appimage-stale-registration.test.ts @@ -105,8 +105,7 @@ beforeEach(() => { mocks.resolveAppImageRuntimeIdentity.mockReset().mockImplementation(() => process.platform === 'linux' ? { - appImagePath: '/opt/Orca.AppImage', - appDirPath: '/tmp/.mount_Orca123' + appImagePath: '/opt/Orca.AppImage' } : null ) From 0079fe2fa81e194e044db737480d6943df99e344 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:15:26 -0700 Subject: [PATCH 03/94] test(cli): assert registration lock serialization --- src/main/cli/cli-installer-appimage-removal.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/cli/cli-installer-appimage-removal.test.ts b/src/main/cli/cli-installer-appimage-removal.test.ts index eea1e1d4ab1..60927ecb6fd 100644 --- a/src/main/cli/cli-installer-appimage-removal.test.ts +++ b/src/main/cli/cli-installer-appimage-removal.test.ts @@ -132,8 +132,12 @@ describe.skipIf(process.platform === 'win32')('AppImage CLI removal', () => { } } let siblingStatusReads = 0 + let rejectSiblingStatusRead = false class TrackingInstaller extends CliInstaller { override async getStatus() { + if (rejectSiblingStatusRead) { + throw new Error('sibling status read before registration lock release') + } siblingStatusReads += 1 return super.getStatus() } @@ -172,11 +176,11 @@ describe.skipIf(process.platform === 'win32')('AppImage CLI removal', () => { let siblingInstall: Promise | null = null siblingStatusReads = 0 firstInstaller.afterNextStatus = async () => { + rejectSiblingStatusRead = true siblingInstall = secondInstaller.install() - await Promise.resolve() - expect(siblingStatusReads).toBe(0) } await firstInstaller.remove() + rejectSiblingStatusRead = false expect(siblingInstall).not.toBeNull() await siblingInstall From d19a8cdf864a16d9c37816ba655200f6dc1ddadd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:17:59 -0700 Subject: [PATCH 04/94] fix(linux): fence AppImage terminal shim mounts --- src/main/cli/appimage-extraction-pruning.ts | 6 +- src/main/cli/appimage-stable-launcher.test.ts | 44 ++-- src/main/cli/appimage-stable-launcher.ts | 31 ++- .../cli-installer-appimage-removal.test.ts | 16 +- .../cli/linux-terminal-orca-cli-shim.test.ts | 208 ++++++++++-------- src/main/cli/linux-terminal-orca-cli-shim.ts | 162 +++++++++++++- 6 files changed, 327 insertions(+), 140 deletions(-) diff --git a/src/main/cli/appimage-extraction-pruning.ts b/src/main/cli/appimage-extraction-pruning.ts index be88301d51b..d89236f4591 100644 --- a/src/main/cli/appimage-extraction-pruning.ts +++ b/src/main/cli/appimage-extraction-pruning.ts @@ -4,7 +4,10 @@ import type { Dirent } from 'node:fs' import { lstat, readlink, readdir, rename, rm, rmdir, symlink, unlink } from 'node:fs/promises' import { basename, dirname, join, resolve } from 'node:path' import { isAppImageCacheKey, resolveCachedAppImagePayloadRoot } from './appimage-cache-layout' -import { resolveAppImageLauncherEndpointPath } from './appimage-stable-launcher' +import { + removeAppImageLegacyLiveEndpoint, + resolveAppImageLauncherEndpointPath +} from './appimage-stable-launcher' const EXTRACTION_STAGING_PREFIX = '.extract-' const ACTIVE_EXTRACTION_PREFIX = '.active-' @@ -39,6 +42,7 @@ export async function pruneAppImageExtractedRoots(keepRootPath: string): Promise export async function removeAppImageInstalledPayloads(namespacePath: string): Promise { const resolvedNamespace = resolve(namespacePath) const cacheRootPath = dirname(resolvedNamespace) + removeAppImageLegacyLiveEndpoint(cacheRootPath) if (await removeInstalledEndpoint(cacheRootPath, resolvedNamespace)) { await pruneNamespace(resolvedNamespace, new Set()) await rmdir(resolvedNamespace).catch(() => {}) diff --git a/src/main/cli/appimage-stable-launcher.test.ts b/src/main/cli/appimage-stable-launcher.test.ts index a009aee5611..b7830443b4b 100644 --- a/src/main/cli/appimage-stable-launcher.test.ts +++ b/src/main/cli/appimage-stable-launcher.test.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs' import type * as NodeFs from 'node:fs' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -63,36 +63,30 @@ async function writeLauncher(path: string, output: string): Promise { await writeFile(path, `#!/usr/bin/env bash\nprintf '${output}'`, { mode: 0o755 }) } -describe('AppImage stable launcher', () => { - it('prefers the live mount and falls back to the installed payload', async () => { +describe.skipIf(process.platform === 'win32')('AppImage stable launcher', () => { + it('uses only the installed payload and ignores a legacy live endpoint', async () => { const cacheRootPath = await makeFixture() const livePath = join(cacheRootPath, 'payloads', 'live') const installedPath = join(cacheRootPath, 'payloads', 'installed') await Promise.all([writeLauncher(livePath, 'live'), writeLauncher(installedPath, 'installed')]) - expect( - publishAppImageLauncherEndpoint(cacheRootPath, 'installed', installedPath) - ).not.toBeNull() - const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'live', livePath)! - await expect( - runProcess({ program: launcherPath, args: [], timeoutMs: 3_000 }) - ).resolves.toMatchObject({ code: 0, stdout: 'live' }) - - await rm(livePath) + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', installedPath)! + await symlink(livePath, resolveAppImageLauncherEndpointPath(cacheRootPath, 'live')) await expect( runProcess({ program: launcherPath, args: [], timeoutMs: 3_000 }) ).resolves.toMatchObject({ code: 0, stdout: 'installed' }) + expect(await readFile(launcherPath, 'utf8')).not.toContain('/live') }) it('observes an endpoint published after the wrapper starts', async () => { const cacheRootPath = await makeFixture() const missingPath = join(cacheRootPath, 'payloads', 'missing') const readyPath = join(cacheRootPath, 'payloads', 'ready') - const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'live', missingPath)! + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', missingPath)! const invocation = runProcess({ program: launcherPath, args: [], timeoutMs: 3_000 }) setTimeout(() => { void writeLauncher(readyPath, 'ready').then(() => { - publishAppImageLauncherEndpoint(cacheRootPath, 'live', readyPath) + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', readyPath) }) }, 100) @@ -103,11 +97,13 @@ describe('AppImage stable launcher', () => { const cacheRootPath = await makeFixture() const targetPath = join(cacheRootPath, 'payloads', 'target') await writeLauncher(targetPath, 'target') - const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'live', targetPath)! + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)! const marker = (await readFile(launcherPath, 'utf8')).split('\n')[1] await writeFile(launcherPath, `#!/usr/bin/env bash\n${marker}\nprintf stale`, { mode: 0o755 }) - expect(publishAppImageLauncherEndpoint(cacheRootPath, 'live', targetPath)).toBe(launcherPath) + expect(publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)).toBe( + launcherPath + ) expect(await readFile(launcherPath, 'utf8')).toContain('launcher_dir=') await expect( runProcess({ program: launcherPath, args: [], timeoutMs: 3_000 }) @@ -120,7 +116,7 @@ describe('AppImage stable launcher', () => { await writeLauncher(targetPath, 'target') filePublicationFailures.link = 1 - const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'live', targetPath) + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath) expect(launcherPath).toBe(resolveAppImageStableLauncherPath(cacheRootPath)) await expect(readFile(launcherPath!, 'utf8')).resolves.toContain('launcher_dir=') @@ -130,14 +126,14 @@ describe('AppImage stable launcher', () => { const cacheRootPath = await makeFixture() const targetPath = join(cacheRootPath, 'payloads', 'target') await writeLauncher(targetPath, 'target') - const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'live', targetPath)! + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)! const marker = (await readFile(launcherPath, 'utf8')).split('\n')[1] const staleContent = `#!/usr/bin/env bash\n${marker}\nprintf stale` await writeFile(launcherPath, staleContent, { mode: 0o755 }) filePublicationFailures.link = 2 filePublicationFailures.copy = 2 - expect(publishAppImageLauncherEndpoint(cacheRootPath, 'live', targetPath)).toBeNull() + expect(publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)).toBeNull() await expect(readFile(launcherPath, 'utf8')).resolves.toBe(staleContent) }) @@ -145,26 +141,26 @@ describe('AppImage stable launcher', () => { const cacheRootPath = await makeFixture() const targetPath = join(cacheRootPath, 'payloads', 'target') await writeLauncher(targetPath, 'target') - const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'live', targetPath)! + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)! const marker = (await readFile(launcherPath, 'utf8')).split('\n')[1] await writeFile(launcherPath, `#!/usr/bin/env bash\n${marker}\nprintf stale`, { mode: 0o755 }) const foreignContent = '#!/usr/bin/env bash\nprintf foreign' filePublicationFailures.replaceBeforeRename = foreignContent filePublicationFailures.link = 2 - expect(publishAppImageLauncherEndpoint(cacheRootPath, 'live', targetPath)).toBeNull() + expect(publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)).toBeNull() await expect(readFile(launcherPath, 'utf8')).resolves.toBe(foreignContent) }) it('preserves a foreign launcher and declines endpoint publication', async () => { const cacheRootPath = await makeFixture() const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) - const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'live') + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') await mkdir(dirname(launcherPath), { recursive: true }) await writeFile(launcherPath, '#!/usr/bin/env bash\nprintf foreign', { mode: 0o755 }) expect( - publishAppImageLauncherEndpoint(cacheRootPath, 'live', join(cacheRootPath, 'target')) + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', join(cacheRootPath, 'target')) ).toBeNull() await expect(readFile(launcherPath, 'utf8')).resolves.toContain('foreign') expect(existsSync(endpointPath)).toBe(false) @@ -178,7 +174,7 @@ describe('AppImage stable launcher', () => { await writeFile(launcherPath, content, { mode: 0o755 }) expect( - publishAppImageLauncherEndpoint(cacheRootPath, 'live', join(cacheRootPath, 'target')) + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', join(cacheRootPath, 'target')) ).toBeNull() await expect(readFile(launcherPath, 'utf8')).resolves.toBe(content) }) diff --git a/src/main/cli/appimage-stable-launcher.ts b/src/main/cli/appimage-stable-launcher.ts index 880869ef513..9494479abd8 100644 --- a/src/main/cli/appimage-stable-launcher.ts +++ b/src/main/cli/appimage-stable-launcher.ts @@ -5,6 +5,7 @@ import { copyFileSync, fstatSync, linkSync, + lstatSync, mkdirSync, openSync, readSync, @@ -45,12 +46,27 @@ export function isAppImageStableLauncherReady(cacheRootPath: string): boolean { return isExactExecutableLauncher(launcherPath, buildStableLauncherScript(cacheRootPath)) } +/** Ensures the persistent wrapper exists without publishing an endpoint. */ +export function ensureAppImageStableLauncher(cacheRootPath: string): string | null { + return ensureAppImageStableLauncherFile(cacheRootPath) +} + +/** Removes the legacy live endpoint only when it is a symlink. */ +export function removeAppImageLegacyLiveEndpoint(cacheRootPath: string): void { + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'live') + try { + if (lstatSync(endpointPath).isSymbolicLink()) { + unlinkSync(endpointPath) + } + } catch {} +} + export function publishAppImageLauncherEndpoint( cacheRootPath: string, - endpoint: AppImageLauncherEndpoint, + endpoint: 'installed', targetPath: string ): string | null { - const launcherPath = ensureAppImageStableLauncher(cacheRootPath) + const launcherPath = ensureAppImageStableLauncherFile(cacheRootPath) if (!launcherPath) { return null } @@ -70,7 +86,7 @@ export function publishAppImageLauncherEndpoint( } } -function ensureAppImageStableLauncher(cacheRootPath: string): string | null { +function ensureAppImageStableLauncherFile(cacheRootPath: string): string | null { const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) const content = buildStableLauncherScript(cacheRootPath) try { @@ -213,11 +229,10 @@ shopt -s execfail launcher_dir=${quoteShell(launcherDirectory)} deadline=$((SECONDS + ${LAUNCHER_WAIT_SECONDS})) while (( SECONDS <= deadline )); do - for launcher in "$launcher_dir/${LIVE_ENDPOINT_NAME}" "$launcher_dir/${INSTALLED_ENDPOINT_NAME}"; do - if [[ -f "$launcher" && -x "$launcher" ]]; then - exec "$launcher" "$@" - fi - done + launcher="$launcher_dir/${INSTALLED_ENDPOINT_NAME}" + if [[ -f "$launcher" && -x "$launcher" ]]; then + exec "$launcher" "$@" + fi sleep 0.1 done printf 'Orca CLI is not ready; reopen Orca or register the CLI again.\\n' >&2 diff --git a/src/main/cli/cli-installer-appimage-removal.test.ts b/src/main/cli/cli-installer-appimage-removal.test.ts index 60927ecb6fd..9483141184c 100644 --- a/src/main/cli/cli-installer-appimage-removal.test.ts +++ b/src/main/cli/cli-installer-appimage-removal.test.ts @@ -1,9 +1,8 @@ import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readlink, rm, unlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readlink, rm, symlink, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' vi.mock('electron', () => ({ app: { isPackaged: false, getPath: () => tmpdir(), getAppPath: () => tmpdir() } @@ -26,7 +25,7 @@ afterEach(async () => { describe.skipIf(process.platform === 'win32')('AppImage CLI removal', () => { it.each([false, true])( - 'removes installed payloads while preserving the live PTY launcher (command missing: %s)', + 'removes installed payloads and the legacy live endpoint (command missing: %s)', async (removeCommandFirst) => { const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-remove-')) created.push(root) @@ -38,7 +37,9 @@ describe.skipIf(process.platform === 'win32')('AppImage CLI removal', () => { await mkdir(join(resourcesPath, 'bin'), { recursive: true }) await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) await writeFile(liveLauncherPath, '#!/usr/bin/env bash\nprintf live', { mode: 0o755 }) - publishAppImageLauncherEndpoint(cacheRootPath, 'live', liveLauncherPath) + const liveEndpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'live') + await mkdir(dirname(liveEndpointPath), { recursive: true }) + await symlink(liveLauncherPath, liveEndpointPath) const installer = new CliInstaller({ platform: 'linux', @@ -80,12 +81,9 @@ describe.skipIf(process.platform === 'win32')('AppImage CLI removal', () => { expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed'))).toBe( false ) - expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(true) + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(false) const stableLauncherPath = resolveAppImageStableLauncherPath(cacheRootPath) expect(existsSync(stableLauncherPath)).toBe(true) - await expect( - runProcess({ program: stableLauncherPath, args: [], timeoutMs: 3_000 }) - ).resolves.toMatchObject({ code: 0, stdout: 'live' }) } ) diff --git a/src/main/cli/linux-terminal-orca-cli-shim.test.ts b/src/main/cli/linux-terminal-orca-cli-shim.test.ts index ded34e1d0a3..164271bd7f2 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.test.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdirSync, readFileSync, readlinkSync, statSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -9,13 +9,11 @@ vi.mock('electron', () => ({ app: { isPackaged: true } })) -import { - resolveAppImageLauncherEndpointPath, - resolveAppImageStableLauncherPath -} from './appimage-stable-launcher' +import { resolveAppImageLauncherEndpointPath } from './appimage-stable-launcher' import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim' const created: string[] = [] +const canFenceAppImageRuntime = process.platform === 'linux' && existsSync('/proc/self/stat') async function makeFixture(): Promise<{ userDataPath: string; resourcesPath: string }> { const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-')) @@ -80,93 +78,131 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { expect(statSync(healedPath).mode & 0o111).not.toBe(0) }) - it('routes AppImage terminals through the stable cache without extracting', async () => { - const { userDataPath, resourcesPath } = await makeFixture() - const appImagePath = join(userDataPath, 'Orca.AppImage') - await mkdir(userDataPath, { recursive: true }) - await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) - const cacheRootPath = join(userDataPath, 'cache') - const liveLauncherPath = join(resourcesPath, 'bin', 'orca-ide') - writeFileSync(liveLauncherPath, '#!/usr/bin/env bash\nprintf live', 'utf8') - chmodSync(liveLauncherPath, 0o755) - const shimDir = ensureLinuxTerminalOrcaCliShimDir({ - userDataPath, - resourcesPath, - appImagePath, - appImageCacheRootPath: cacheRootPath - }) + it.skipIf(!canFenceAppImageRuntime)( + 'routes first-use AppImage terminals through a fenced current mount without a live endpoint', + async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Orca.AppImage') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + const cacheRootPath = join(userDataPath, 'cache') + const liveLauncherPath = join(resourcesPath, 'bin', 'orca-ide') + writeFileSync(liveLauncherPath, '#!/usr/bin/env bash\nprintf live', 'utf8') + chmodSync(liveLauncherPath, 0o755) + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath, + resourcesPath, + appImagePath, + appImageCacheRootPath: cacheRootPath + }) - const shimPath = join(shimDir!, 'orca') - const stableLauncherPath = resolveAppImageStableLauncherPath(cacheRootPath) - const content = readFileSync(shimPath, 'utf8') - expect(content).toContain(stableLauncherPath) - expect(content).not.toContain(resourcesPath) - expect(content).not.toContain(appImagePath) - await expect( - runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) - ).resolves.toMatchObject({ code: 0, stdout: 'live' }) - }) - - it('updates restored terminals to the current AppImage mount without rewriting the shim', async () => { - const { userDataPath, resourcesPath } = await makeFixture() - const appImagePath = join(userDataPath, 'Orca.AppImage') - await mkdir(userDataPath, { recursive: true }) - await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) - const cacheRootPath = join(userDataPath, 'cache') - const firstLauncher = join(resourcesPath, 'bin', 'orca-ide') - writeFileSync(firstLauncher, '#!/usr/bin/env bash\nprintf first', 'utf8') - chmodSync(firstLauncher, 0o755) - const options = { - userDataPath, - resourcesPath, - appImagePath, - appImageCacheRootPath: cacheRootPath + const shimPath = join(shimDir!, 'orca') + const content = readFileSync(shimPath, 'utf8') + expect(content).toContain(liveLauncherPath) + expect(content).toContain('runtime_pid=') + expect(content).toContain('/proc/$runtime_pid/stat') + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(false) + await expect( + runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'live' }) } - const shimDir = ensureLinuxTerminalOrcaCliShimDir(options) - const shimPath = join(shimDir!, 'orca') - const originalShim = readFileSync(shimPath, 'utf8') + ) - const nextResourcesPath = join(userDataPath, 'next-mount', 'resources') - const nextLauncher = join(nextResourcesPath, 'bin', 'orca-ide') - await mkdir(join(nextResourcesPath, 'bin'), { recursive: true }) - await writeFile(nextLauncher, '#!/usr/bin/env bash\nprintf next', { mode: 0o755 }) - await rm(firstLauncher) - expect( - ensureLinuxTerminalOrcaCliShimDir({ ...options, resourcesPath: nextResourcesPath }) - ).toBe(shimDir) + it.skipIf(!canFenceAppImageRuntime)( + 'refreshes restored terminals to the current AppImage mount', + async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Orca.AppImage') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + const cacheRootPath = join(userDataPath, 'cache') + const firstLauncher = join(resourcesPath, 'bin', 'orca-ide') + writeFileSync(firstLauncher, '#!/usr/bin/env bash\nprintf first', 'utf8') + chmodSync(firstLauncher, 0o755) + const options = { + userDataPath, + resourcesPath, + appImagePath, + appImageCacheRootPath: cacheRootPath + } + const shimDir = ensureLinuxTerminalOrcaCliShimDir(options) + const shimPath = join(shimDir!, 'orca') + const originalShim = readFileSync(shimPath, 'utf8') - expect(readFileSync(shimPath, 'utf8')).toBe(originalShim) - expect(readlinkSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe( - nextLauncher - ) - await expect( - runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) - ).resolves.toMatchObject({ code: 0, stdout: 'next' }) - }) + const nextResourcesPath = join(userDataPath, 'next-mount', 'resources') + const nextLauncher = join(nextResourcesPath, 'bin', 'orca-ide') + await mkdir(join(nextResourcesPath, 'bin'), { recursive: true }) + await writeFile(nextLauncher, '#!/usr/bin/env bash\nprintf next', { mode: 0o755 }) + await rm(firstLauncher) + expect( + ensureLinuxTerminalOrcaCliShimDir({ ...options, resourcesPath: nextResourcesPath }) + ).toBe(shimDir) - it('waits briefly for a temporarily unavailable live endpoint', async () => { - const { userDataPath, resourcesPath } = await makeFixture() - const appImagePath = join(userDataPath, 'Orca.AppImage') - const cacheRootPath = join(userDataPath, 'cache') - await mkdir(userDataPath, { recursive: true }) - await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) - const liveLauncher = join(resourcesPath, 'bin', 'orca-ide') - chmodSync(liveLauncher, 0o755) - const shimDir = ensureLinuxTerminalOrcaCliShimDir({ - userDataPath, - resourcesPath, - appImagePath, - appImageCacheRootPath: cacheRootPath - }) - const shimPath = join(shimDir!, 'orca') - await rm(liveLauncher) - const invocation = runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) - setTimeout(() => { - writeFileSync(liveLauncher, '#!/usr/bin/env bash\nprintf recovered', { mode: 0o755 }) - }, 100) + const refreshedShim = readFileSync(shimPath, 'utf8') + expect(refreshedShim).not.toBe(originalShim) + expect(refreshedShim).toContain(nextLauncher) + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(false) + await expect( + runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'next' }) + } + ) - await expect(invocation).resolves.toMatchObject({ code: 0, stdout: 'recovered' }) - }) + it.skipIf(!canFenceAppImageRuntime)( + 'rejects a stale shim when its mount path is removed and reused', + async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Orca.AppImage') + const cacheRootPath = join(userDataPath, 'cache') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + const liveLauncher = join(resourcesPath, 'bin', 'orca-ide') + writeFileSync(liveLauncher, '#!/usr/bin/env bash\nprintf original', { mode: 0o755 }) + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath, + resourcesPath, + appImagePath, + appImageCacheRootPath: cacheRootPath + }) + const shimPath = join(shimDir!, 'orca') + await rm(liveLauncher) + await writeFile(liveLauncher, '#!/usr/bin/env bash\nprintf replaced-by-another-mount', { + mode: 0o755 + }) + + await expect( + runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 1, stdout: '' }) + } + ) + + it.skipIf(!canFenceAppImageRuntime)( + 'rejects a shim after its owning AppImage process generation changes', + async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Orca.AppImage') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + const liveLauncher = join(resourcesPath, 'bin', 'orca-ide') + writeFileSync(liveLauncher, '#!/usr/bin/env bash\nprintf original', { mode: 0o755 }) + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath, + resourcesPath, + appImagePath, + appImageCacheRootPath: join(userDataPath, 'cache') + }) + const shimPath = join(shimDir!, 'orca') + const staleContent = readFileSync(shimPath, 'utf8').replace( + /runtime_start_time='[^']*'/, + "runtime_start_time='stale-process'" + ) + writeFileSync(shimPath, staleContent, { mode: 0o755 }) + + await expect( + runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 1, stdout: '' }) + } + ) it('returns null (and does not memoize) when the bundled launcher is missing', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-missing-')) diff --git a/src/main/cli/linux-terminal-orca-cli-shim.ts b/src/main/cli/linux-terminal-orca-cli-shim.ts index cef4844450d..1a0ebb40ba4 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.ts @@ -1,13 +1,28 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, + type Stats +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' import { hasAppImageRuntimeEnvironment, resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity' -import { getAppImageCacheRootPath } from './appimage-extracted-root' -import { publishAppImageLauncherEndpoint } from './appimage-stable-launcher' +import { + getAppImageCacheRootPath, + isAppImageInstalledLauncherCurrent +} from './appimage-extracted-root' +import { + ensureAppImageStableLauncher, + removeAppImageLegacyLiveEndpoint +} from './appimage-stable-launcher' import { getBundledLauncherPath } from './bundled-cli-launcher-path' import { buildBareOrcaCliScript } from './linux-bare-orca-dispatcher' +import { quoteShell } from './cli-install-path-format' const SHIM_DIR_NAME = 'linux-orca-cli-shim' @@ -42,7 +57,7 @@ export function ensureLinuxTerminalOrcaCliShimDir( ? (options.appImagePath ?? null) : (runtimeIdentity?.appImagePath ?? null) if (appImagePath) { - return ensureAppImageShim(options, resourcesPath) + return ensureAppImageShim(options, resourcesPath, appImagePath) } if (!resourcesPath) { @@ -56,26 +71,149 @@ export function ensureLinuxTerminalOrcaCliShimDir( function ensureAppImageShim( options: LinuxTerminalOrcaCliShimOptions, - resourcesPath: string | null + resourcesPath: string | null, + appImagePath: string ): string | null { if (!resourcesPath) { return null } + const cacheRootPath = options.appImageCacheRootPath ?? getAppImageCacheRootPath() + removeAppImageLegacyLiveEndpoint(cacheRootPath) + const liveLauncherPath = getBundledLauncherPath('linux', resourcesPath) if (!liveLauncherPath || !existsSync(liveLauncherPath)) { return null } - const stableLauncherPath = publishAppImageLauncherEndpoint( - options.appImageCacheRootPath ?? getAppImageCacheRootPath(), - 'live', - liveLauncherPath - ) - return stableLauncherPath ? ensureShimForLauncher(options.userDataPath, stableLauncherPath) : null + + const stableLauncherPath = ensureAppImageStableLauncher(cacheRootPath) + if ( + stableLauncherPath && + isAppImageInstalledLauncherCurrent({ + appImagePath, + cacheRootPath + }) + ) { + return ensureShimForLauncher(options.userDataPath, stableLauncherPath) + } + + const fence = captureAppImageRuntimeFence(liveLauncherPath) + return fence + ? ensureShimForScript( + options.userDataPath, + buildAppImageLiveLauncherScript(fence.launcherPath, fence) + ) + : null +} + +type AppImageRuntimeFence = { + pid: number + startTime: string + runtimeRoot: string + runtimeIdentity: string + launcherIdentity: string + launcherPath: string +} + +function captureAppImageRuntimeFence(launcherPath: string): AppImageRuntimeFence | null { + if (process.platform !== 'linux') { + return null + } + const resolvedLauncherPath = resolve(launcherPath) + const runtimeRoot = dirname(dirname(dirname(resolvedLauncherPath))) + const runtimeIdentity = readFileIdentity(runtimeRoot) + const launcherIdentity = readFileIdentity(resolvedLauncherPath) + const startTime = readLinuxProcessStartTime(process.pid) + return runtimeIdentity && launcherIdentity && startTime + ? { + pid: process.pid, + startTime, + runtimeRoot, + runtimeIdentity, + launcherIdentity, + launcherPath: resolvedLauncherPath + } + : null +} + +function readFileIdentity(path: string): string | null { + try { + const stats = statSync(path) + return formatFileIdentity(stats) + } catch { + return null + } +} + +function formatFileIdentity(stats: Stats): string { + return [ + stats.dev, + stats.ino, + stats.size, + Math.floor(stats.mtimeMs / 1000), + Math.floor(stats.ctimeMs / 1000) + ].join(':') +} + +function readLinuxProcessStartTime(pid: number): string | null { + try { + const content = readFileSync(join('/proc', String(pid), 'stat'), 'utf8') + const commandEnd = content.lastIndexOf(') ') + if (commandEnd === -1) { + return null + } + const fields = content + .slice(commandEnd + 2) + .trim() + .split(/\s+/) + return fields[19] ?? null + } catch { + return null + } +} + +function buildAppImageLiveLauncherScript( + launcherPath: string, + fence: AppImageRuntimeFence +): string { + const runtimePid = quoteShell(String(fence.pid)) + const runtimeStartTime = quoteShell(fence.startTime) + const runtimeRoot = quoteShell(fence.runtimeRoot) + const expectedRuntimeIdentity = quoteShell(fence.runtimeIdentity) + const expectedLauncherIdentity = quoteShell(fence.launcherIdentity) + const quotedLauncherPath = quoteShell(launcherPath) + return `#!/usr/bin/env bash +runtime_pid=${runtimePid} +runtime_start_time=${runtimeStartTime} +runtime_root=${runtimeRoot} +launcher=${quotedLauncherPath} +expected_runtime_identity=${expectedRuntimeIdentity} +expected_launcher_identity=${expectedLauncherIdentity} +fail() { + printf 'Orca CLI is unavailable; reopen Orca or register the CLI again.\\n' >&2 + exit 1 +} +proc_stat_path="/proc/$runtime_pid/stat" +[[ -r "$proc_stat_path" ]] || fail +proc_stat="$(<"$proc_stat_path")" || fail +proc_fields=() +read -r -a proc_fields <<< "\${proc_stat##*) }" || fail +[[ "\${proc_fields[19]:-}" == "$runtime_start_time" ]] || fail +runtime_identity="$(stat -Lc '%d:%i:%s:%Y:%Z' -- "$runtime_root" 2>/dev/null)" || fail +[[ "$runtime_identity" == "$expected_runtime_identity" ]] || fail +launcher_identity="$(stat -Lc '%d:%i:%s:%Y:%Z' -- "$launcher" 2>/dev/null)" || fail +[[ "$launcher_identity" == "$expected_launcher_identity" ]] || fail +[[ -f "$launcher" && -x "$launcher" ]] || fail +exec "$launcher" "$@" +` } function ensureShimForLauncher(userDataPath: string, launcherPath: string): string | null { const script = buildBareOrcaCliScript(launcherPath) + return ensureShimForScript(userDataPath, script) +} + +function ensureShimForScript(userDataPath: string, script: string): string | null { const shimDir = join(userDataPath, SHIM_DIR_NAME) const shimPath = join(shimDir, 'orca') try { From a2c8859f7633322dd9ca6687c91026533e93ae24 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:21:21 -0700 Subject: [PATCH 05/94] fix(linux): accept extracted AppImage runtimes with APPDIR only --- src/main/appimage-runtime-identity.test.ts | 12 +++---- src/main/appimage-runtime-identity.ts | 7 ++-- src/main/cli/cli-install-location.ts | 4 +-- .../cli-installer-appimage-ownership.test.ts | 32 +++++++++++++++++++ .../cli/linux-bare-orca-dispatcher.test.ts | 12 +++++++ src/main/cli/linux-bare-orca-dispatcher.ts | 4 +-- .../cli/linux-terminal-orca-cli-shim.test.ts | 14 ++++++++ src/main/cli/linux-terminal-orca-cli-shim.ts | 4 +-- 8 files changed, 73 insertions(+), 16 deletions(-) diff --git a/src/main/appimage-runtime-identity.test.ts b/src/main/appimage-runtime-identity.test.ts index 903d67827f9..f9a7319ee1c 100644 --- a/src/main/appimage-runtime-identity.test.ts +++ b/src/main/appimage-runtime-identity.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { - hasAppImageRuntimeEnvironment, + hasAppImagePathEnvironment, resolveAppImageRuntimeIdentity } from './appimage-runtime-identity' @@ -231,10 +231,10 @@ describe.skipIf(process.platform === 'win32')('resolveAppImageRuntimeIdentity', }) }) -describe('hasAppImageRuntimeEnvironment', () => { - it('detects either AppImage runtime variable', () => { - expect(hasAppImageRuntimeEnvironment({ APPIMAGE: '/tmp/Orca.AppImage' })).toBe(true) - expect(hasAppImageRuntimeEnvironment({ APPDIR: '/tmp/.mount_Orca123' })).toBe(true) - expect(hasAppImageRuntimeEnvironment({ APPIMAGE: '', APPDIR: '' })).toBe(false) +describe('hasAppImagePathEnvironment', () => { + it('requires the AppImage file path before treating the runtime as verifiable', () => { + expect(hasAppImagePathEnvironment({ APPIMAGE: '/tmp/Orca.AppImage' })).toBe(true) + expect(hasAppImagePathEnvironment({ APPDIR: '/tmp/.mount_Orca123' })).toBe(false) + expect(hasAppImagePathEnvironment({ APPIMAGE: '', APPDIR: '/tmp/.mount_Orca123' })).toBe(false) }) }) diff --git a/src/main/appimage-runtime-identity.ts b/src/main/appimage-runtime-identity.ts index 860d6c83a63..08b2a92dd65 100644 --- a/src/main/appimage-runtime-identity.ts +++ b/src/main/appimage-runtime-identity.ts @@ -143,10 +143,9 @@ function hasAppImagePackageEvidence(runtimeRoot: string, resourcesPath: string): } } -export function hasAppImageRuntimeEnvironment( - environment: NodeJS.ProcessEnv = process.env -): boolean { - return Boolean(environment.APPIMAGE || environment.APPDIR) +/** Returns whether the process inherited an AppImage file path to validate. */ +export function hasAppImagePathEnvironment(environment: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(environment.APPIMAGE) } export function resolveAppImageRuntimeIdentity( diff --git a/src/main/cli/cli-install-location.ts b/src/main/cli/cli-install-location.ts index e65c17cc1cd..228e574addc 100644 --- a/src/main/cli/cli-install-location.ts +++ b/src/main/cli/cli-install-location.ts @@ -4,7 +4,7 @@ import { basename, dirname, join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import type { CliInstallStatus } from '../../shared/cli-install-types' import { - hasAppImageRuntimeEnvironment, + hasAppImagePathEnvironment, resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity' import { @@ -106,7 +106,7 @@ export abstract class CliInstallLocation { this.platform === 'linux' && this.isPackaged && !hasExplicitAppImagePath && - hasAppImageRuntimeEnvironment() && + hasAppImagePathEnvironment() && !runtimeAppImageIdentity this.appImagePath = this.platform === 'linux' && this.isPackaged diff --git a/src/main/cli/cli-installer-appimage-ownership.test.ts b/src/main/cli/cli-installer-appimage-ownership.test.ts index 92fb7325ec8..81147e09886 100644 --- a/src/main/cli/cli-installer-appimage-ownership.test.ts +++ b/src/main/cli/cli-installer-appimage-ownership.test.ts @@ -70,6 +70,38 @@ function installerOptions(fixture: Fixture) { } describe.skipIf(process.platform === 'win32')('AppImage CLI ownership', () => { + it('uses the mounted bundled launcher when only APPDIR is inherited', async () => { + const fixture = await makeFixture() + const resourcesPath = join(fixture.root, 'mounted', 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca-ide') + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + vi.stubEnv('APPIMAGE', '') + vi.stubEnv('APPDIR', dirname(resourcesPath)) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(fixture.root, 'user-data'), + resourcesPath, + execPath: join(dirname(resourcesPath), 'orca-ide'), + appPath: join(resourcesPath, 'app.asar'), + homePath: join(fixture.root, 'home'), + processPathEnv: fixture.commandDirectory, + commandPathOverride: fixture.commandPath + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'not_installed', + launcherPath + }) + await expect(installer.install()).resolves.toMatchObject({ + state: 'installed', + launcherPath + }) + await expect(readlink(fixture.commandPath)).resolves.toBe(launcherPath) + }) + it('ignores inherited APPIMAGE without the matching runtime identity', async () => { const fixture = await makeFixture() const resourcesPath = join(fixture.root, 'installed', 'resources') diff --git a/src/main/cli/linux-bare-orca-dispatcher.test.ts b/src/main/cli/linux-bare-orca-dispatcher.test.ts index 18a16d53f60..b8031535134 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.test.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.test.ts @@ -72,6 +72,7 @@ async function makeFixture(): Promise<{ homePath: string; resourcesPath: string } afterEach(async () => { + vi.unstubAllEnvs() filePublicationFailures.link = 0 filePublicationFailures.replaceBeforeRename = '' registrationLock.completed = null @@ -81,6 +82,17 @@ afterEach(async () => { }) describe('installLinuxBareOrcaDispatcher', () => { + it('uses the mounted bundled launcher when only APPDIR is inherited', async () => { + const { homePath, resourcesPath } = await makeFixture() + vi.stubEnv('APPIMAGE', '') + vi.stubEnv('APPDIR', resourcesPath) + + const result = await installLinuxBareOrcaDispatcher({ resourcesPath, homePath }) + + expect(result.state).toBe('installed') + expect(result.target).toBe(join(resourcesPath, 'bin', 'orca-ide')) + }) + it('writes an executable bare-orca dispatcher that execs the bundled orca-ide launcher', async () => { const { homePath, resourcesPath } = await makeFixture() diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index 780dca8b1f6..3bf9fae35ad 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -4,7 +4,7 @@ import { copyFile, link, lstat, mkdir, readFile, rename, unlink, writeFile } fro import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { - hasAppImageRuntimeEnvironment, + hasAppImagePathEnvironment, resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity' import { @@ -89,7 +89,7 @@ async function resolveStableLauncherPath( ): Promise { const hasExplicitAppImagePath = Object.hasOwn(options, 'appImagePath') const runtimeIdentity = resolveAppImageRuntimeIdentity({ resourcesPath: options.resourcesPath }) - if (!hasExplicitAppImagePath && hasAppImageRuntimeEnvironment() && !runtimeIdentity) { + if (!hasExplicitAppImagePath && hasAppImagePathEnvironment() && !runtimeIdentity) { return null } const appImagePath = hasExplicitAppImagePath diff --git a/src/main/cli/linux-terminal-orca-cli-shim.test.ts b/src/main/cli/linux-terminal-orca-cli-shim.test.ts index 164271bd7f2..905d4807368 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.test.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.test.ts @@ -26,10 +26,24 @@ async function makeFixture(): Promise<{ userDataPath: string; resourcesPath: str } afterEach(async () => { + vi.unstubAllEnvs() await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) describe('ensureLinuxTerminalOrcaCliShimDir', () => { + it('uses the mounted bundled launcher when only APPDIR is inherited', async () => { + const { userDataPath, resourcesPath } = await makeFixture() + vi.stubEnv('APPIMAGE', '') + vi.stubEnv('APPDIR', resourcesPath) + + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath, resourcesPath }) + + expect(shimDir).toBe(join(userDataPath, 'linux-orca-cli-shim')) + expect(readFileSync(join(shimDir!, 'orca'), 'utf8')).toContain( + `exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"` + ) + }) + it('writes an executable bare-orca shim that execs the bundled orca-ide launcher', async () => { const { userDataPath, resourcesPath } = await makeFixture() diff --git a/src/main/cli/linux-terminal-orca-cli-shim.ts b/src/main/cli/linux-terminal-orca-cli-shim.ts index 1a0ebb40ba4..7697bac01ac 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.ts @@ -9,7 +9,7 @@ import { } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { - hasAppImageRuntimeEnvironment, + hasAppImagePathEnvironment, resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity' import { @@ -50,7 +50,7 @@ export function ensureLinuxTerminalOrcaCliShimDir( options.resourcesPath === undefined ? process.resourcesPath : options.resourcesPath const hasExplicitAppImagePath = Object.hasOwn(options, 'appImagePath') const runtimeIdentity = resolveAppImageRuntimeIdentity({ resourcesPath }) - if (!hasExplicitAppImagePath && hasAppImageRuntimeEnvironment() && !runtimeIdentity) { + if (!hasExplicitAppImagePath && hasAppImagePathEnvironment() && !runtimeIdentity) { return null } const appImagePath = hasExplicitAppImagePath From 95212ef572f5d7bf8a7d44e2310cde1dc8272e84 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:14:13 -0700 Subject: [PATCH 06/94] docs(linux): make headless AppImage extraction runnable --- docs/reference/headless-linux-server.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index beb0220d15b..3d7db834e8e 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -58,11 +58,23 @@ FUSE. On Ubuntu 24.04 and Debian 13 the package is `libfuse2t64`, though the pla `libfuse2` name also resolves there because nothing else provides it. FUSE is optional: without it, use the AppImage's supported extraction path. CLI registration does this once automatically, so registered commands do not need -FUSE: +FUSE. + +Download and make the AppImage executable: + +```bash +sudo mkdir -p /opt/orca +sudo curl -L https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage \ + -o /opt/orca/orca-linux.AppImage +sudo chmod +x /opt/orca/orca-linux.AppImage +``` + +To extract it without FUSE, run the extraction as root because the installation +directory is root-owned: ```bash cd /opt/orca -./orca-linux.AppImage --appimage-extract +sudo ./orca-linux.AppImage --appimage-extract sudo chmod -R a+rX /opt/orca/squashfs-root /opt/orca/squashfs-root/AppRun serve --port 6768 ``` @@ -78,15 +90,6 @@ extract-and-run wrapper can print extracted paths before Orca starts, so automation that requires stdout to contain only the ready JSON should extract once and invoke `squashfs-root/AppRun`. -Download and make the AppImage executable: - -```bash -sudo mkdir -p /opt/orca -sudo curl -L https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage \ - -o /opt/orca/orca-linux.AppImage -sudo chmod +x /opt/orca/orca-linux.AppImage -``` - If `Xvfb` was installed somewhere other than `/usr/bin`, confirm systemd can find it later: From bbd2047066884e692ec4c768c99fbe4372c6ae43 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:32:17 -0700 Subject: [PATCH 07/94] refactor(linux): import bundled launcher directly --- .../cli/cli-command-filesystem-transaction.ts | 8 +++---- ...cli-command-privileged-transaction.test.ts | 22 +++++++++++++++++++ src/main/cli/cli-installer.ts | 3 --- src/main/cli/linux-bare-orca-dispatcher.ts | 2 +- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/main/cli/cli-command-filesystem-transaction.ts b/src/main/cli/cli-command-filesystem-transaction.ts index 7e464f52368..b5e29386f8e 100644 --- a/src/main/cli/cli-command-filesystem-transaction.ts +++ b/src/main/cli/cli-command-filesystem-transaction.ts @@ -196,12 +196,12 @@ export function buildMacPrivilegedSymlinkTransaction( } const rollback = - `/bin/rm ${quoteShell(publishPath)}; /bin/rmdir ${quoteShell(publishDirectory)}; ` + + `/bin/rm -f ${quoteShell(publishPath)}; /bin/rmdir ${quoteShell(publishDirectory)} 2>/dev/null || :; ` + `if [ "$captured" -eq 1 ]; then ${restoreOrPreserve}; else /bin/rmdir ${quoteShell(transactionDirectory)}; fi; exit 73` return ( - `${capture}/bin/mkdir ${quoteShell(publishDirectory)} || exit $?; ` + - `/bin/ln -s ${quoteShell(args.launcherPath)} ${quoteShell(publishPath)} || exit $?; ` + - `if /bin/ln -P ${quoteShell(publishPath)} ${quoteShell(commandDirectory)}; then ` + + `${capture}if /bin/mkdir ${quoteShell(publishDirectory)} && ` + + `/bin/ln -s ${quoteShell(args.launcherPath)} ${quoteShell(publishPath)} && ` + + `/bin/ln -P ${quoteShell(publishPath)} ${quoteShell(commandDirectory)}; then ` + `/bin/rm ${quoteShell(publishPath)}; /bin/rmdir ${quoteShell(publishDirectory)}; ` + `if [ "$captured" -eq 1 ]; then /bin/rm ${quoteShell(heldPath)}; fi; ` + `/bin/rmdir ${quoteShell(transactionDirectory)}; else ${rollback}; fi` diff --git a/src/main/cli/cli-command-privileged-transaction.test.ts b/src/main/cli/cli-command-privileged-transaction.test.ts index 2cad192bc11..63349cb70c9 100644 --- a/src/main/cli/cli-command-privileged-transaction.test.ts +++ b/src/main/cli/cli-command-privileged-transaction.test.ts @@ -163,5 +163,27 @@ describe.skipIf(process.platform !== 'darwin' || process.getuid?.() === 0)( (await readdir(fixture.protectedDirectory)).some((name) => name.startsWith('.orca-cli-')) ).toBe(false) }) + + it('restores the displaced command when publication setup fails', async () => { + const fixture = await createPrivilegedFixture() + const staleTarget = join(fixture.userDataPath, 'cli', 'bin', 'old', 'orca') + await symlink(staleTarget, fixture.commandPath) + const installer = new CliInstaller({ + ...fixtureInstallerOptions(fixture), + privilegedRunner: async (command) => { + await chmod(fixture.protectedDirectory, 0o700) + const sabotaged = command.replace(/\/bin\/mkdir ('[^']*\/publish')/, '/usr/bin/false') + expect(sabotaged).not.toBe(command) + await executePrivilegedShell(sabotaged) + } + }) + + await chmod(fixture.protectedDirectory, 0o500) + await expect(installer.install()).rejects.toThrow() + await expect(readlink(fixture.commandPath)).resolves.toBe(staleTarget) + expect( + (await readdir(fixture.protectedDirectory)).some((name) => name.startsWith('.orca-cli-')) + ).toBe(false) + }) } ) diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index c6fba042216..6b0a4adf30e 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -11,7 +11,6 @@ import { isAppImageInstalledLauncherOwnedBySibling, resolveAppImageNamespacePath } from './appimage-extracted-root' -import { getBundledLauncherPath } from './bundled-cli-launcher-path' import { isAppImageStableLauncherReady } from './appimage-stable-launcher' import { CliPathRegistration } from './cli-path-registration' @@ -213,5 +212,3 @@ export class CliInstaller extends CliPathRegistration { : operation() } } - -export { getBundledLauncherPath } diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index 3bf9fae35ad..6c4b273fcd8 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -13,8 +13,8 @@ import { } from './appimage-extracted-root' import { pruneAppImageExtractedRoots } from './appimage-extraction-pruning' import { withAppImageRegistrationLock } from './appimage-registration-lock' +import { getBundledLauncherPath } from './bundled-cli-launcher-path' import { quoteShell } from './cli-install-path-format' -import { getBundledLauncherPath } from './cli-installer' // Why: marks a dispatcher this function wrote so repeat serve starts overwrite // our own file idempotently but never clobber a user's own ~/.local/bin/orca. From 1767858ea79d34ce3744e3da1fbf1f26e3e45a4a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:47:52 -0700 Subject: [PATCH 08/94] fix(linux): reclaim superseded AppImage payloads and packaged symlinks Pruning removed 3215 of 3216 files from a superseded generation and always stranded resources/app.asar, leaking ~105 MB per version update. Electron's asar shim reports a *.asar file as a directory, so the recursive remove tried to rmdir a real file and failed with ENOTEMPTY; the .catch(() => {}) hid it. Reproduced end to end on Ubuntu 24.04: 519M -> 623M across one update, and 519M again once the payload is actually reclaimed. removeExtractedAppImagePayload holds process.noAsar for the removal, counted so overlapping removals cannot hand the shim back early, and the prune site now warns with the path instead of swallowing the rejection. All three removal sites use it -- staging cleanup and displaced roots leaked the same way. Also reclaim symlinks left by a packaged deb/rpm install, which the extracted-cache-only rule turned into a hard conflict on a deb -> AppImage migration, and name the remedy in the conflict error. --- src/main/cli/appimage-extracted-root.ts | 5 +- src/main/cli/appimage-extraction-pruning.ts | 11 +++- ...ppimage-payload-removal.reentrancy.test.ts | 65 ++++++++++++++++++ src/main/cli/appimage-payload-removal.test.ts | 66 +++++++++++++++++++ src/main/cli/appimage-payload-removal.ts | 35 ++++++++++ src/main/cli/cli-command-inspection.ts | 15 +++++ src/main/cli/cli-command-installation.ts | 8 ++- .../cli-installer-appimage-ownership.test.ts | 22 +++++++ src/main/cli/cli-installer.ts | 4 +- src/main/cli/wsl-cli-installer.ts | 2 +- 10 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 src/main/cli/appimage-payload-removal.reentrancy.test.ts create mode 100644 src/main/cli/appimage-payload-removal.test.ts create mode 100644 src/main/cli/appimage-payload-removal.ts diff --git a/src/main/cli/appimage-extracted-root.ts b/src/main/cli/appimage-extracted-root.ts index ae37ff8a34f..93bca0feed6 100644 --- a/src/main/cli/appimage-extracted-root.ts +++ b/src/main/cli/appimage-extracted-root.ts @@ -5,6 +5,7 @@ import { homedir } from 'node:os' import { dirname, isAbsolute, join, resolve } from 'node:path' import { runProcess } from '../../shared/child-process/run-process' import { resolveCachedAppImagePayloadRoot } from './appimage-cache-layout' +import { removeExtractedAppImagePayload } from './appimage-payload-removal' import { LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' import { APPIMAGE_EXTRACTION_TIMEOUT_MS, @@ -179,7 +180,7 @@ async function extractAppImageGeneration( return isAppImageExtractionComplete(root) } finally { stopTracking() - await rm(stagingPath, { recursive: true, force: true }).catch(() => {}) + await removeExtractedAppImagePayload(stagingPath).catch(() => {}) await rmdir(namespacePath).catch(() => {}) } } @@ -227,7 +228,7 @@ async function publishExtractedRoot( if (hasPayloadLauncher(displacedPath)) { return (await renameRoot(displacedPath, root.rootPath)) || isAppImageExtractionComplete(root) } - await rm(displacedPath, { recursive: true, force: true }) + await removeExtractedAppImagePayload(displacedPath) return (await renameRoot(extractedPath, root.rootPath)) || isAppImageExtractionComplete(root) } diff --git a/src/main/cli/appimage-extraction-pruning.ts b/src/main/cli/appimage-extraction-pruning.ts index d89236f4591..b491982129d 100644 --- a/src/main/cli/appimage-extraction-pruning.ts +++ b/src/main/cli/appimage-extraction-pruning.ts @@ -1,9 +1,10 @@ import { randomUUID } from 'node:crypto' import { existsSync } from 'node:fs' import type { Dirent } from 'node:fs' -import { lstat, readlink, readdir, rename, rm, rmdir, symlink, unlink } from 'node:fs/promises' +import { lstat, readlink, readdir, rename, rmdir, symlink, unlink } from 'node:fs/promises' import { basename, dirname, join, resolve } from 'node:path' import { isAppImageCacheKey, resolveCachedAppImagePayloadRoot } from './appimage-cache-layout' +import { removeExtractedAppImagePayload } from './appimage-payload-removal' import { removeAppImageLegacyLiveEndpoint, resolveAppImageLauncherEndpointPath @@ -133,7 +134,13 @@ async function pruneNamespace( ) { continue } - await rm(entryPath, { recursive: true, force: true }).catch(() => {}) + // Best effort, but never silent: a swallowed failure here leaks a whole payload generation. + await removeExtractedAppImagePayload(entryPath).catch((error: unknown) => { + console.warn( + `[cli] could not reclaim AppImage payload ${entryPath}:`, + error instanceof Error ? error.message : error + ) + }) } } diff --git a/src/main/cli/appimage-payload-removal.reentrancy.test.ts b/src/main/cli/appimage-payload-removal.reentrancy.test.ts new file mode 100644 index 00000000000..ebf9c399c9e --- /dev/null +++ b/src/main/cli/appimage-payload-removal.reentrancy.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +// Why a mocked rm: the property under test is the ORDER in which overlapping removals settle, which +// real filesystem timing cannot pin down. Deferreds make the interleaving exact. +const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() })) +vi.mock('node:fs/promises', () => ({ rm: rmMock })) + +const originalNoAsar = process.noAsar + +afterEach(() => { + process.noAsar = originalNoAsar + vi.resetModules() +}) + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +describe('removeExtractedAppImagePayload reentrancy', () => { + // The hazard is the FIRST removal settling while a later one is still running: a naive + // save/restore would hand the shim back and silently leak the rest of the second removal. + it('keeps asar interception disabled while a later removal is still running', async () => { + process.noAsar = false + const first = deferred() + const second = deferred() + rmMock.mockReset() + rmMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const { removeExtractedAppImagePayload } = await import('./appimage-payload-removal') + + const firstCall = removeExtractedAppImagePayload('/cache/gen-a') + const secondCall = removeExtractedAppImagePayload('/cache/gen-b') + expect(process.noAsar).toBe(true) + + first.resolve() + await firstCall + // gen-b is still being removed: handing the shim back here is exactly the leak. + expect(process.noAsar).toBe(true) + + second.resolve() + await secondCall + expect(process.noAsar).toBe(false) + }) + + it('keeps the flag held when the first removal rejects mid-overlap', async () => { + process.noAsar = false + const second = deferred() + rmMock.mockReset() + rmMock.mockRejectedValueOnce(new Error('EACCES')).mockReturnValueOnce(second.promise) + const { removeExtractedAppImagePayload } = await import('./appimage-payload-removal') + + const firstCall = removeExtractedAppImagePayload('/cache/gen-a') + const secondCall = removeExtractedAppImagePayload('/cache/gen-b') + + await expect(firstCall).rejects.toThrow('EACCES') + expect(process.noAsar).toBe(true) + + second.resolve() + await secondCall + expect(process.noAsar).toBe(false) + }) +}) diff --git a/src/main/cli/appimage-payload-removal.test.ts b/src/main/cli/appimage-payload-removal.test.ts new file mode 100644 index 00000000000..caa3ee33c1c --- /dev/null +++ b/src/main/cli/appimage-payload-removal.test.ts @@ -0,0 +1,66 @@ +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { removeExtractedAppImagePayload } from './appimage-payload-removal' + +const originalNoAsar = process.noAsar + +afterEach(() => { + process.noAsar = originalNoAsar +}) + +async function makePayloadTree(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-payload-removal-')) + await mkdir(join(root, 'resources'), { recursive: true }) + // The real leak: Electron's fs patch reports a *.asar file as a directory. + await writeFile(join(root, 'resources', 'app.asar'), 'asar-payload') + await writeFile(join(root, 'AppRun'), '#!/bin/sh\n') + return root +} + +describe('removeExtractedAppImagePayload', () => { + it('removes a payload tree containing an asar file', async () => { + const root = await makePayloadTree() + await removeExtractedAppImagePayload(root) + expect(existsSync(root)).toBe(false) + }) + + it('disables asar interception for the removal', async () => { + const root = await makePayloadTree() + let observed: boolean | undefined + const originalRealpath = process.noAsar + Object.defineProperty(process, 'noAsar', { + configurable: true, + get: () => observed ?? originalRealpath, + set: (value: boolean) => { + observed ??= value + } + }) + try { + await removeExtractedAppImagePayload(root) + } finally { + Object.defineProperty(process, 'noAsar', { + configurable: true, + writable: true, + value: originalRealpath + }) + } + expect(observed).toBe(true) + }) + + it('restores the previous asar setting after a failure', async () => { + process.noAsar = false + await expect( + removeExtractedAppImagePayload(join(tmpdir(), 'orca-missing', 'nested', '\0invalid')) + ).rejects.toThrow() + expect(process.noAsar).toBe(false) + }) + + it('is a no-op for a path that does not exist', async () => { + await expect( + removeExtractedAppImagePayload(join(tmpdir(), 'orca-payload-removal-absent')) + ).resolves.toBeUndefined() + }) +}) diff --git a/src/main/cli/appimage-payload-removal.ts b/src/main/cli/appimage-payload-removal.ts new file mode 100644 index 00000000000..86a6a326cee --- /dev/null +++ b/src/main/cli/appimage-payload-removal.ts @@ -0,0 +1,35 @@ +import { rm } from 'node:fs/promises' + +// Why a counter and not a saved value: `process.noAsar` is process-wide, so two overlapping +// removals would race — the first to settle would restore the shim while the second is still +// running, and the rest of that removal would silently leak again. Removals are sequential today; +// this keeps that a property of the module rather than of its callers. +let activeRemovals = 0 +// Captured once when the outermost removal starts; `process.noAsar` is typed boolean, and an +// unset flag is falsy, so restoring `false` is equivalent to restoring `undefined`. +let asarBeforeOutermostRemoval = false + +/** + * Removes an extracted AppImage payload tree. + * + * Why not a plain recursive `rm`: Electron patches `fs` so a `*.asar` file reports + * `isDirectory() === true`. A recursive remove then tries to `rmdir` a real file, fails with + * ENOTEMPTY, and strands the ~105 MB `resources/app.asar` of every superseded generation. + * `process.noAsar` restores real filesystem semantics; the window is ~33 ms for a full 519 MB + * generation, and nothing in the registration path reads asar content inside it. + */ +export async function removeExtractedAppImagePayload(targetPath: string): Promise { + if (activeRemovals === 0) { + asarBeforeOutermostRemoval = process.noAsar === true + } + activeRemovals += 1 + process.noAsar = true + try { + await rm(targetPath, { recursive: true, force: true }) + } finally { + activeRemovals -= 1 + if (activeRemovals === 0) { + process.noAsar = asarBeforeOutermostRemoval + } + } +} diff --git a/src/main/cli/cli-command-inspection.ts b/src/main/cli/cli-command-inspection.ts index cbb63ac4c31..c580c597478 100644 --- a/src/main/cli/cli-command-inspection.ts +++ b/src/main/cli/cli-command-inspection.ts @@ -10,6 +10,11 @@ import { CliInstallLocation } from './cli-install-location' import { isPathInsideOrEqual, samePathEntry } from './cli-install-path-format' import { extractLegacyAppImageCliWrapperTarget } from './legacy-appimage-cli-wrapper' +// Why: electron-builder's /opt directory name varies with productName sanitization, which is why +// resources/linux/packaging/after-install.sh enumerates all three of these. A symlink into one is a +// previous packaged Orca and is ours to reclaim; anything else stays a conflict. +const PACKAGED_LINUX_LAUNCHER_DIRECTORIES = ['/opt/Orca', '/opt/orca-ide', '/opt/orca'] + export class CliCommandInspection extends CliInstallLocation { protected async inspectSymlink( commandPath: string, @@ -107,6 +112,9 @@ export class CliCommandInspection extends CliInstallLocation { } if (this.platform === 'linux') { + if (this.isPackagedLinuxLauncherTarget(resolvedTarget, expectedName)) { + return true + } const extractionOptions = this.appImageExtractionOptions() return extractionOptions ? isAppImageExtractedLauncherPath(extractionOptions, resolvedTarget) @@ -116,6 +124,13 @@ export class CliCommandInspection extends CliInstallLocation { return false } + /** A launcher inside a packaged Linux install tree, left behind by a deb/rpm Orca. */ + protected isPackagedLinuxLauncherTarget(resolvedTarget: string, expectedName: string): boolean { + return PACKAGED_LINUX_LAUNCHER_DIRECTORIES.some( + (directory) => resolvedTarget === `${directory}/resources/bin/${expectedName}` + ) + } + protected isSiblingDevLauncherTarget( resolvedTarget: string, packagedLauncherName: string diff --git a/src/main/cli/cli-command-installation.ts b/src/main/cli/cli-command-installation.ts index 24798f35266..5b277bd8c62 100644 --- a/src/main/cli/cli-command-installation.ts +++ b/src/main/cli/cli-command-installation.ts @@ -35,7 +35,7 @@ export class CliCommandInstallation extends CliCommandInspection { const inspected = await this.inspectStableSymlink(commandPath, launcherPath) if (inspected.status.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${commandPath}.`) + throw new Error(`Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.`) } if (inspected.status.state === 'installed') { return @@ -54,7 +54,7 @@ export class CliCommandInstallation extends CliCommandInspection { if (!(await capturedExpectedEntry(quarantine, inspected))) { await this.restoreQuarantinedCommand(quarantine, commandPath) - throw new Error(`Refusing to replace non-Orca command at ${commandPath}.`) + throw new Error(`Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.`) } try { @@ -140,6 +140,10 @@ export class CliCommandInstallation extends CliCommandInspection { return false } + if (this.isPackagedLinuxLauncherTarget(resolvedTarget, LEGACY_LINUX_COMMAND_NAME)) { + return true + } + const devLauncherDir = resolve(this.userDataPath, ...DEV_LAUNCHER_DIR) if (isPathInsideOrEqual(devLauncherDir, resolvedTarget)) { return true diff --git a/src/main/cli/cli-installer-appimage-ownership.test.ts b/src/main/cli/cli-installer-appimage-ownership.test.ts index 81147e09886..f3b5107c331 100644 --- a/src/main/cli/cli-installer-appimage-ownership.test.ts +++ b/src/main/cli/cli-installer-appimage-ownership.test.ts @@ -258,4 +258,26 @@ describe.skipIf(process.platform === 'win32')('AppImage CLI ownership', () => { false ) }) + + // #15081 review: the Linux reclaim rule was narrowed to extracted-cache launchers, which left a + // deb/rpm -> AppImage migration wedged on its own leftover symlink. + it('reclaims a symlink left by a packaged deb/rpm install', async () => { + for (const directory of ['/opt/Orca', '/opt/orca-ide', '/opt/orca']) { + const fixture = await makeFixture() + await symlink(`${directory}/resources/bin/orca-ide`, fixture.commandPath) + + await expect(new CliInstaller(installerOptions(fixture)).getStatus()).resolves.toMatchObject({ + state: 'stale' + }) + } + }) + + it('still refuses a launcher-named symlink outside the packaged install tree', async () => { + const fixture = await makeFixture() + await symlink('/opt/not-orca/resources/bin/orca-ide', fixture.commandPath) + + await expect(new CliInstaller(installerOptions(fixture)).getStatus()).resolves.toMatchObject({ + state: 'conflict' + }) + }) }) diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 6b0a4adf30e..3c832df5078 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -116,7 +116,7 @@ export class CliInstaller extends CliPathRegistration { throw new Error(initialStatus.detail ?? 'CLI registration is unavailable on this build.') } if (initialStatus.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${initialStatus.commandPath}.`) + throw new Error(`Refusing to replace non-Orca command at ${initialStatus.commandPath}. Remove it and register again if it is no longer needed.`) } const extractedRoot = await this.ensureLinuxAppImagePayload() const status = extractedRoot @@ -126,7 +126,7 @@ export class CliInstaller extends CliPathRegistration { throw new Error(status.detail ?? 'CLI registration is unavailable on this build.') } if (status.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`) + throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.`) } // eslint-disable-next-line unicorn/prefer-ternary -- Why: the install path performs async side effects and is easier to audit as an explicit branch than as an awaited ternary. diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index a3c102e1dfc..f8362fddc66 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -207,7 +207,7 @@ export class WslCliInstaller { throw new Error(status.detail ?? 'WSL CLI registration is unavailable.') } if (status.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`) + throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.`) } await this.run( From a310150e6a420317f0c6d795b4faaab60cb0f69a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:02:30 -0700 Subject: [PATCH 09/94] fix(linux): bound the CLI registration lock wait `retries: 1000` caps the attempt count, not elapsed time, so at up to 1s per attempt an IPC-driven registration could hang ~16 minutes against a wedged holder with no feedback. A legitimate holder is bounded by the extraction timeout, so wait that plus slack and then fail with a message naming the lock file, rather than hanging. `maxRetryTime` is forwarded verbatim to the `retry` package by proper-lockfile. --- .../cli/appimage-registration-lock.test.ts | 51 +++++++++++++++++++ src/main/cli/appimage-registration-lock.ts | 30 ++++++++--- 2 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 src/main/cli/appimage-registration-lock.test.ts diff --git a/src/main/cli/appimage-registration-lock.test.ts b/src/main/cli/appimage-registration-lock.test.ts new file mode 100644 index 00000000000..f32cf7d5b0d --- /dev/null +++ b/src/main/cli/appimage-registration-lock.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { lockMock, mkdirMock } = vi.hoisted(() => ({ lockMock: vi.fn(), mkdirMock: vi.fn() })) + +vi.mock('proper-lockfile', () => ({ lock: lockMock })) +vi.mock('node:fs/promises', () => ({ mkdir: mkdirMock })) + +afterEach(() => { + vi.resetModules() +}) + +async function load() { + mkdirMock.mockReset().mockResolvedValue(undefined) + return import('./appimage-registration-lock') +} + +describe('withAppImageRegistrationLock', () => { + it('bounds the wait with a wall-clock deadline, not just an attempt count', async () => { + lockMock.mockReset().mockResolvedValue(vi.fn().mockResolvedValue(undefined)) + const { withAppImageRegistrationLock } = await load() + + await withAppImageRegistrationLock('/cache/orca/appimage', async () => 'done') + + const options = lockMock.mock.calls[0][1] + // Why: `retries` alone caps attempts, not elapsed time — 1000 x 1s is ~16 minutes. + expect(options.retries.maxRetryTime).toBeGreaterThan(0) + expect(options.retries.maxRetryTime).toBeLessThanOrEqual(options.stale) + }) + + it('reports a wedged holder with a remedy instead of hanging', async () => { + lockMock.mockReset().mockRejectedValue(Object.assign(new Error('ELOCKED'), { code: 'ELOCKED' })) + const { withAppImageRegistrationLock } = await load() + + await expect( + withAppImageRegistrationLock('/cache/orca/appimage', async () => 'done') + ).rejects.toThrow(/Timed out waiting for another Orca process[\s\S]*remove .*\.lock/) + }) + + it('releases the lock when the operation throws', async () => { + const release = vi.fn().mockResolvedValue(undefined) + lockMock.mockReset().mockResolvedValue(release) + const { withAppImageRegistrationLock } = await load() + + await expect( + withAppImageRegistrationLock('/cache/orca/appimage', async () => { + throw new Error('boom') + }) + ).rejects.toThrow('boom') + expect(release).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/cli/appimage-registration-lock.ts b/src/main/cli/appimage-registration-lock.ts index c14573a4d24..7c6350e249f 100644 --- a/src/main/cli/appimage-registration-lock.ts +++ b/src/main/cli/appimage-registration-lock.ts @@ -5,12 +5,18 @@ import { APPIMAGE_EXTRACTION_TIMEOUT_MS } from './appimage-extraction-pruning' const LOCK_TARGET_NAME = '.cli-registration' const LOCK_STALE_MS = APPIMAGE_EXTRACTION_TIMEOUT_MS * 3 +// Why a wall-clock deadline: `retries` alone bounds the attempt count, not the wait — 1000 attempts +// at up to 1s each let an IPC-driven registration hang ~16 minutes against a wedged holder with no +// feedback. A legitimate holder is bounded by the extraction timeout, so anything past that plus +// slack is wedged, and failing with a message beats hanging. +const LOCK_ACQUIRE_DEADLINE_MS = APPIMAGE_EXTRACTION_TIMEOUT_MS + 30_000 const LOCK_RETRIES = { retries: 1_000, factor: 1.2, minTimeout: 25, maxTimeout: 1_000, - randomize: true + randomize: true, + maxRetryTime: LOCK_ACQUIRE_DEADLINE_MS } export async function withAppImageRegistrationLock( @@ -18,12 +24,22 @@ export async function withAppImageRegistrationLock( operation: () => Promise ): Promise { await mkdir(cacheRootPath, { recursive: true, mode: 0o700 }) - const release = await lock(join(cacheRootPath, LOCK_TARGET_NAME), { - realpath: false, - retries: LOCK_RETRIES, - stale: LOCK_STALE_MS, - update: APPIMAGE_EXTRACTION_TIMEOUT_MS / 10 - }) + let release: () => Promise + try { + release = await lock(join(cacheRootPath, LOCK_TARGET_NAME), { + realpath: false, + retries: LOCK_RETRIES, + stale: LOCK_STALE_MS, + update: APPIMAGE_EXTRACTION_TIMEOUT_MS / 10 + }) + } catch (error) { + throw new Error( + `Timed out waiting for another Orca process to finish CLI registration ` + + `(waited ${Math.round(LOCK_ACQUIRE_DEADLINE_MS / 1000)}s). ` + + `If no other Orca is running, remove ${join(cacheRootPath, LOCK_TARGET_NAME)}.lock and retry.`, + { cause: error } + ) + } try { return await operation() } finally { From 28214e1ea13b8f4a8c172ce82de21a6b63ac6b84 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:24:51 -0700 Subject: [PATCH 10/94] fix(linux): stop re-extracting the AppImage on inode metadata churn The extracted-payload cache key hashed ctime alongside dev/ino/size/mtime. ctime moves on any inode metadata write -- `chmod +x`, which every AppImage user is told to run, plus `chown`, an ACL or SELinux relabel, and a backup restore -- none of which alter a byte of the payload. Measured on Ubuntu 24.04: `chmod +x` leaves dev, ino, size and mtime identical and moves ctime alone, so the key changed and the next launch paid a full ~519 MB re-extraction and a multi-second stall to rebuild a payload it already had, then pruned the old generation. Key on content identity instead. An in-place content change moves mtime and almost always size; a replacement moves the inode. The existing replace-in-place test still passes. --- src/main/cli/appimage-extracted-root.test.ts | 15 ++++++++++++++- src/main/cli/appimage-extracted-root.ts | 11 ++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/main/cli/appimage-extracted-root.test.ts b/src/main/cli/appimage-extracted-root.test.ts index 3d50e0f6687..dbc7c95613b 100644 --- a/src/main/cli/appimage-extracted-root.test.ts +++ b/src/main/cli/appimage-extracted-root.test.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -106,6 +106,19 @@ describe('appimage extracted root', () => { ) }) + // Why: `chmod +x` is what every AppImage user is told to run, and a backup restore or SELinux + // relabel does the same thing. Re-keying on that cost a full re-extraction of an unchanged payload. + it('does not re-key the payload when only inode metadata changes', async () => { + const { appImagePath } = await makeFixture() + const before = resolveAppImageCacheKey(appImagePath) + + await chmod(appImagePath, 0o700) + expect(resolveAppImageCacheKey(appImagePath)).toBe(before) + + await chmod(appImagePath, 0o755) + expect(resolveAppImageCacheKey(appImagePath)).toBe(before) + }) + // Why: a crashed extraction must not leave a directory that later reads treat // as a usable payload — the command would exec a path that does not exist. it('publishes nothing when extraction fails partway', async () => { diff --git a/src/main/cli/appimage-extracted-root.ts b/src/main/cli/appimage-extracted-root.ts index 93bca0feed6..36d3a8b5493 100644 --- a/src/main/cli/appimage-extracted-root.ts +++ b/src/main/cli/appimage-extracted-root.ts @@ -44,10 +44,19 @@ export function getAppImageCacheRootPath(homePath = homedir()): string { return join(cacheHome, ...CACHE_DIR_SEGMENTS) } +/** + * Identity of the AppImage's *content*, used to key its extracted generation. + * + * Deliberately excludes ctime: it changes on any inode metadata write — `chmod +x` (which every + * AppImage user is told to run), `chown`, an ACL or SELinux relabel, a backup restore — none of + * which alter a byte of the payload. Including it re-keyed the cache on those, costing a full + * ~519 MB re-extraction and a multi-second stall for nothing. An in-place content change moves + * mtime and almost always size; a replacement moves the inode. + */ export function resolveAppImageCacheKey(appImagePath: string): string | null { try { const stats = statSync(appImagePath) - return digest(`${stats.dev}\0${stats.ino}\0${stats.size}\0${stats.mtimeMs}\0${stats.ctimeMs}`) + return digest(`${stats.dev}\0${stats.ino}\0${stats.size}\0${stats.mtimeMs}`) } catch { return null } From 7314ada3fb6d1ff02ccdf8a0572c4daaed78b89f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:31:23 -0700 Subject: [PATCH 11/94] fix(native-chat): ignore stale restored working status (#17995) * fix(native-chat): ignore stale restored working status * fix(native-chat): sample status freshness per epoch * Revert "fix(native-chat): sample status freshness per epoch" This reverts commit 8e49b1badd27253c56e8eb6b0de05559a761d2bb. * test(native-chat): include hook status timestamps * test(native-chat): include visibility hook timestamp --------- Co-authored-by: Merge Sim --- .../use-native-chat-hook-status.test.ts | 62 +++++++++++++++++++ .../use-native-chat-hook-status.ts | 31 ++++++++-- ...ative-chat-live-session-visibility.test.ts | 2 +- .../use-native-chat-live-session.test.ts | 39 +++++++++--- 4 files changed, 120 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts diff --git a/src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts b/src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts new file mode 100644 index 00000000000..90d31d09e3d --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { resolveNativeChatHookState } from './use-native-chat-hook-status' + +describe('resolveNativeChatHookState', () => { + const now = 1_000_000 + + it('does not treat a restored working row as live activity', () => { + expect( + resolveNativeChatHookState( + { + state: 'working', + workingMode: undefined, + updatedAt: now, + restoredUnconfirmed: true + }, + now + ) + ).toBeNull() + }) + + it('keeps confirmed working activity live', () => { + expect( + resolveNativeChatHookState( + { + state: 'working', + workingMode: undefined, + updatedAt: now, + restoredUnconfirmed: false + }, + now + ) + ).toBe('working') + }) + + it('continues to suppress monitoring rows', () => { + expect( + resolveNativeChatHookState( + { + state: 'working', + workingMode: 'monitoring', + updatedAt: now, + restoredUnconfirmed: false + }, + now + ) + ).toBeNull() + }) + + it('does not keep an expired working row live', () => { + expect( + resolveNativeChatHookState( + { + state: 'working', + workingMode: undefined, + updatedAt: now - 30 * 60 * 1000 - 1, + restoredUnconfirmed: false + }, + now + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts index 687dbc83ad2..5cfa0f11679 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts @@ -1,16 +1,39 @@ import { useAppStore } from '../../store' -import type { AgentStatusState } from '../../../../shared/agent-status-types' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentStatusState +} from '../../../../shared/agent-status-types' + +/** + * Hydrated nonterminal rows are only recovery evidence until a live hook event + * confirms the turn. They must not make Native Chat look permanently busy. + */ +export function resolveNativeChatHookState( + entry: + | Pick + | undefined, + now = Date.now() +): AgentStatusState | null { + if (!entry || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + return null + } + return entry.state === 'working' && entry.workingMode === 'monitoring' ? null : entry.state +} export function useNativeChatHookStatus( paneKey: string ): readonly [AgentStatusState | null, number | null, boolean] { + // Freshness is time-based; subscribe to the scheduler epoch so a silent + // working row stops driving Native Chat when its TTL expires. + const agentStatusEpoch = useAppStore((store) => store.agentStatusEpoch) + void agentStatusEpoch // Why: primitive selectors keep unrelated pane/status updates from rerendering // native chat while still exposing the three fields used for reconciliation. const state = useAppStore((store) => { const entry = store.agentStatusByPaneKey[paneKey] - return entry?.state === 'working' && entry.workingMode === 'monitoring' - ? null - : (entry?.state ?? null) + return resolveNativeChatHookState(entry) }) const stateStartedAt = useAppStore( (store) => store.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts index a4855b9600b..80002ac263d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts @@ -122,7 +122,7 @@ describe('useNativeChatLiveSession visibility', () => { it('unsubscribes on hide, retains committed messages, and rejects hidden work', async () => { useAppStore.setState({ agentStatusByPaneKey: { - [BASE_ARGS.paneKey]: { state: 'working', stateStartedAt: 100 } + [BASE_ARGS.paneKey]: { state: 'working', stateStartedAt: 100, updatedAt: Date.now() } } } as never) await render(BASE_ARGS) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index 3bb2e2785e4..d08eb4bd6d6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -385,7 +385,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it("self-heals a stale 'working' hook once the turn-complete marker lands", async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -403,7 +405,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('stops foreground working UI when Claude enters monitoring', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -419,7 +423,12 @@ describe('useNativeChatLiveSession — transport routing', () => { await act(async () => { useAppStore.setState({ agentStatusByPaneKey: { - [PANE]: { state: 'working', workingMode: 'monitoring', stateStartedAt: 1 } as never + [PANE]: { + state: 'working', + workingMode: 'monitoring', + stateStartedAt: 1, + updatedAt: Date.now() + } as never } }) }) @@ -429,7 +438,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('applies a lifecycle-only append after the final message frame', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -456,7 +467,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('applies a terminal-side interruption frame without a local Stop action', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -483,7 +496,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('does not let an older pagination read rewind a live completion', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, index) => @@ -529,7 +544,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('reconciles completion from a reconnect snapshot', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 10 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 10, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -557,7 +574,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('reconciles interruption from a reconnect snapshot', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 10 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 10, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -708,7 +727,9 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } + } } as never) await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) From 7873f73d807fa7bec80803cdf8ac36cd1f1c4316 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:52:43 -0400 Subject: [PATCH 12/94] fix(daemon): keep attach cancellation behind client timeout (#17816) --- .../daemon/daemon-client-rpc-request.test.ts | 57 +++++++++++++++++++ src/main/daemon/daemon-client-rpc-request.ts | 20 +++++-- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/main/daemon/daemon-client-rpc-request.test.ts b/src/main/daemon/daemon-client-rpc-request.test.ts index b495ee07425..75d9d9edfd6 100644 --- a/src/main/daemon/daemon-client-rpc-request.test.ts +++ b/src/main/daemon/daemon-client-rpc-request.test.ts @@ -21,9 +21,66 @@ function addSiblingRequest(pendingRequests: DaemonPendingRequests, reject: () => describe('requestDaemonRpc', () => { afterEach(() => { + vi.restoreAllMocks() vi.useRealTimers() }) + it('keeps the daemon attach guard behind the client timeout window', async () => { + const pendingRequests = new DaemonPendingRequests() + const abort = new AbortController() + const write = vi.fn() + const request = requestDaemonRpc({ + socket: { write } as unknown as Socket, + pendingRequests, + id: 'req-1', + type: 'createOrAttach', + payload: { sessionId: 'guarded-spawn' }, + timeoutMs: 30_000, + signal: abort.signal, + unmatchedCancelGraceMs: 5_000, + onCreateCancellationFailure: vi.fn(), + settleCreateCancellation: vi.fn(async () => ({ canceled: true })) + }) + + expect(JSON.parse(String(write.mock.calls[0]?.[0]))).toMatchObject({ + payload: { sessionId: 'guarded-spawn', cancelAfterMs: 35_000 } + }) + abort.abort() + await expect(request).rejects.toThrow('client_disconnected') + }) + + it('classifies a cancellation delivered after an overdue timeout as a timeout', async () => { + vi.useFakeTimers() + const monotonicNow = vi.spyOn(performance, 'now').mockReturnValue(0) + const pendingRequests = new DaemonPendingRequests() + const settleCreateCancellation = vi.fn(() => new Promise<{ canceled: boolean }>(() => {})) + const request = requestDaemonRpc({ + socket: { write: vi.fn() } as unknown as Socket, + pendingRequests, + id: 'req-1', + type: 'createOrAttach', + payload: { sessionId: 'deadline-spawn' }, + timeoutMs: 10, + unmatchedCancelGraceMs: 5, + onCreateCancellationFailure: vi.fn(), + settleCreateCancellation + }) + const rejected = expect(request).rejects.toMatchObject({ + name: 'DaemonRequestTimeoutError', + message: 'Request createOrAttach timed out after 10ms' + }) + + monotonicNow.mockReturnValue(16) + pendingRequests.settle({ + id: 'req-1', + ok: false, + error: 'Attach canceled for session deadline-spawn' + }) + + await rejected + expect(settleCreateCancellation).not.toHaveBeenCalled() + }) + it('keeps a completed spawn result when cancellation arrives too late', async () => { const pendingRequests = new DaemonPendingRequests() const abort = new AbortController() diff --git a/src/main/daemon/daemon-client-rpc-request.ts b/src/main/daemon/daemon-client-rpc-request.ts index 33936a9286a..fb72538eaa0 100644 --- a/src/main/daemon/daemon-client-rpc-request.ts +++ b/src/main/daemon/daemon-client-rpc-request.ts @@ -54,20 +54,27 @@ function wedgedDaemonError(requestError: Error, cancelError: unknown): Error | n } export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise { + // A stalled event loop can deliver a daemon cancellation before its overdue timer runs. const { payload, type } = opts + const createTimeoutError = (): DaemonRequestTimeoutError => + new DaemonRequestTimeoutError(`Request ${type} timed out after ${opts.timeoutMs}ms`) const createSessionId = type === 'createOrAttach' && payload !== null && typeof payload === 'object' ? Reflect.get(payload, 'sessionId') : null const requestPayload = type === 'createOrAttach' && payload !== null && typeof payload === 'object' - ? { ...payload, cancelAfterMs: Math.max(1, opts.timeoutMs - 100) } + ? { + ...payload, + cancelAfterMs: Math.max(1, opts.timeoutMs + opts.unmatchedCancelGraceMs) + } : payload const encoded = encodeNdjson({ id: opts.id, type, ...(requestPayload !== undefined ? { payload: requestPayload } : {}) }) + const clientDeadlineMs = performance.now() + opts.timeoutMs return new Promise((resolve, reject) => { let sent = false @@ -146,9 +153,7 @@ export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise { }) } const timer = setTimeout(() => { - const error = new DaemonRequestTimeoutError( - `Request ${type} timed out after ${opts.timeoutMs}ms` - ) + const error = createTimeoutError() if (typeof createSessionId === 'string') { cancelCreate(error) } else { @@ -172,8 +177,11 @@ export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise { removeAbortListener() clearTimers() reject( - cancellationError !== null && isTerminalAttachCanceledMessage(error.message) - ? cancellationError + isTerminalAttachCanceledMessage(error.message) + ? (cancellationError ?? + (type === 'createOrAttach' && performance.now() >= clientDeadlineMs + ? createTimeoutError() + : error)) : error ) }, From 93a258c81d2dd39e1fd141535fc624022afac1d8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:59:03 -0700 Subject: [PATCH 13/94] fix(worktrees): reclaim orphaned pr-* fork remotes (#17842) * fix(worktrees): reclaim orphaned pr-* fork remotes pr-* remotes Orca adds for fork-PR worktrees were only ever pruned by a single worktree's own removal, and only when that removal had complete provenance metadata, no branch pinning it, and actually ran through Orca. Legacy metadata missing remoteCreated, "preserve branch on delete" pinning the remote via branch.*.remote config after the worktree is gone, and worktrees removed outside Orca entirely all left the remote behind forever -- one real user accumulated ~50 leaked remotes this way. Add a repo-scoped reconciliation sweep that inverts the existing cleanup predicates over every pr-* remote instead of one removal, reusing sameGitHubRemoteUrl/hasBranchConfigUsingRemote so no new safety logic is introduced. It only touches a remote some worktree's persisted pushTarget explicitly recorded Orca creating (remoteCreated: true) -- naming and URL shape alone are not proof of provenance. Runs opportunistically alongside existing single-target cleanup (including RuntimePreservedBranchCleanup's force-delete path), rate-limited per repo, and fire-and-forget so it never adds latency to the worktree-removal path a user is waiting on. Fixes #17828 * test(worktrees): set a local git identity in the pr-remote fixture CI runners have no global git identity, so `git commit` in the fixture repos failed with "Author identity unknown" -- only passed locally because dev machines have one. Set user.name/user.email (plus commit.gpgSign and core.hooksPath, matching src/main/git/repo-remote-drift-real.test.ts) as local repo config in both the main and cloned "fork" fixture repos, so the test is independent of the runner's global config, signing setup, or hooks. --- .../ipc/worktree-push-target-cleanup.test.ts | 66 +++++ src/main/ipc/worktree-push-target-cleanup.ts | 138 +++++++-- ...ush-target-reconciliation-real-git.test.ts | 173 ++++++++++++ ...orktree-push-target-reconciliation.test.ts | 262 ++++++++++++++++++ .../worktree-push-target-reconciliation.ts | 204 ++++++++++++++ src/main/ipc/worktree-remote.ts | 24 ++ 6 files changed, 837 insertions(+), 30 deletions(-) create mode 100644 src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts create mode 100644 src/main/ipc/worktree-push-target-reconciliation.test.ts create mode 100644 src/main/ipc/worktree-push-target-reconciliation.ts diff --git a/src/main/ipc/worktree-push-target-cleanup.test.ts b/src/main/ipc/worktree-push-target-cleanup.test.ts index 577235c86dd..0b736acd482 100644 --- a/src/main/ipc/worktree-push-target-cleanup.test.ts +++ b/src/main/ipc/worktree-push-target-cleanup.test.ts @@ -4,6 +4,8 @@ import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitPushTarget } from '../../shared/worktree/types' import { cleanupUnusedWorktreePushTargetRemoteWithExec, + findWorktreeMetaReferencingRemote, + hasBranchConfigUsingRemote, sameGitHubRemoteUrl, type GitRemoteExec, type WorktreePushTargetStore @@ -253,6 +255,70 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => { }) }) +describe('hasBranchConfigUsingRemote', () => { + it('requireExistingBranch: false (default) protects on config alone, even if the branch is gone', async () => { + const exec = makeExec({ branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}` }) + await expect(hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget())).resolves.toBe(true) + }) + + it('requireExistingBranch: true only protects when the referencing branch still exists', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: `branch.contributor/fix.remote ${FORK_REMOTE}`, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'main\ncontributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + await expect( + hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true }) + ).resolves.toBe(true) + }) + + it('requireExistingBranch: true does not protect on a stale config entry from a deleted branch', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: `branch.contributor/fix.remote ${FORK_REMOTE}`, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'main\n', stderr: '' } // contributor/fix no longer exists + } + return { stdout: '', stderr: '' } + }) + await expect( + hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true }) + ).resolves.toBe(false) + }) + + it('extracts branch names containing dots correctly', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: `branch.release/1.2.3.remote ${FORK_REMOTE}`, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'release/1.2.3\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + await expect( + hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true }) + ).resolves.toBe(true) + }) +}) + +describe('findWorktreeMetaReferencingRemote', () => { + it('scopes matches to the given repo id and excludes worktrees without a pushTarget', () => { + const store = storeOf({ + 'repo-1::/wt/a': forkTarget(), + 'repo-1::/wt/b': undefined, + 'repo-2::/wt/c': forkTarget() + }) + const matches = findWorktreeMetaReferencingRemote(store, 'repo-1', forkTarget()) + expect(matches.map((match) => match.worktreeId)).toEqual(['repo-1::/wt/a']) + }) +}) + describe('sameGitHubRemoteUrl', () => { it('matches SSH and HTTPS forms of the same GitHub fork', () => { expect( diff --git a/src/main/ipc/worktree-push-target-cleanup.ts b/src/main/ipc/worktree-push-target-cleanup.ts index 2bda01c5a89..9bf29f718b2 100644 --- a/src/main/ipc/worktree-push-target-cleanup.ts +++ b/src/main/ipc/worktree-push-target-cleanup.ts @@ -1,9 +1,12 @@ // Why: fork-PR worktrees can add a contributor's fork as a git remote. When such // a worktree is deleted we prune that remote, but only when it's truly unused. // This module holds that decision logic behind an injectable `execGit` boundary so -// the multi-fork cleanup matrix is unit-testable without a real repo. +// the multi-fork cleanup matrix is unit-testable without a real repo. The same +// predicates back the periodic sweep in `worktree-push-target-reconciliation.ts`, +// which inverts them over every `pr-*` remote instead of one removed worktree. import type { Store } from '../persistence' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitPushTarget } from '../../shared/worktree/types' import { parseGitHubOwnerRepo } from '../github/gh-utils' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' @@ -29,61 +32,113 @@ export function sameGitHubRemoteUrl(left: string, right: string): boolean { ) } +/** A worktree metadata entry, in the same repo as `target`, whose pushTarget references it. */ +export type WorktreeMetaReferencingRemote = { worktreeId: string; meta: WorktreeMeta } + +// Exported so the reconciliation sweep can inspect *which* worktrees reference a remote +// (to check liveness/provenance) instead of only the single-target yes/no this file needs. +export function findWorktreeMetaReferencingRemote( + store: WorktreePushTargetStore, + repoId: string, + target: Pick +): WorktreeMetaReferencingRemote[] { + return Object.entries(store.getAllWorktreeMeta()) + .filter(([worktreeId, meta]) => { + // Why: git remotes are repo-local; matching metadata from another repo + // must not pin this repo's fork remote forever. + if (getRepoIdFromWorktreeId(worktreeId) !== repoId || !meta.pushTarget) { + return false + } + const otherRemoteUrl = meta.pushTarget.remoteUrl + const targetRemoteUrl = target.remoteUrl + return ( + meta.pushTarget.remoteName === target.remoteName || + (typeof otherRemoteUrl === 'string' && + typeof targetRemoteUrl === 'string' && + sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl)) + ) + }) + .map(([worktreeId, meta]) => ({ worktreeId, meta })) +} + function isPushTargetUsedByAnotherWorktree( store: WorktreePushTargetStore, removedWorktreeId: string, target: GitPushTarget ): boolean { const removedRepoId = getRepoIdFromWorktreeId(removedWorktreeId) - return Object.entries(store.getAllWorktreeMeta()).some(([worktreeId, meta]) => { - // Why: git remotes are repo-local; matching metadata from another repo - // must not pin this repo's fork remote forever. - const belongsToSameRepo = getRepoIdFromWorktreeId(worktreeId) === removedRepoId - if (worktreeId === removedWorktreeId || !belongsToSameRepo || !meta.pushTarget) { - return false - } - const otherRemoteUrl = meta.pushTarget.remoteUrl - const targetRemoteUrl = target.remoteUrl - return ( - meta.pushTarget.remoteName === target.remoteName || - (typeof otherRemoteUrl === 'string' && - typeof targetRemoteUrl === 'string' && - sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl)) - ) - }) + return findWorktreeMetaReferencingRemote(store, removedRepoId, target).some( + ({ worktreeId }) => worktreeId !== removedWorktreeId + ) } -async function hasBranchConfigUsingRemote( +export type BranchConfigMatch = { branchName: string } + +// Exported for the sweep, which additionally verifies each matched branch still exists +// before treating it as a reason to keep the remote (`requireExistingBranch`). +export async function hasBranchConfigUsingRemote( execGit: GitRemoteExec, repoPath: string, - target: GitPushTarget + target: Pick, + options: { requireExistingBranch?: boolean } = {} +): Promise { + let stdout: string + try { + ;({ stdout } = await execGit( + ['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'], + repoPath + )) + } catch { + return false + } + const matches: BranchConfigMatch[] = [] + // Why: git config output can be large; avoid materializing line/split arrays here. + for (const line of iterateProcessOutputLines(stdout)) { + const parsed = parseBranchRemoteConfigLine(line) + if (parsed && (parsed.value === target.remoteName || parsed.value === target.remoteUrl)) { + matches.push({ branchName: parsed.branchName }) + } + } + if (matches.length === 0) { + return false + } + if (!options.requireExistingBranch) { + return true + } + return branchesExist( + execGit, + repoPath, + matches.map((match) => match.branchName) + ) +} + +async function branchesExist( + execGit: GitRemoteExec, + repoPath: string, + branchNames: string[] ): Promise { try { const { stdout } = await execGit( - ['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'], + ['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], repoPath ) - // Why: git config output can be large; avoid materializing line/split arrays here. - for (const line of iterateProcessOutputLines(stdout)) { - const value = readBranchRemoteConfigValue(line) - if (value === target.remoteName || value === target.remoteUrl) { - return true - } - } - return false + const existingBranches = new Set(iterateProcessOutputLines(stdout)) + return branchNames.some((branchName) => existingBranches.has(branchName)) } catch { return false } } -function readBranchRemoteConfigValue(line: string): string | null { +function parseBranchRemoteConfigLine(line: string): { branchName: string; value: string } | null { let index = 0 while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) { index += 1 } + const keyStart = index while (index < line.length && !isBranchConfigSeparator(line.charCodeAt(index))) { index += 1 } + const key = line.slice(keyStart, index) while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) { index += 1 } @@ -96,7 +151,30 @@ function readBranchRemoteConfigValue(line: string): string | null { while (valueEnd > valueStart && isBranchConfigSeparator(line.charCodeAt(valueEnd - 1))) { valueEnd -= 1 } - return valueStart < valueEnd ? line.slice(valueStart, valueEnd) : null + if (valueStart >= valueEnd) { + return null + } + const branchName = extractBranchNameFromConfigKey(key) + return branchName ? { branchName, value: line.slice(valueStart, valueEnd) } : null +} + +// `branch..remote` / `branch..pushRemote`; `` may itself contain dots +// (e.g. `release/1.2.3`), so only the known trailing suffix is stripped. +function extractBranchNameFromConfigKey(key: string): string | null { + const prefix = 'branch.' + if (!key.startsWith(prefix)) { + return null + } + const rest = key.slice(prefix.length) + const lastDot = rest.lastIndexOf('.') + if (lastDot <= 0) { + return null + } + const suffix = rest.slice(lastDot + 1) + if (suffix !== 'remote' && suffix !== 'pushRemote') { + return null + } + return rest.slice(0, lastDot) } function isBranchConfigSeparator(code: number): boolean { diff --git a/src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts b/src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts new file mode 100644 index 00000000000..3c226b3475f --- /dev/null +++ b/src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts @@ -0,0 +1,173 @@ +// Real-binary coverage for the pr-* remote reconciliation sweep (#17828): the mocked-exec suite +// proves the decision matrix, but not that `git remote -v`, `git config --get-regexp`, and +// `git for-each-ref` are parsed correctly against real Git output. +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup' +import { reconcileOrphanedPrRemotesWithExec } from './worktree-push-target-reconciliation' + +const execFileAsync = promisify(execFile) + +const REPO_ID = 'repo-1' +const FORK_REMOTE = 'pr-contributor-orca' + +let scratchDir = '' +let repoPath = '' +let forkPath = '' + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +const execGit: GitRemoteExec = (args, cwd) => execFileAsync('git', args, { cwd }) + +function worktreeId(suffix: string): string { + return `${REPO_ID}::${suffix}` +} + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: FORK_REMOTE, + branchName: 'contributor/fix', + remoteUrl: forkPath, + remoteCreated: true, + ...overrides + } +} + +function storeOf(entries: Record): WorktreePushTargetStore { + const meta: Record = {} + for (const [id, pushTarget] of Object.entries(entries)) { + meta[id] = { pushTarget } as unknown as WorktreeMeta + } + return { getAllWorktreeMeta: () => meta } +} + +beforeEach(async () => { + // realpath: macOS hands out /var/... temp paths while Git reports /private/var/... + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-pr-remote-reconcile-'))) + repoPath = join(scratchDir, 'repo') + forkPath = join(scratchDir, 'fork') + await mkdir(repoPath, { recursive: true }) + await git(['init', '-q'], repoPath) + await git(['config', 'user.name', 'Orca Test'], repoPath) + await git(['config', 'user.email', 'orca@example.test'], repoPath) + await git(['config', 'commit.gpgSign', 'false'], repoPath) + await git(['config', 'core.hooksPath', '.git/no-hooks'], repoPath) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + await git(['add', '-A'], repoPath) + await git(['commit', '-qm', 'seed'], repoPath) + + // A second local "fork" repo the pr-* remote points at, so `remote add`/fetch behave normally. + await git(['clone', '-q', repoPath, forkPath], scratchDir) + await git(['config', 'user.name', 'Orca Test'], forkPath) + await git(['config', 'user.email', 'orca@example.test'], forkPath) + await git(['config', 'commit.gpgSign', 'false'], forkPath) + await git(['config', 'core.hooksPath', '.git/no-hooks'], forkPath) + await git(['checkout', '-qb', 'contributor/fix'], forkPath) + await writeFile(join(forkPath, 'fork.txt'), 'fork change\n') + await git(['add', '-A'], forkPath) + await git(['commit', '-qm', 'fork change'], forkPath) + + await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath) + await git( + [ + 'fetch', + FORK_REMOTE, + `+refs/heads/contributor/fix:refs/remotes/${FORK_REMOTE}/contributor/fix` + ], + repoPath + ) +}) + +afterEach(async () => { + await rm(scratchDir, { recursive: true, force: true }) +}) + +describe('reconcileOrphanedPrRemotesWithExec against the real Git binary', () => { + it('leaves a user-created remote alone: naming/URL shape is not proof of provenance', async () => { + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({}), // no worktree metadata anywhere claims this remote + execGit, + [] + ) + expect(reclaimed).toEqual([]) + await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE) + }) + + it('leaves the remote alone while a live worktree still references it', async () => { + const worktreePath = join(scratchDir, 'wt-live') + await git(['worktree', 'add', '-q', worktreePath, '-b', 'contributor/fix-local'], repoPath) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(worktreePath)]: forkTarget() }), + execGit, + [worktreePath] + ) + expect(reclaimed).toEqual([]) + await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE) + }) + + it('leaves the remote alone while its branch still exists (preserve-on-delete kept alive)', async () => { + await git(['branch', 'contributor/fix', `${FORK_REMOTE}/contributor/fix`], repoPath) + await git(['config', `branch.contributor/fix.remote`, FORK_REMOTE], repoPath) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(join(scratchDir, 'wt-gone'))]: forkTarget() }), + execGit, + [] // the worktree that created it is gone, but the branch it preserved is not + ) + expect(reclaimed).toEqual([]) + await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE) + }) + + it('reclaims the remote once the branch that pinned it is deleted (path 2)', async () => { + await git(['branch', 'contributor/fix', `${FORK_REMOTE}/contributor/fix`], repoPath) + await git(['config', `branch.contributor/fix.remote`, FORK_REMOTE], repoPath) + // Delete only the ref, leaving the config behind, exactly as `update-ref -d` alone would -- + // proving the sweep checks branch existence rather than trusting stale config. + await rm(join(repoPath, '.git', 'refs', 'heads', 'contributor', 'fix')) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(join(scratchDir, 'wt-gone'))]: forkTarget() }), + execGit, + [] + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + await expect(git(['remote'], repoPath)).resolves.not.toContain(FORK_REMOTE) + }) + + it('reclaims a remote orphaned by a worktree removed outside Orca (path 3)', async () => { + const worktreePath = join(scratchDir, 'wt-externally-removed') + await git(['worktree', 'add', '-q', worktreePath, '-b', 'contributor/fix-local-2'], repoPath) + // Simulate a plain `git worktree remove` the user ran outside Orca: Orca's metadata for + // that worktree is still sitting in the store (nothing told it to clean up), but the + // worktree itself is gone. + await git(['worktree', 'remove', '--force', worktreePath], repoPath) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(worktreePath)]: forkTarget() }), + execGit, + [] // listWorktrees no longer reports it + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + await expect(git(['remote'], repoPath)).resolves.not.toContain(FORK_REMOTE) + }) +}) diff --git a/src/main/ipc/worktree-push-target-reconciliation.test.ts b/src/main/ipc/worktree-push-target-reconciliation.test.ts new file mode 100644 index 00000000000..ed29809a151 --- /dev/null +++ b/src/main/ipc/worktree-push-target-reconciliation.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it, vi, type Mock } from 'vitest' +import { validateGitExecArgs } from '../../relay/git-exec-validator' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup' +import { + _resetPrRemoteReconciliationRateLimitForTests, + isOrcaGeneratedPrRemoteName, + reconcileOrphanedPrRemotesWithExec +} from './worktree-push-target-reconciliation' + +type ExecMock = Mock + +const REPO_PATH = '/repo-root' +const REPO_ID = 'repo-1' +const FORK_URL = 'git@github.com:contributor/orca.git' +const FORK_REMOTE = 'pr-contributor-orca' + +function worktreeId(suffix: string): string { + return `${REPO_ID}::${suffix}` +} + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: FORK_REMOTE, + branchName: 'contributor/fix', + remoteUrl: FORK_URL, + remoteCreated: true, + ...overrides + } +} + +function metaWith(pushTarget: GitPushTarget | undefined): WorktreeMeta { + return { pushTarget } as unknown as WorktreeMeta +} + +function storeOf(entries: Record): WorktreePushTargetStore { + const meta: Record = {} + for (const [id, pushTarget] of Object.entries(entries)) { + meta[id] = metaWith(pushTarget) + } + return { getAllWorktreeMeta: () => meta } +} + +type ExecScript = { + remotes?: string + branchConfig?: string + localBranches?: string +} + +function makeExec(script: ExecScript = {}): ExecMock { + const { remotes = '', branchConfig = '', localBranches = '' } = script + return vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === '-v') { + return { stdout: remotes, stderr: '' } + } + if (args[0] === 'config') { + return { stdout: branchConfig, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: localBranches, stderr: '' } + } + if (args[0] === 'remote' && args[1] === 'remove') { + return { stdout: '', stderr: '' } + } + return { stdout: '', stderr: '' } + }) +} + +function remoteLines(entries: { name: string; url: string }[]): string { + return entries + .flatMap(({ name, url }) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join('\n') +} + +function removeCalls(exec: ExecMock): string[][] { + return exec.mock.calls + .map(([args]) => args) + .filter((args) => args[0] === 'remote' && args[1] === 'remove') +} + +describe('isOrcaGeneratedPrRemoteName', () => { + it('matches Orca-generated names, including disambiguated ones', () => { + expect(isOrcaGeneratedPrRemoteName('pr-head')).toBe(true) + expect(isOrcaGeneratedPrRemoteName('pr-contributor-orca')).toBe(true) + expect(isOrcaGeneratedPrRemoteName('pr-head-2')).toBe(true) + expect(isOrcaGeneratedPrRemoteName('pr-contributor-orca-3')).toBe(true) + }) + + it('does not match unrelated remote names', () => { + expect(isOrcaGeneratedPrRemoteName('origin')).toBe(false) + expect(isOrcaGeneratedPrRemoteName('upstream')).toBe(false) + expect(isOrcaGeneratedPrRemoteName('project-remote')).toBe(false) + }) +}) + +describe('reconcileOrphanedPrRemotesWithExec', () => { + it('leaves a remote alone when no worktree metadata ever proves Orca created it (user-created, ambiguous)', async () => { + // Same naming shape a user could coincidentally pick; no pushTarget anywhere claims it. + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({}), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('leaves a remote alone that is not shaped like an Orca-generated pr-* remote', async () => { + const exec = makeExec({ + remotes: remoteLines([{ name: 'my-fork', url: FORK_URL }]) + }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: { ...forkTarget(), remoteName: 'my-fork' } }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('leaves a remote alone that a live worktree still references (path guard)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: forkTarget() }), + exec, + ['/wt/a'] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('leaves a remote alone that is referenced by an existing branch (path 2, branch still kept)', async () => { + const exec = makeExec({ + remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]), + branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`, + localBranches: 'contributor/fix\nmain' + }) + // Metadata for the worktree that created it is gone, but the branch it preserved lives on. + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('reclaims a remote whose protecting branch config is stale (path 2, branch since deleted)', async () => { + const exec = makeExec({ + remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]), + // Config line survives even though `contributor/fix` no longer exists. + branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`, + localBranches: 'main' + }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) + }) + + it('reclaims a remote left behind by a worktree removed outside Orca (path 3)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + // Metadata still records the (now-vanished) worktree's Orca-created pushTarget. + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] // no live worktrees at all + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) + }) + + it('reclaims a remote even when the only referencing metadata lacks remoteCreated, as long as another entry proves provenance (path 1)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ + // The worktree whose removal originally bailed (legacy metadata, no remoteCreated flag)... + [worktreeId('/wt/legacy')]: forkTarget({ remoteCreated: false }), + // ...but a sibling that reused the remote correctly inherited ownership, and is also gone. + [worktreeId('/wt/sibling-gone')]: forkTarget({ remoteCreated: true }) + }), + exec, + [] + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + }) + + it('never touches origin or upstream even if metadata is malformed', async () => { + const exec = makeExec({ + remotes: remoteLines([ + { name: 'origin', url: FORK_URL }, + { name: 'upstream', url: FORK_URL } + ]) + }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: forkTarget({ remoteName: 'origin' }) }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('scopes provenance and liveness to the same repo (remotes are repo-local)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ 'repo-2::/wt/other-repo': forkTarget() }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + }) + + it('sends only argv the relay accepts, so the sweep is not silently skipped over SSH', async () => { + // Exercise every branch (config probe, for-each-ref probe, and the reclaim itself). + const exec = makeExec({ + remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]), + branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`, + localBranches: 'main' + }) + await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] + ) + expect(exec.mock.calls.length).toBeGreaterThan(0) + for (const [args] of exec.mock.calls) { + expect(() => validateGitExecArgs(args)).not.toThrow() + } + }) +}) + +describe('reconcileOrphanedPrRemotes rate limiting', () => { + it('exposes a test reset so repeated test runs are not affected by prior cooldowns', () => { + expect(() => _resetPrRemoteReconciliationRateLimitForTests()).not.toThrow() + }) +}) diff --git a/src/main/ipc/worktree-push-target-reconciliation.ts b/src/main/ipc/worktree-push-target-reconciliation.ts new file mode 100644 index 00000000000..e59da7bdfe7 --- /dev/null +++ b/src/main/ipc/worktree-push-target-reconciliation.ts @@ -0,0 +1,204 @@ +// Why: `pr-*` remotes Orca adds for fork-PR review are only ever pruned by +// `worktree-push-target-cleanup.ts`, and only when a *single* worktree removal +// triggers it. Three things escape that: (1) legacy/reused metadata missing the +// `remoteCreated` flag, (2) a "preserve branch on delete" pinning its remote via +// `branch.*.remote` config long after the worktree is gone, and (3) a worktree +// removed outside Orca entirely (no removal event ever fires). This sweep +// inverts the same safety predicates over every `pr-*` remote in the repo +// instead of one removal, so all three eventually get reclaimed. It never adds +// new safety logic — see `worktree-push-target-cleanup.ts` for the predicates. + +import { gitExecFileAsync } from '../git/runner' +import { listWorktrees } from '../git/worktree' +import type { SshGitProvider } from '../providers/ssh-git-provider' +import type { GitPushTarget } from '../../shared/worktree/types' +import { WORKTREE_ID_SEPARATOR, worktreeIdComparisonKey } from '../../shared/worktree/id' +import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' +import { + findWorktreeMetaReferencingRemote, + hasBranchConfigUsingRemote, + type GitRemoteExec, + type WorktreePushTargetStore +} from './worktree-push-target-cleanup' + +// Orca only ever mints `pr-head` or `pr--` (see `sanitizeRemoteName`), optionally +// disambiguated with `-2`..`-99` (see `ensureUniqueRemoteName`). The naming convention alone is +// not proof of provenance -- a user could name a remote `pr-foo` -- so this only narrows which +// remotes are even considered; `hasOrcaCreatedProvenance` below is the actual safety gate. +const ORCA_PR_REMOTE_NAME_PATTERN = + /^pr-(?:head|[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)(?:-[0-9]{1,2})?$/ + +export function isOrcaGeneratedPrRemoteName(name: string): boolean { + return ORCA_PR_REMOTE_NAME_PATTERN.test(name) +} + +type PrRemoteCandidate = { name: string; url: string } + +async function listPrRemoteCandidates( + execGit: GitRemoteExec, + repoPath: string +): Promise { + let stdout: string + try { + ;({ stdout } = await execGit(['remote', '-v'], repoPath)) + } catch { + return [] + } + const candidates = new Map() + for (const line of iterateProcessOutputLines(stdout)) { + const parsed = parseRemoteVerboseLine(line) + if (parsed?.direction === 'fetch' && isOrcaGeneratedPrRemoteName(parsed.name)) { + candidates.set(parsed.name, parsed.url) + } + } + return [...candidates.entries()].map(([name, url]) => ({ name, url })) +} + +function parseRemoteVerboseLine( + line: string +): { name: string; url: string; direction: 'fetch' | 'push' } | null { + const tabIndex = line.indexOf('\t') + if (tabIndex === -1) { + return null + } + const name = line.slice(0, tabIndex) + const match = /^(.*) \((fetch|push)\)$/.exec(line.slice(tabIndex + 1).trim()) + return match ? { name, url: match[1], direction: match[2] as 'fetch' | 'push' } : null +} + +async function shouldReclaimPrRemote( + execGit: GitRemoteExec, + repoPath: string, + repoId: string, + store: WorktreePushTargetStore, + remote: PrRemoteCandidate, + liveWorktreeKeys: ReadonlySet +): Promise { + const target: Pick = { + remoteName: remote.name, + remoteUrl: remote.url + } + const referencingEntries = findWorktreeMetaReferencingRemote(store, repoId, target) + // Provenance gate: only touch a remote some worktree's persisted pushTarget explicitly + // recorded Orca creating. Naming and URL shape are necessary but not sufficient proof. + if (!referencingEntries.some(({ meta }) => meta.pushTarget?.remoteCreated === true)) { + return false + } + const stillClaimedByLiveWorktree = referencingEntries.some(({ worktreeId }) => { + const key = worktreeIdComparisonKey(worktreeId) + return key !== null && liveWorktreeKeys.has(key) + }) + if (stillClaimedByLiveWorktree) { + return false + } + // A branch that still exists may push to this fork again later; only a branch that's + // actually gone (force-deleted, or deleted outside the "preserve on delete" flow) frees it. + if ( + await hasBranchConfigUsingRemote(execGit, repoPath, target, { requireExistingBranch: true }) + ) { + return false + } + return true +} + +// Exported for unit/real-git tests: the `execGit` seam and injected live-worktree paths let +// tests drive the sweep without a real repo (or with one, for the real-git coverage). +export async function reconcileOrphanedPrRemotesWithExec( + repoPath: string, + repoId: string, + store: WorktreePushTargetStore, + execGit: GitRemoteExec, + liveWorktreePaths: readonly string[] +): Promise { + const liveWorktreeKeys = new Set( + liveWorktreePaths + .map((path) => worktreeIdComparisonKey(`${repoId}${WORKTREE_ID_SEPARATOR}${path}`)) + .filter((key): key is string => key !== null) + ) + const reclaimed: string[] = [] + for (const remote of await listPrRemoteCandidates(execGit, repoPath)) { + if (await shouldReclaimPrRemote(execGit, repoPath, repoId, store, remote, liveWorktreeKeys)) { + await execGit(['remote', 'remove', remote.name], repoPath) + reclaimed.push(remote.name) + } + } + return reclaimed +} + +// Why: the sweep costs a handful of git subprocesses (remote -v, worktree list, per-candidate +// config/for-each-ref); bound to once per repo per cooldown so bursts of removals don't repeat it. +const RECONCILE_COOLDOWN_MS = 60 * 60 * 1000 +const lastReconciledAtByRepoId = new Map() + +function shouldReconcileNow(repoId: string): boolean { + const last = lastReconciledAtByRepoId.get(repoId) + return last === undefined || Date.now() - last >= RECONCILE_COOLDOWN_MS +} + +export function _resetPrRemoteReconciliationRateLimitForTests(): void { + lastReconciledAtByRepoId.clear() +} + +function logReclaimed(repoPath: string, reclaimed: string[]): void { + if (reclaimed.length > 0) { + console.log( + `[worktrees] Reclaimed ${reclaimed.length} orphaned PR remote(s) in ${repoPath}: ${reclaimed.join(', ')}` + ) + } +} + +/** Best-effort, rate-limited sweep run alongside single-target cleanup (see call sites). */ +export async function reconcileOrphanedPrRemotes( + repoPath: string, + repoId: string, + store: WorktreePushTargetStore, + gitOptions: { wslDistro?: string } = {} +): Promise { + if (!shouldReconcileNow(repoId)) { + return + } + lastReconciledAtByRepoId.set(repoId, Date.now()) + try { + const liveWorktrees = await listWorktrees(repoPath, gitOptions) + logReclaimed( + repoPath, + await reconcileOrphanedPrRemotesWithExec( + repoPath, + repoId, + store, + (args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }), + liveWorktrees.map((worktree) => worktree.path) + ) + ) + } catch (error) { + console.warn(`[worktrees] Failed to reconcile orphaned PR remotes for ${repoPath}`, error) + } +} + +/** SSH counterpart of {@link reconcileOrphanedPrRemotes}; the execution host owns the remotes. */ +export async function reconcileOrphanedPrRemotesSsh( + provider: SshGitProvider, + repoPath: string, + repoId: string, + store: WorktreePushTargetStore +): Promise { + if (!shouldReconcileNow(repoId)) { + return + } + lastReconciledAtByRepoId.set(repoId, Date.now()) + try { + const liveWorktrees = await provider.listWorktrees(repoPath) + logReclaimed( + repoPath, + await reconcileOrphanedPrRemotesWithExec( + repoPath, + repoId, + store, + (args, cwd) => provider.exec(args, cwd), + liveWorktrees.map((worktree) => worktree.path) + ) + ) + } catch (error) { + console.warn(`[worktrees] Failed to reconcile orphaned PR remotes (SSH) for ${repoPath}`, error) + } +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 82d54f1d39a..e53ae264129 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -104,6 +104,10 @@ import { type GitRemoteExec, type WorktreePushTargetStore } from './worktree-push-target-cleanup' +import { + reconcileOrphanedPrRemotes, + reconcileOrphanedPrRemotesSsh +} from './worktree-push-target-reconciliation' import { configureCreatedWorktreePushTargetWithExec, ensureUniqueRemoteName, @@ -1030,6 +1034,18 @@ export async function cleanupUnusedWorktreePushTargetRemote( } catch (error) { console.warn(`[worktrees] Failed to clean up fork PR remote for ${removedWorktreeId}`, error) } + // Why: also catches remotes this specific removal couldn't reclaim (legacy metadata, + // a preserved branch since deleted, a worktree removed outside Orca) -- see + // worktree-push-target-reconciliation.ts. Rate-limited internally; safe to call every removal. + // Not awaited: a repo with a large backlog (the scenario this exists for) can have dozens of + // candidate remotes, each probed with a couple of git subprocesses -- that must never add + // latency to the worktree-removal call the user is waiting on. It catches its own errors. + void reconcileOrphanedPrRemotes( + repoPath, + getRepoIdFromWorktreeId(removedWorktreeId), + store, + gitOptions + ) } export async function configureCreatedWorktreePushTarget( @@ -1134,6 +1150,14 @@ export async function cleanupUnusedWorktreePushTargetRemoteSsh( error ) } + // Why: SSH counterpart of the sweep above -- the execution host owns these remotes. + // Not awaited for the same reason as the local path: never add sweep latency to removal. + void reconcileOrphanedPrRemotesSsh( + provider, + repoPath, + getRepoIdFromWorktreeId(removedWorktreeId), + store + ) } async function readRemoteEffectiveHooks( From 2c559fa96a039b17004ce0c1b288bbe1c83b8ca1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:11:12 -0700 Subject: [PATCH 14/94] test(child-process): make the import ratchet able to fail never grows asserted offenders.length <= ALLOWLIST.length, but the two membership assertions already force those equal, so it could not fail. The comment claimed it caught a swap -- one file migrated off child_process, one added -- which is exactly the case it let through. Pins the true count and asserts both directions, so a swap fails and a pin left stale-high after a migration also fails rather than banking ground twice. Gives the console-visibility ratchet the same test: it had no count assertion at all and the same gap. Also anchors the owner-directory exemption with a trailing slash, so a future src/shared/child-process-foo.ts is scanned rather than silently exempt. --- .../child-process-import-boundary.test.ts | 32 ++++++++++++++++--- .../windows-console-visibility.test.ts | 23 +++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/shared/child-process/child-process-import-boundary.test.ts b/src/shared/child-process/child-process-import-boundary.test.ts index 4e261de5c8a..10ca4fd8522 100644 --- a/src/shared/child-process/child-process-import-boundary.test.ts +++ b/src/shared/child-process/child-process-import-boundary.test.ts @@ -23,10 +23,19 @@ const CHILD_PROCESS_IMPORT_ALLOWLIST: readonly string[] = readFileSync( .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith('#')) +/** + * The true count of files importing child_process directly. + * + * May only ever be DECREASED, and only by migrating a file off + * `node:child_process`. Raising it is never the fix. + */ +const DIRECT_IMPORTER_PIN = 160 + const IMPORT_PATTERN = /(?:from\s+['"]node:child_process['"]|from\s+['"]child_process['"]|require\(\s*['"]node:child_process['"]|require\(\s*['"]child_process['"])/ -const OWNER_DIRECTORY = 'src/shared/child-process' +// Why: trailing slash, so a sibling like src/shared/child-process-foo.ts is scanned, not exempted. +const OWNER_DIRECTORY = 'src/shared/child-process/' const SCANNED_EXTENSIONS = ['.ts', '.tsx'] const IGNORED_DIRECTORIES = new Set([ 'node_modules', @@ -110,9 +119,22 @@ describe('child_process import boundary', () => { expect(stale, 'Allowlist entry no longer imports child_process — delete the line.').toEqual([]) }) - it('never grows', () => { - // The count is asserted separately from membership so a swap (one file - // migrated, one added) still fails loudly. - expect(offenders.length).toBeLessThanOrEqual(CHILD_PROCESS_IMPORT_ALLOWLIST.length) + it('holds the offender count at the pin', () => { + // Bounding by the allowlist's own length proves nothing: the two move + // together, so a swap (one file migrated off, one new file added with its + // entry) kept the bound satisfied. The pin is a literal for that reason. + expect( + offenders.length, + `${offenders.length} files import child_process directly; the pin is ${DIRECT_IMPORTER_PIN}. ` + + 'Never raise the pin -- migrate the file to runProcess/spawnProcess from ' + + 'src/shared/child-process instead.' + ).toBeLessThanOrEqual(DIRECT_IMPORTER_PIN) + // A pin left above reality is how a ratchet rots: it re-opens room for the + // next direct import to land for free. + expect( + offenders.length, + `Only ${offenders.length} files import child_process directly. Lower DIRECT_IMPORTER_PIN to ` + + `${offenders.length} to keep the ground you just took.` + ).toBeGreaterThanOrEqual(DIRECT_IMPORTER_PIN) }) }) diff --git a/src/shared/child-process/windows-console-visibility.test.ts b/src/shared/child-process/windows-console-visibility.test.ts index e98a97cfa98..7985b0ee11a 100644 --- a/src/shared/child-process/windows-console-visibility.test.ts +++ b/src/shared/child-process/windows-console-visibility.test.ts @@ -27,6 +27,15 @@ const ALLOWLIST: readonly string[] = readAllowlist( join(__dirname, '__fixtures__', 'windows-console-visibility-allowlist.txt') ) +/** + * The true count of files spawning without `windowsHide`. + * + * May only ever be DECREASED, and only by fixing a call site. Set equality with + * the allowlist does not bound this: a swap (one file fixed and delisted, one + * new file added with its entry) satisfies both membership assertions. + */ +const UNHIDDEN_SPAWNER_PIN = 68 + const CHILD_PROCESS_IMPORT = /from\s+['"](?:node:)?child_process['"]|require\(\s*['"](?:node:)?child_process['"]/ // Includes the promisified and renamed spellings -- `execAsync`, `spawnDetached`, @@ -166,4 +175,18 @@ describe('direct child-process calls hide the Windows console', () => { // A fixed file must leave the list, or the ratchet stops ratcheting. expect(ALLOWLIST.filter((path) => !offenders.includes(path))).toEqual([]) }) + + it('holds the offender count at the pin', () => { + expect( + offenders.length, + `${offenders.length} files spawn without windowsHide; the pin is ${UNHIDDEN_SPAWNER_PIN}. ` + + 'Never raise the pin -- add the flag, or route the call through run-process.ts.' + ).toBeLessThanOrEqual(UNHIDDEN_SPAWNER_PIN) + // A pin left above reality re-opens room for the next unguarded spawn. + expect( + offenders.length, + `Only ${offenders.length} files spawn without windowsHide. Lower UNHIDDEN_SPAWNER_PIN to ` + + `${offenders.length} to keep the ground you just took.` + ).toBeGreaterThanOrEqual(UNHIDDEN_SPAWNER_PIN) + }) }) From 73fcdea23c74bab3bd49c6ee0b6441102a541bdc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:11:13 -0700 Subject: [PATCH 15/94] fix(palette): recompute quick-action availability when runtime status changes buildQuickActionContext reads runtimeStatusByEnvironmentId transitively through getClientCreationActionPolicy, but the split dropped it from the memo deps. The store replaces the Map identity on update, so the palette held availability from a snapshot that never refreshed -- offering a browser action against a provider that had gone away, or hiding one that had come back. exhaustive-deps could not catch it: the read is behind a void statement, which the rule does not see. --- ...use-worktree-jump-palette-quick-actions.ts | 8 +- ...palette-quick-action-availability.test.tsx | 103 ++++++++++++++++++ .../lib/lazy-chunk-recovery-reload.test.ts | 29 +++++ 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/worktree-jump-palette-quick-action-availability.test.tsx diff --git a/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts b/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts index c6cf2c48d05..0783a7e5d23 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts @@ -58,6 +58,7 @@ export function useWorktreeJumpPaletteQuickActions({ groupsByWorktree, isLoading, settings, + runtimeStatusByEnvironmentId, deferredQuery, settingsResults }: WorktreeJumpPaletteQuickActionsInput) { @@ -117,6 +118,9 @@ export function useWorktreeJumpPaletteQuickActions({ openNewTerminalTabInActiveWorkspace ] ) + // Why: buildQuickActionContext() reads the store imperatively, so these voided values are the + // memo's real inputs — each one is read (some transitively, e.g. runtimeStatusByEnvironmentId + // via the managed-browser creation policy) while availability is computed. const availableActionResults = useMemo(() => { void activeView void activeWorktreeId @@ -127,6 +131,7 @@ export function useWorktreeJumpPaletteQuickActions({ void groupsByWorktree void isLoading void settings?.activeRuntimeEnvironmentId + void runtimeStatusByEnvironmentId const context = buildQuickActionContext() return actionResults.filter((action) => action.isAvailable(context).available) }, [ @@ -140,7 +145,8 @@ export function useWorktreeJumpPaletteQuickActions({ activeGroupIdByWorktree, groupsByWorktree, isLoading, - settings?.activeRuntimeEnvironmentId + settings?.activeRuntimeEnvironmentId, + runtimeStatusByEnvironmentId ]) const middleItems = useMemo<(SettingsPaletteItem | QuickActionPaletteItem)[]>( () => diff --git a/src/renderer/src/components/worktree-jump-palette-quick-action-availability.test.tsx b/src/renderer/src/components/worktree-jump-palette-quick-action-availability.test.tsx new file mode 100644 index 00000000000..69459901bfa --- /dev/null +++ b/src/renderer/src/components/worktree-jump-palette-quick-action-availability.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { createRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { buildCmdJActionResults } from '@/components/cmd-j/palette-results' +import { getCmdJQuickActions } from '@/components/cmd-j/quick-actions' +import { useWorktreeJumpPaletteQuickActions } from './use-worktree-jump-palette-quick-actions' + +const mocks = vi.hoisted(() => ({ state: {} as Record })) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => RUNTIME_ID +})) +vi.mock('@/components/sidebar/delete-worktree-flow', () => ({ runWorktreeDelete: vi.fn() })) + +const RUNTIME_ID = 'runtime-1' +const WORKTREE_ID = 'repo-1::/repo/wt' + +function runtimeStatuses(capabilities: string[]): Map { + return new Map([[RUNTIME_ID, { status: { capabilities, hostPlatform: 'darwin' } }]]) +} + +// Every input except runtimeStatusByEnvironmentId keeps a stable identity across rerenders, +// so a recomputation can only come from the runtime status dependency itself. +function buildStableProps() { + return { + openModal: vi.fn(), + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn(), + activeGroupSnapshotRef: createRef(), + openNewBrowserTabInActiveWorkspace: vi.fn(), + openNewMarkdownInActiveWorkspace: vi.fn(), + openNewTerminalTabInActiveWorkspace: vi.fn(), + actionResults: buildCmdJActionResults(getCmdJQuickActions()), + activeView: 'terminal', + activeWorktreeId: WORKTREE_ID, + worktreesByRepo: mocks.state.worktreesByRepo, + repos: mocks.state.repos, + sshConnectionStates: mocks.state.sshConnectionStates, + activeGroupIdByWorktree: mocks.state.activeGroupIdByWorktree, + groupsByWorktree: mocks.state.groupsByWorktree, + isLoading: false, + settings: mocks.state.settings, + deferredQuery: 'new browser tab', + settingsResults: [] + } +} + +function renderQuickActions(initialStatuses: Map) { + const stable = buildStableProps() + mocks.state.runtimeStatusByEnvironmentId = initialStatuses + const harness = renderHook( + (runtimeStatusByEnvironmentId: Map) => + useWorktreeJumpPaletteQuickActions({ ...stable, runtimeStatusByEnvironmentId } as never), + { initialProps: initialStatuses } + ) + return { + offersBrowserAction: (): boolean => + harness.result.current.middleItems.some((item) => item.id === 'quick-action:new-browser-tab'), + setRuntimeStatuses: (next: Map): void => { + mocks.state.runtimeStatusByEnvironmentId = next + harness.rerender(next) + } + } +} + +describe('worktree jump palette quick action availability', () => { + beforeEach(() => { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + mocks.state = { + activeView: 'terminal', + activeWorktreeId: WORKTREE_ID, + worktreesByRepo: { 'repo-1': [{ id: WORKTREE_ID, repoId: 'repo-1' }] }, + repos: [{ id: 'repo-1' }], + sshConnectionStates: new Map(), + activeGroupIdByWorktree: { [WORKTREE_ID]: 'group-1' }, + groupsByWorktree: { [WORKTREE_ID]: [{ id: 'group-1' }] }, + settings: { activeRuntimeEnvironmentId: RUNTIME_ID } + } + }) + afterEach(() => { + delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ + }) + + it('drops the paired-web browser action when the runtime loses screencast capability', () => { + const palette = renderQuickActions(runtimeStatuses([BROWSER_SCREENCAST_RUNTIME_CAPABILITY])) + expect(palette.offersBrowserAction()).toBe(true) + + palette.setRuntimeStatuses(runtimeStatuses([])) + expect(palette.offersBrowserAction()).toBe(false) + }) + + it('restores the browser action when a capable runtime comes back', () => { + const palette = renderQuickActions(runtimeStatuses([])) + expect(palette.offersBrowserAction()).toBe(false) + + palette.setRuntimeStatuses(runtimeStatuses([BROWSER_SCREENCAST_RUNTIME_CAPABILITY])) + expect(palette.offersBrowserAction()).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts b/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts index 9cdf4fa728d..8c113363861 100644 --- a/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts +++ b/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts @@ -6,6 +6,7 @@ import { requestLazyChunkRecoveryReload } from './lazy-chunk-recovery-reload' describe('requestLazyChunkRecoveryReload', () => { afterEach(() => { vi.restoreAllMocks() + vi.unstubAllGlobals() }) it('refuses the reload when the staged checkpoint never reaches disk', async () => { @@ -36,4 +37,32 @@ describe('requestLazyChunkRecoveryReload', () => { expect(order).toEqual(['flushed', 'reload']) }) + + it('joins the preload checkpoint before navigating when no override is supplied', async () => { + const order: string[] = [] + let flush: () => void = () => undefined + const awaitBeforeUnloadCheckpoint = vi.fn( + () => + new Promise((resolve) => { + flush = () => { + order.push('flushed') + resolve() + } + }) + ) + vi.stubGlobal('api', { app: { awaitBeforeUnloadCheckpoint } }) + const reload = vi.spyOn(window.location, 'reload').mockImplementation(() => { + order.push('reload') + window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) + }) + + const outcome = requestLazyChunkRecoveryReload(window) + await vi.waitFor(() => expect(awaitBeforeUnloadCheckpoint).toHaveBeenCalledTimes(1)) + expect(reload).not.toHaveBeenCalled() + + flush() + + await expect(outcome).resolves.toBe('unload-vetoed') + expect(order).toEqual(['flushed', 'reload']) + }) }) From c8937936eb5ae50d8e0b9a0481617f90a6e7054a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:11:14 -0700 Subject: [PATCH 16/94] refactor(mobile): pin the terminal WebView payload and split its widest slice The payload is one concatenated string, so slice boundaries follow document order rather than responsibility -- but join is associative, so cutting a slice into consecutive slices is byte-identical by construction. Splits the widest slice, which carried fit-scale, a DECSET scanner and the write queue together with no room left under the line cap. Adds a hash guard. The behavioral tests each execute one region of the payload in a vm, so an edit to an uncovered region shipped silently; the composed output is now pinned by sha256 and length. Derives the source-file list from the composer's own imports instead of a second hardcoded list a new slice had to be added to by hand -- the same silent subject-loss shape already found twice elsewhere in this repo. --- ...rminal-webview-html-source.test-support.ts | 45 +-- mobile/src/terminal/terminal-webview-html.ts | 8 +- .../fit-scale-and-write-queue.ts | 288 ------------------ .../mouse-mode-decset-scan.ts | 52 ++++ .../terminal-fit-scale.ts | 130 ++++++++ .../terminal-webview-html/write-queue.ts | 110 +++++++ .../terminal-webview-payload-hash.test.ts | 17 ++ 7 files changed, 341 insertions(+), 309 deletions(-) delete mode 100644 mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts create mode 100644 mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts create mode 100644 mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts create mode 100644 mobile/src/terminal/terminal-webview-html/write-queue.ts create mode 100644 mobile/src/terminal/terminal-webview-payload-hash.test.ts diff --git a/mobile/src/terminal/terminal-webview-html-source.test-support.ts b/mobile/src/terminal/terminal-webview-html-source.test-support.ts index 25902305f41..19a9cfc07ba 100644 --- a/mobile/src/terminal/terminal-webview-html-source.test-support.ts +++ b/mobile/src/terminal/terminal-webview-html-source.test-support.ts @@ -1,24 +1,31 @@ import { readFileSync } from 'node:fs' -const SOURCE_FILES = [ - './terminal-webview-html.ts', - './terminal-webview-html/document-shell.ts', - './terminal-webview-html/runtime-state-and-text-scaling.ts', - './terminal-webview-html/fit-scale-and-write-queue.ts', - './terminal-webview-html/terminal-init-and-write.ts', - './terminal-webview-html/host-message-router.ts', - './terminal-webview-html/selection-state-and-eviction.ts', - './terminal-webview-html/term-observers-and-mode-mirroring.ts', - './terminal-webview-html/mouse-report-and-scroll-routing.ts', - './terminal-webview-html/smooth-scroll-and-cell-geometry.ts', - './terminal-webview-html/selection-overlay.ts', - './terminal-webview-html/surface-touch-gestures.ts', - './terminal-webview-html/message-bridge-and-document-close.ts' -] as const +const COMPOSER_FILE = './terminal-webview-html.ts' +const SLICE_IMPORT_RE = /^import \{[^}]*\} from '(\.\/terminal-webview-html\/[\w-]+)'$/gm +const COMPOSED_ENTRY_RE = /^ {2}TERMINAL_HTML_\w+,?$/gm -/** Reads the TypeScript source that assembles the in-WebView document. */ +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8') +} + +/** + * Reads the TypeScript source that assembles the in-WebView document. + * + * Why: the slice list is derived from the composer's own imports rather than duplicated, so a + * new slice cannot join the emitted document while staying invisible to the tests that search + * this source. The count cross-check catches an import shape the regex cannot see. + */ export function readTerminalWebViewHtmlSource(): string { - return SOURCE_FILES.map((relativePath) => - readFileSync(new URL(relativePath, import.meta.url), 'utf8') - ).join('\n') + const composer = readSource(COMPOSER_FILE) + const slices = [...composer.matchAll(SLICE_IMPORT_RE)].map((match) => `${match[1]}.ts`) + const composedCount = [...composer.matchAll(COMPOSED_ENTRY_RE)].length + if (composedCount === 0) { + throw new Error('no composed WebView document slices found') + } + if (slices.length !== composedCount) { + throw new Error( + `WebView document slice imports (${slices.length}) do not match composed entries (${composedCount})` + ) + } + return [composer, ...slices.map(readSource)].join('\n') } diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 40b7a2db22c..17fadd4d26c 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -1,6 +1,8 @@ import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell' import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from './terminal-webview-html/runtime-state-and-text-scaling' -import { TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE } from './terminal-webview-html/fit-scale-and-write-queue' +import { TERMINAL_HTML_FIT_SCALE } from './terminal-webview-html/terminal-fit-scale' +import { TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN } from './terminal-webview-html/mouse-mode-decset-scan' +import { TERMINAL_HTML_WRITE_QUEUE } from './terminal-webview-html/write-queue' import { TERMINAL_HTML_INIT_AND_WRITE } from './terminal-webview-html/terminal-init-and-write' import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from './terminal-webview-html/host-message-router' import { TERMINAL_HTML_SELECTION_STATE_AND_EVICTION } from './terminal-webview-html/selection-state-and-eviction' @@ -19,7 +21,9 @@ export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme' export const XTERM_HTML = [ TERMINAL_HTML_DOCUMENT_SHELL, TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING, - TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE, + TERMINAL_HTML_FIT_SCALE, + TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN, + TERMINAL_HTML_WRITE_QUEUE, TERMINAL_HTML_INIT_AND_WRITE, TERMINAL_HTML_HOST_MESSAGE_ROUTER, TERMINAL_HTML_SELECTION_STATE_AND_EVICTION, diff --git a/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts b/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts deleted file mode 100644 index 074185d43a5..00000000000 --- a/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected' - -// Also carries the DECSET mouse-mode scanner: emitted-document order pins it between these two concerns. -export const TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE = `${TERMINAL_WEBVIEW_THEME_JS} - - function getCellHeight() { - if (!term || !term._core) return 15; - var core = term._core; - if (core._renderService && core._renderService.dimensions) { - return core._renderService.dimensions.css.cell.height || 15; - } - return 15; - } - - // Why: clamp pan so the terminal content always covers the viewport - // when zoomed in. When content is smaller than viewport in a - // dimension, pin to top-left (no floating in the middle). - function clampPan() { - if (!term || !term.element) return; - var ts = getTotalScale(); - var cw = term.element.scrollWidth * ts; - var ch = term.element.scrollHeight * ts; - var vpW = window.innerWidth; - var vpH = window.innerHeight; - if (cw > vpW) { - panX = Math.min(0, Math.max(vpW - cw, panX)); - } else { - panX = 0; - } - if (ch > vpH) { - panY = Math.min(0, Math.max(vpH - ch, panY)); - } else { - panY = 0; - } - } - - // Why: intentional no-op. Mobile replays a live PTY snapshot then applies - // live cursor-relative chunks from that same PTY; resizing only the WebView - // xterm changes cursor coordinates and makes TUI repaint chunks duplicate or - // overlap. Kept as a no-op so its call sites stay legible. - function adjustRowsForViewport() {} - - // Why: cold-start fit. After init() opens xterm, the renderer needs - // several frames before cell dimensions are computed. Reading too early - // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM - // not laid out), and computeFitScale returns 1 → no zoom. - // - // Gate: cellWidth × cols is the canonical "logical width" of the grid - // and reflects xterm's layout decision, independent of buffer content. - // We commit when cellWidth becomes positive (renderer ready). Fallback: - // if cellWidth never becomes available, gate on stable positive - // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) - // so a backgrounded WebView never spins forever. - var FIT_RETRY_MAX_FRAMES = 60; - var fitRetryToken = 0; - function applyFitScale(reason) { - if (!term || !term.element) return; - var token = ++fitRetryToken; - var attempts = 0; - var lastScrollWidth = -1; - function attempt() { - if (token !== fitRetryToken) return; - if (!term || !term.element) return; - attempts++; - var cellW = getCellWidth(); - if (cellW > 0 && term.cols > 0) { - commitFitScale(reason, attempts, 'cellW'); - return; - } - var w = term.element.scrollWidth; - if (w > 0 && w === lastScrollWidth) { - commitFitScale(reason, attempts, 'stableSW'); - return; - } - lastScrollWidth = w; - if (attempts >= FIT_RETRY_MAX_FRAMES) { - flog('commit-timeout', { - reason: reason, - attempts: attempts, - cellW: cellW, - scrollWidth: w, - cols: term.cols - }); - commitFitScale(reason, attempts, 'timeout'); - return; - } - requestAnimationFrame(attempt); - } - requestAnimationFrame(attempt); - } - - function commitFitScale(reason, attempts, gate) { - if (!term || !term.element) return; - var preSnapScale = computeFitScale(); - currentScale = preSnapScale; - // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar - // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents - // a second applyFitScale from observing a "no-op needed" state. - if (currentScale >= 0.95) currentScale = 1; - userScale = 1; - panX = 0; - panY = 0; - smoothScrollOffsetY = 0; - updateTransform(); - adjustRowsForViewport(); - - var cellW = getCellWidth(); - var sw = term.element.scrollWidth; - var vpW = window.innerWidth; - var expectedW = cellW * term.cols; - var suspect = - currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom - if (suspect) { - flog('commit-SUSPECT', { - reason: reason, - attempts: attempts, - gate: gate, - preSnapScale: preSnapScale, - finalScale: currentScale, - cellW: cellW, - cols: term.cols, - expectedW: expectedW, - scrollWidth: sw, - vpWidth: vpW - }); - } - repositionOverlay(); - } - - function isAltScreenActive(data) { - if (typeof data !== 'string') return false; - var on = data.lastIndexOf(ESC + '[?1049h'); - var off = data.lastIndexOf(ESC + '[?1049l'); - return on !== -1 && on > off; - } - - function normalizeInitialData(data) { - if (!isAltScreenActive(data)) return data; - var on = data.lastIndexOf(ESC + '[?1049h'); - // Why: SerializeAddon can include normal-buffer scrollback before the - // active alternate-screen snapshot. Replaying both into a fresh mobile - // xterm duplicates TUI frames and can flatten SGR attributes. - return on > 0 ? data.slice(on) : data; - } - - function updateMouseModeFromData(data) { - if (typeof data !== 'string' || data.length === 0) return; - var input = mouseModeScanTail + data; - mouseModeScanTail = extractMouseModeScanTail(input); - var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g'); - var match; - while ((match = re.exec(input)) !== null) { - if (match[0] === ESC + 'c') { - trackedMouseTrackingMode = 'none'; - sgrMouseMode = false; - sgrMousePixelsMode = false; - continue; - } - var enabled = (match[2] || match[4]) === 'h'; - var params = (match[1] || match[3]).split(';'); - for (var i = 0; i < params.length; i++) { - if (params[i] === '') continue; - var param = Number(params[i]); - if (!Number.isInteger(param)) continue; - if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none'; - if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none'; - if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none'; - if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none'; - if (param === 1006) { - sgrMouseMode = enabled; - sgrMousePixelsMode = false; - } - if (param === 1016) { - sgrMouseMode = false; - sgrMousePixelsMode = enabled; - } - } - } - } - - function resetWriteQueue() { - writeQueue = []; - writeQueueHead = 0; - } - - function isStatusDotPresentationSelector(value) { - return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR; - } - - function endsWithStatusDotPresentationSequence(data) { - var i = data.length - 1; - while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--; - return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT; - } - - // Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph. - function normalizeStatusDotPresentation(data) { - if (typeof data !== 'string' || data.length === 0) return data; - if (statusDotPendingSelector) { - statusDotPendingSelector = false; - var strippedPendingSelectors = false; - while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1); - strippedPendingSelectors = data.length === 0; - if (strippedPendingSelectors) { - statusDotPendingSelector = true; - return ''; - } - } - var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR); - statusDotPendingSelector = endsWithStatusDotPresentationSequence(data); - return normalized; - } - - function enqueueWrite(data) { - writeQueue.push(normalizeStatusDotPresentation(data)); - } - - function enqueueWriteBoundary(callback) { - writeQueue.push(callback); - } - - function nextQueuedWrite() { - if (writeQueueHead >= writeQueue.length) { - resetWriteQueue(); - return undefined; - } - var next = writeQueue[writeQueueHead]; - writeQueueHead++; - // Why: high-throughput terminals can enqueue faster than xterm parses; - // compact consumed slots so drain work stays O(1) without retaining old chunks. - if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { - writeQueue = writeQueue.slice(writeQueueHead); - writeQueueHead = 0; - } - return next; - } - - function disposeTermObservers() { - var disposables = termObserverDisposables; - termObserverDisposables = []; - for (var i = 0; i < disposables.length; i++) { - try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} - } - } - - function extractMouseModeScanTail(input) { - var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); - if (start === -1) return ''; - var tail = input.slice(start); - // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. - // Keep parser state far beyond normal mode lists while still bounding memory. - if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return ''; - if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail; - if (tail.indexOf(ESC + '[?') === 0) { - return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''; - } - if (tail.indexOf(C1_CSI + '?') === 0) { - return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''; - } - return ''; - } - - function pumpWrites(gen) { - if (!ready || !term || writesDraining || gen !== terminalGeneration) return; - var next = nextQueuedWrite(); - if (typeof next !== 'string') { - if (typeof next === 'function') return next(), pumpWrites(gen); - var callbacks = afterDrainCallbacks; - afterDrainCallbacks = []; - for (var i = 0; i < callbacks.length; i++) callbacks[i](); - return; - } - writesDraining = true; - // Why: xterm.write() parses asynchronously. Row adjustment/resizing must - // wait until replayed SGR attributes have landed in the buffer. - term.write(next, function() { - if (gen !== terminalGeneration) return; - writesDraining = false; - pumpWrites(gen); - }); - } - - function afterWritesDrained(callback) { - afterDrainCallbacks.push(callback); - pumpWrites(terminalGeneration); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts b/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts new file mode 100644 index 00000000000..6f0685df87e --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts @@ -0,0 +1,52 @@ +export const TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN = ` function isAltScreenActive(data) { + if (typeof data !== 'string') return false; + var on = data.lastIndexOf(ESC + '[?1049h'); + var off = data.lastIndexOf(ESC + '[?1049l'); + return on !== -1 && on > off; + } + + function normalizeInitialData(data) { + if (!isAltScreenActive(data)) return data; + var on = data.lastIndexOf(ESC + '[?1049h'); + // Why: SerializeAddon can include normal-buffer scrollback before the + // active alternate-screen snapshot. Replaying both into a fresh mobile + // xterm duplicates TUI frames and can flatten SGR attributes. + return on > 0 ? data.slice(on) : data; + } + + function updateMouseModeFromData(data) { + if (typeof data !== 'string' || data.length === 0) return; + var input = mouseModeScanTail + data; + mouseModeScanTail = extractMouseModeScanTail(input); + var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g'); + var match; + while ((match = re.exec(input)) !== null) { + if (match[0] === ESC + 'c') { + trackedMouseTrackingMode = 'none'; + sgrMouseMode = false; + sgrMousePixelsMode = false; + continue; + } + var enabled = (match[2] || match[4]) === 'h'; + var params = (match[1] || match[3]).split(';'); + for (var i = 0; i < params.length; i++) { + if (params[i] === '') continue; + var param = Number(params[i]); + if (!Number.isInteger(param)) continue; + if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none'; + if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none'; + if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none'; + if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none'; + if (param === 1006) { + sgrMouseMode = enabled; + sgrMousePixelsMode = false; + } + if (param === 1016) { + sgrMouseMode = false; + sgrMousePixelsMode = enabled; + } + } + } + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts b/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts new file mode 100644 index 00000000000..b756bcb550c --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts @@ -0,0 +1,130 @@ +import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected' + +// Opens with the injected theme block: it lands at this point in the emitted document. +export const TERMINAL_HTML_FIT_SCALE = `${TERMINAL_WEBVIEW_THEME_JS} + + function getCellHeight() { + if (!term || !term._core) return 15; + var core = term._core; + if (core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.height || 15; + } + return 15; + } + + // Why: clamp pan so the terminal content always covers the viewport + // when zoomed in. When content is smaller than viewport in a + // dimension, pin to top-left (no floating in the middle). + function clampPan() { + if (!term || !term.element) return; + var ts = getTotalScale(); + var cw = term.element.scrollWidth * ts; + var ch = term.element.scrollHeight * ts; + var vpW = window.innerWidth; + var vpH = window.innerHeight; + if (cw > vpW) { + panX = Math.min(0, Math.max(vpW - cw, panX)); + } else { + panX = 0; + } + if (ch > vpH) { + panY = Math.min(0, Math.max(vpH - ch, panY)); + } else { + panY = 0; + } + } + + // Why: intentional no-op. Mobile replays a live PTY snapshot then applies + // live cursor-relative chunks from that same PTY; resizing only the WebView + // xterm changes cursor coordinates and makes TUI repaint chunks duplicate or + // overlap. Kept as a no-op so its call sites stay legible. + function adjustRowsForViewport() {} + + // Why: cold-start fit. After init() opens xterm, the renderer needs + // several frames before cell dimensions are computed. Reading too early + // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM + // not laid out), and computeFitScale returns 1 → no zoom. + // + // Gate: cellWidth × cols is the canonical "logical width" of the grid + // and reflects xterm's layout decision, independent of buffer content. + // We commit when cellWidth becomes positive (renderer ready). Fallback: + // if cellWidth never becomes available, gate on stable positive + // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) + // so a backgrounded WebView never spins forever. + var FIT_RETRY_MAX_FRAMES = 60; + var fitRetryToken = 0; + function applyFitScale(reason) { + if (!term || !term.element) return; + var token = ++fitRetryToken; + var attempts = 0; + var lastScrollWidth = -1; + function attempt() { + if (token !== fitRetryToken) return; + if (!term || !term.element) return; + attempts++; + var cellW = getCellWidth(); + if (cellW > 0 && term.cols > 0) { + commitFitScale(reason, attempts, 'cellW'); + return; + } + var w = term.element.scrollWidth; + if (w > 0 && w === lastScrollWidth) { + commitFitScale(reason, attempts, 'stableSW'); + return; + } + lastScrollWidth = w; + if (attempts >= FIT_RETRY_MAX_FRAMES) { + flog('commit-timeout', { + reason: reason, + attempts: attempts, + cellW: cellW, + scrollWidth: w, + cols: term.cols + }); + commitFitScale(reason, attempts, 'timeout'); + return; + } + requestAnimationFrame(attempt); + } + requestAnimationFrame(attempt); + } + + function commitFitScale(reason, attempts, gate) { + if (!term || !term.element) return; + var preSnapScale = computeFitScale(); + currentScale = preSnapScale; + // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar + // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents + // a second applyFitScale from observing a "no-op needed" state. + if (currentScale >= 0.95) currentScale = 1; + userScale = 1; + panX = 0; + panY = 0; + smoothScrollOffsetY = 0; + updateTransform(); + adjustRowsForViewport(); + + var cellW = getCellWidth(); + var sw = term.element.scrollWidth; + var vpW = window.innerWidth; + var expectedW = cellW * term.cols; + var suspect = + currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom + if (suspect) { + flog('commit-SUSPECT', { + reason: reason, + attempts: attempts, + gate: gate, + preSnapScale: preSnapScale, + finalScale: currentScale, + cellW: cellW, + cols: term.cols, + expectedW: expectedW, + scrollWidth: sw, + vpWidth: vpW + }); + } + repositionOverlay(); + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/write-queue.ts b/mobile/src/terminal/terminal-webview-html/write-queue.ts new file mode 100644 index 00000000000..ae8ed85297f --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/write-queue.ts @@ -0,0 +1,110 @@ +// Also carries disposeTermObservers() and extractMouseModeScanTail(): both belong to +// other concerns, but emitted-document order pins them inside this queue. +export const TERMINAL_HTML_WRITE_QUEUE = ` function resetWriteQueue() { + writeQueue = []; + writeQueueHead = 0; + } + + function isStatusDotPresentationSelector(value) { + return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR; + } + + function endsWithStatusDotPresentationSequence(data) { + var i = data.length - 1; + while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--; + return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT; + } + + // Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph. + function normalizeStatusDotPresentation(data) { + if (typeof data !== 'string' || data.length === 0) return data; + if (statusDotPendingSelector) { + statusDotPendingSelector = false; + var strippedPendingSelectors = false; + while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1); + strippedPendingSelectors = data.length === 0; + if (strippedPendingSelectors) { + statusDotPendingSelector = true; + return ''; + } + } + var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR); + statusDotPendingSelector = endsWithStatusDotPresentationSequence(data); + return normalized; + } + + function enqueueWrite(data) { + writeQueue.push(normalizeStatusDotPresentation(data)); + } + + function enqueueWriteBoundary(callback) { + writeQueue.push(callback); + } + + function nextQueuedWrite() { + if (writeQueueHead >= writeQueue.length) { + resetWriteQueue(); + return undefined; + } + var next = writeQueue[writeQueueHead]; + writeQueueHead++; + // Why: high-throughput terminals can enqueue faster than xterm parses; + // compact consumed slots so drain work stays O(1) without retaining old chunks. + if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { + writeQueue = writeQueue.slice(writeQueueHead); + writeQueueHead = 0; + } + return next; + } + + function disposeTermObservers() { + var disposables = termObserverDisposables; + termObserverDisposables = []; + for (var i = 0; i < disposables.length; i++) { + try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} + } + } + + function extractMouseModeScanTail(input) { + var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); + if (start === -1) return ''; + var tail = input.slice(start); + // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. + // Keep parser state far beyond normal mode lists while still bounding memory. + if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return ''; + if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail; + if (tail.indexOf(ESC + '[?') === 0) { + return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''; + } + if (tail.indexOf(C1_CSI + '?') === 0) { + return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''; + } + return ''; + } + + function pumpWrites(gen) { + if (!ready || !term || writesDraining || gen !== terminalGeneration) return; + var next = nextQueuedWrite(); + if (typeof next !== 'string') { + if (typeof next === 'function') return next(), pumpWrites(gen); + var callbacks = afterDrainCallbacks; + afterDrainCallbacks = []; + for (var i = 0; i < callbacks.length; i++) callbacks[i](); + return; + } + writesDraining = true; + // Why: xterm.write() parses asynchronously. Row adjustment/resizing must + // wait until replayed SGR attributes have landed in the buffer. + term.write(next, function() { + if (gen !== terminalGeneration) return; + writesDraining = false; + pumpWrites(gen); + }); + } + + function afterWritesDrained(callback) { + afterDrainCallbacks.push(callback); + pumpWrites(terminalGeneration); + } + +` diff --git a/mobile/src/terminal/terminal-webview-payload-hash.test.ts b/mobile/src/terminal/terminal-webview-payload-hash.test.ts new file mode 100644 index 00000000000..f8bfa4bd134 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-payload-hash.test.ts @@ -0,0 +1,17 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { XTERM_HTML } from './terminal-webview-html' + +// Why: every other WebView test exercises one slice of the document, so an edit to an +// uncovered region ships silently. A diff here means the emitted WebView source changed — +// update these values only when that change is deliberate, and only after checking the +// document still runs. Refactors that merely move slice boundaries must leave them alone. +const EXPECTED_SHA256 = '42cc000faddc3b58b8fd4855f848c7878f0cd6166c613f66d733645e8e1b9608' +const EXPECTED_LENGTH = 729776 + +describe('terminal WebView payload', () => { + it('composes the expected document', () => { + expect(XTERM_HTML.length).toBe(EXPECTED_LENGTH) + expect(createHash('sha256').update(XTERM_HTML, 'utf8').digest('hex')).toBe(EXPECTED_SHA256) + }) +}) From 80a52bb9b3fccd6c510bb1647c86fb7f19c8455b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:14:57 -0700 Subject: [PATCH 17/94] fix(git): recover commit ref badges on Git older than 2.43 (#17923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GIT_HISTORY_COMMIT_FORMAT asked for decorations with %(decorate:…), which Git 2.43 introduced. Older Git prints the placeholder verbatim and exits zero, so nothing raised and every commit in the Source Control panel silently lost its branch, remote and tag badges. The record now also carries %D (Git 2.10) on its own line, selected by an exact match against the unexpanded placeholder — a ref name can never contain the \x1f that Git expands inside the echoed text. %n emits the %D line on both sides of the boundary, so the message index is fixed and a missed match degrades to no badges rather than a corrupted message. The decoration separator is now bound to the field that produced the text instead of sniffed from it. A lone decoration carries no separator, so the old sniff split `refs/heads/feat,one` into two bogus refs. Verified against real Git 2.38.1 and 2.49.1. Co-authored-by: kaluli123123 <295758798+kaluli123123@users.noreply.github.com> --- docs/reference/git-compatibility.md | 11 ++++++ src/shared/git-binary-compatibility.test.ts | 26 ++++++++++++ src/shared/git-history-log-parser.ts | 33 +++++++++++----- src/shared/git-history.test.ts | 44 ++++++++++++++++++++- 4 files changed, 102 insertions(+), 12 deletions(-) diff --git a/docs/reference/git-compatibility.md b/docs/reference/git-compatibility.md index 3004b8888ab..0e8b1f257d3 100644 --- a/docs/reference/git-compatibility.md +++ b/docs/reference/git-compatibility.md @@ -42,6 +42,17 @@ authority. | `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 | | `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form | +### Placeholders That Fail Open + +`GitCapabilityCache` records commands Git *rejects*. A `git log --format` +placeholder Git does not know is not rejected: Git echoes it verbatim and exits +zero, so there is no error to remember and no probe to cache. Ask for both forms +in one record and pick at parse time. + +| Placeholder | Preferred behavior | Compatibility behavior | +| ---------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `%(decorate:…)` | Git 2.43 separates commit decorations with `\x1f`, so ref names containing commas survive | The same record also carries `%D` (Git 2.10); an unexpanded `%(decorate` placeholder selects it, at the cost of comma-splitting | + ## Why Not `simple-git` `simple-git` is a process wrapper around the installed Git binary. Its custom diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index debab394649..3a8f562dff1 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -15,6 +15,7 @@ import { isUnsupportedWorktreeListZError } from './git-worktree-command-capabilities' import { gitCredentialPromptGuardEnv } from './git-credential-prompt-env' +import { GIT_HISTORY_COMMIT_FORMAT, parseGitHistoryLog } from './git-history-log-parser' import { githubPullRequestHeadLocalRef, gitlabMergeRequestHeadLocalRef, @@ -378,4 +379,29 @@ describeBinaryCompatibility('real Git binary compatibility', () => { runGit(['show', '--end-of-options', `${pinnedOid}:absent.txt`]) ).rejects.toBeDefined() }) + // Why pin this: an older Git echoes %(decorate:…) and exits zero, so only %D + // in the same record carries the badges (#15507). Asserts the echo and the recovery. + it('reads commit decorations on both sides of the %(decorate:...) boundary', async () => { + await writeFile(join(repoPath, 'decorated.txt'), 'decorated\n') + await runGit(['add', 'decorated.txt']) + await runGit(['commit', '-qm', 'decorated commit']) + await runGit(['tag', 'compat-decorated']) + const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() + + const log = await runGit([ + 'log', + `--format=${GIT_HISTORY_COMMIT_FORMAT}`, + '-z', + '--decorate=full', + '-n1', + head + ]) + + expect(log.stdout.includes('%(decorate')).toBe(!supports(2, 43)) + + const [item] = parseGitHistoryLog(log.stdout) + expect(item?.id).toBe(head) + expect(item?.subject).toBe('decorated commit') + expect(item?.references?.map((ref) => ref.id)).toContain('refs/tags/compat-decorated') + }) }) diff --git a/src/shared/git-history-log-parser.ts b/src/shared/git-history-log-parser.ts index 8f34002f0cc..ddc354513b9 100644 --- a/src/shared/git-history-log-parser.ts +++ b/src/shared/git-history-log-parser.ts @@ -2,9 +2,15 @@ import type { GitHistoryItem, GitHistoryItemRef } from './git-history-types' import { iterateNulDelimitedFields } from './nul-delimited-fields' const GIT_HISTORY_DECORATION_SEPARATOR = '\x1f' +const GIT_HISTORY_LEGACY_DECORATION_SEPARATOR = ',' +// Why %D too: %(decorate:…) is Git 2.43+, and older Git echoes it verbatim and exits zero. +// Callers must pass --decorate=full; both fields emit short names otherwise, which parse to no refs. export const GIT_HISTORY_COMMIT_FORMAT = - '%H%n%aN%n%aE%n%at%n%ct%n%P%n%(decorate:prefix=,suffix=,separator=%x1f)%n%B' + '%H%n%aN%n%aE%n%at%n%ct%n%P%n%(decorate:prefix=,suffix=,separator=%x1f)%n%D%n%B' + +// Why exact-match: no ref name may contain the \x1f an old Git echoes here. +const UNEXPANDED_DECORATE_PLACEHOLDER = `%(decorate:prefix=,suffix=,separator=${GIT_HISTORY_DECORATION_SEPARATOR})` export function shortGitHash(hash: string): string { return hash.slice(0, 7) @@ -15,17 +21,18 @@ function commitSubject(message: string): string { return firstLine || '(no commit message)' } -function parseGitDecorationRefs(raw: string, revision: string): GitHistoryItemRef[] { +function parseGitDecorationRefs( + raw: string, + revision: string, + separator: string +): GitHistoryItemRef[] { if (!raw.trim()) { return [] } const refs: GitHistoryItemRef[] = [] - // Why: Git permits commas in ref names, so Orca's git log format uses a - // control-character separator that Git ref names cannot contain. - const parts = raw.includes(GIT_HISTORY_DECORATION_SEPARATOR) - ? raw.split(GIT_HISTORY_DECORATION_SEPARATOR) - : raw.split(',') + // Why passed in: a lone decoration carries no separator, so sniffing `raw` split `feat,one`. + const parts = raw.split(separator) for (const part of parts) { const ref = part.trim() @@ -115,8 +122,10 @@ export function parseGitHistoryLog(stdout: string): GitHistoryItem[] { const authorEmail = lines[2] ?? '' const authorDateSeconds = Number.parseInt(lines[3] ?? '', 10) const parents = (lines[5] ?? '').trim() - const decorations = lines[6] ?? '' - const message = lines.slice(7).join('\n').replace(/\n$/, '') + const decorateField = lines[6] ?? '' + const isLegacyGit = decorateField === UNEXPANDED_DECORATE_PLACEHOLDER + const decorations = isLegacyGit ? (lines[7] ?? '') : decorateField + const message = lines.slice(8).join('\n').replace(/\n$/, '') items.push({ id: hash, @@ -127,7 +136,11 @@ export function parseGitHistoryLog(stdout: string): GitHistoryItem[] { authorEmail: authorEmail || undefined, displayId: shortGitHash(hash), timestamp: Number.isFinite(authorDateSeconds) ? authorDateSeconds * 1000 : undefined, - references: parseGitDecorationRefs(decorations, hash) + references: parseGitDecorationRefs( + decorations, + hash, + isLegacyGit ? GIT_HISTORY_LEGACY_DECORATION_SEPARATOR : GIT_HISTORY_DECORATION_SEPARATOR + ) }) } return items diff --git a/src/shared/git-history.test.ts b/src/shared/git-history.test.ts index 54aac202c71..617fa33c2ef 100644 --- a/src/shared/git-history.test.ts +++ b/src/shared/git-history.test.ts @@ -16,6 +16,7 @@ function logRecord({ hash, parents = [], decorations = '', + legacyDecorations = '', message, author = 'Ada Lovelace', timestamp = 1_700_000_000 @@ -23,6 +24,7 @@ function logRecord({ hash: string parents?: string[] decorations?: string + legacyDecorations?: string message: string author?: string timestamp?: number @@ -35,6 +37,7 @@ function logRecord({ String(timestamp), parents.join(' '), decorations, + legacyDecorations, message ].join('\n')}\0` } @@ -94,8 +97,12 @@ describe('git history parsing', () => { const stdout = logRecord({ hash: HEAD_OID, parents: [BASE_OID], - decorations: - 'HEAD -> refs/heads/feature, refs/remotes/origin/HEAD -> refs/remotes/origin/feature, refs/remotes/origin/feature, tag: refs/tags/v1.0.0', + decorations: [ + 'HEAD -> refs/heads/feature', + 'refs/remotes/origin/HEAD -> refs/remotes/origin/feature', + 'refs/remotes/origin/feature', + 'tag: refs/tags/v1.0.0' + ].join(DECORATION_SEPARATOR), message: 'feat: add graph\n\nbody line' }) @@ -117,6 +124,39 @@ describe('git history parsing', () => { ]) }) + it('falls back to %D decorations when Git predates the %(decorate:…) placeholder', () => { + // Why: Git < 2.43 echoes the placeholder and exits zero (#15507). + const stdout = logRecord({ + hash: HEAD_OID, + decorations: `%(decorate:prefix=,suffix=,separator=${DECORATION_SEPARATOR})`, + legacyDecorations: 'HEAD -> refs/heads/feature, tag: refs/tags/v1.0.0', + message: 'feat: add graph' + }) + + const [item] = parseGitHistoryLog(stdout) + + expect(item?.subject).toBe('feat: add graph') + expect(item?.references?.map((ref) => [ref.id, ref.name, ref.category])).toEqual([ + ['refs/heads/feature', 'feature', 'branches'], + ['refs/tags/v1.0.0', 'v1.0.0', 'tags'] + ]) + }) + + it('keeps a comma inside a lone decoration, which carries no separator', () => { + // Why: a lone decoration carries no separator, so sniffing for \x1f split it in two. + const stdout = logRecord({ + hash: HEAD_OID, + decorations: 'HEAD -> refs/heads/feat,one', + message: 'initial' + }) + + const [item] = parseGitHistoryLog(stdout) + + expect(item?.references?.map((ref) => [ref.id, ref.name])).toEqual([ + ['refs/heads/feat,one', 'feat,one'] + ]) + }) + it('preserves commas inside branch and tag decoration names', () => { const stdout = logRecord({ hash: HEAD_OID, From 1e82f66e80c6891d5e9296dd14e7512fcfe45fbb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:15:15 -0700 Subject: [PATCH 18/94] fix(agents): clear the unread completion marker when acknowledging agents (#17924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acknowledging is one action against two records, but only clearTerminalPaneUnread cleared unreadAgentCompletionPanes. Acking from the Activity page, the dashboard drawer or the popout bridge left the tab dot, the ⌘J row and the floating-workspace dot lit with nothing left to read; only the terminal-view auto-ack path cleared both. Cleared inside the existing set so one ack is one commit, and only the agent marker is touched — clearTerminalPaneUnread also drops unreadTerminalPanes, which would silence a BEL the user never saw. Refs #15445 (step 2 of that issue's fix; steps 1 and 3 remain open). Co-authored-by: kaluli123123 <295758798+kaluli123123@users.noreply.github.com> --- .../slices/agent-status-ack-cleanup.test.ts | 28 +++++++++++++++++++ .../src/store/slices/ui-slice-test-harness.ts | 2 ++ .../store/slices/ui/ui-slice-agent-actions.ts | 17 ++++++++++- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts index b763262a22d..4ca35b9bdc2 100644 --- a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts +++ b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts @@ -117,3 +117,31 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => { expect(ackAt < newEntry.stateStartedAt).toBe(true) }) }) + +// Why: only the terminal-view path cleared unreadAgentCompletionPanes, so an +// Activity-page ack left the tab dot lit. +describe('acknowledgeAgents clears the unread agent-completion marker', () => { + it('drops the pane from unreadAgentCompletionPanes', () => { + const store = createTestStore() + store.getState().setAgentStatus('tab-1:0', { state: 'done', prompt: 'p', agentType: 'claude' }) + store.getState().markAgentCompletionPaneUnread('tab-1:0') + expect(store.getState().unreadAgentCompletionPanes['tab-1:0']).toBe(true) + + store.getState().acknowledgeAgents(['tab-1:0']) + + expect(store.getState().unreadAgentCompletionPanes['tab-1:0']).toBeUndefined() + }) + + it('leaves other panes and the terminal-bell unread map untouched', () => { + const store = createTestStore() + store.getState().markAgentCompletionPaneUnread('tab-1:0') + store.getState().markAgentCompletionPaneUnread('tab-2:0') + store.getState().markTerminalPaneUnread('tab-1:0') + + store.getState().acknowledgeAgents(['tab-1:0']) + + expect(store.getState().unreadAgentCompletionPanes['tab-2:0']).toBe(true) + // Why: a BEL is a separate signal; acking the agent must not silence it. + expect(store.getState().unreadTerminalPanes['tab-1:0']).toBe(true) + }) +}) diff --git a/src/renderer/src/store/slices/ui-slice-test-harness.ts b/src/renderer/src/store/slices/ui-slice-test-harness.ts index 318b24afef8..b8bbcf24b85 100644 --- a/src/renderer/src/store/slices/ui-slice-test-harness.ts +++ b/src/renderer/src/store/slices/ui-slice-test-harness.ts @@ -20,6 +20,8 @@ export function createUIStore(): StoreApi { combinedDiffFileTreeWidth: 256, rightSidebarTab: 'explorer', rightSidebarExplorerView: 'files', + // Why: acknowledgeAgents clears the agent-completion marker the terminal slice owns. + unreadAgentCompletionPanes: {}, ...createSettingsSearchState(args[0]), ...createWorktreeNavHistorySlice(...(args as Parameters)), ...createUISlice(...(args as Parameters)) diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts index b1401e7d416..9fa15ffb81e 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts @@ -217,7 +217,16 @@ export function createUiAgentActions( const migrationUnsupported = Object.values(s.migrationUnsupportedByPtyId ?? {}) // Why: only reallocate if an ack advances; compare prev | null = null + // Why: one ack, two records — leaving the completion marker set keeps the tab dot, + // the ⌘J row and the floating-workspace dot lit with nothing left to read. + let nextUnreadCompletions: Record | null = null for (const key of paneKeys) { + if (s.unreadAgentCompletionPanes[key]) { + if (nextUnreadCompletions === null) { + nextUnreadCompletions = { ...s.unreadAgentCompletionPanes } + } + delete nextUnreadCompletions[key] + } const prev = s.acknowledgedAgentsByPaneKey[key] ?? 0 // Why not plain Date.now(): a remote/SSH execution host can stamp a turn ahead of this clock, // and every unread rule is `ackAt < turnTimestamp`. A behind-the-turn ack can never clear the @@ -258,7 +267,13 @@ export function createUiAgentActions( next[key] = stamp } } - return next ? { acknowledgedAgentsByPaneKey: next } : s + if (!next && !nextUnreadCompletions) { + return s + } + return { + ...(next ? { acknowledgedAgentsByPaneKey: next } : {}), + ...(nextUnreadCompletions ? { unreadAgentCompletionPanes: nextUnreadCompletions } : {}) + } }) const notificationIds = [...notificationIdsToDismiss] if (notificationIds.length > 0 && typeof window !== 'undefined') { From 519af49a589e2c95acfe972a844cecc4fcdaa00e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:04:44 -0700 Subject: [PATCH 19/94] fix(dev): keep the shared Electron dist writable for the dev app pn dev crashes on macOS in any worktree that adopted the shared Electron dist. publishSharedElectronDist marks the cache entry read-only, which hardlink sharing needs, but clonefile preserves mode -- so the dist lands 0555, the dev runner copies it into out/electron-dev unchanged, and the first plutil -replace on Info.plist fails with a permission error. The shipped zip has that file at 0644; on disk it is 0555, so the mode is ours, not upstream's. copyPrivateTree now restores write permission. Its contract is a private tree the caller goes on to patch, and its one production caller is the dev runner. The test that should have caught this ran the wrapper with stdio: 'ignore', so a hard crash presented as a bare 20s timeout. It now captures the wrapper's output into the failure message, and waits long enough for the two synchronous swiftc builds and a codesign --deep over ~280MB that precede the assertion. --- config/scripts/space-sharing-copy.mjs | 50 +++++++--- config/scripts/space-sharing-copy.test.ts | 35 +++++++ .../startup/run-electron-vite-dev.test.ts | 93 +++++++++++++------ 3 files changed, 139 insertions(+), 39 deletions(-) diff --git a/config/scripts/space-sharing-copy.mjs b/config/scripts/space-sharing-copy.mjs index 191bd8bffdc..01e9c8ac5ef 100644 --- a/config/scripts/space-sharing-copy.mjs +++ b/config/scripts/space-sharing-copy.mjs @@ -110,32 +110,60 @@ export function makeTreeReadOnly(targetPath, chmod = chmodSync) { chmod(targetPath, 0o755) } +/** + * Restore owner write permission across a private copy. + * + * Counterpart to `makeTreeReadOnly`: clonefile, reflink and `cpSync` all carry the source's mode + * across, so a tree copied from the write-protected shared cache lands read-only and every patch + * the caller then makes -- `plutil -replace`, `codesign` -- fails with EACCES. Only the owner bit + * comes back; group and other stay as the source left them. + */ +export function makeTreeWritable(targetPath, chmod = chmodSync) { + for (const entry of readdirSync(targetPath, { withFileTypes: true })) { + const entryPath = join(targetPath, entry.name) + if (entry.isDirectory()) { + makeTreeWritable(entryPath, chmod) + } else if (!entry.isSymbolicLink()) { + const mode = statSync(entryPath, { throwIfNoEntry: false })?.mode + chmod(entryPath, mode === undefined ? 0o644 : mode | 0o200) + } + } + chmod(targetPath, 0o755) +} + /** * Share storage when possible, otherwise copy the bytes. * * Never hardlinks: this is for trees the caller goes on to patch, where shared inodes would write - * through into the source. + * through into the source. The copy is unprotected on the way out for the same reason -- a private + * tree the caller cannot write to is useless to it. */ export function copyPrivateTree(sourcePath, destinationPath, options = {}) { const platform = options.platform ?? process.platform const copy = options.copy ?? copyTreeVerbatim + const unprotect = options.unprotect ?? makeTreeWritable const privateMechanisms = new Set(['clone', 'reflink']) + let result = { mechanism: null, copyError: null } if (getShareMechanisms(platform).some((mechanism) => privateMechanisms.has(mechanism))) { try { - const mechanism = shareTree(sourcePath, destinationPath, { - ...options, - hardlink: () => { - throw new Error('hardlinks would not be private') - } - }) - return { mechanism, copyError: null } + result = { + mechanism: shareTree(sourcePath, destinationPath, { + ...options, + hardlink: () => { + throw new Error('hardlinks would not be private') + } + }), + copyError: null + } } catch (copyError) { copy(sourcePath, destinationPath) - return { mechanism: null, copyError } + result = { mechanism: null, copyError } } + } else { + copy(sourcePath, destinationPath) } - copy(sourcePath, destinationPath) - return { mechanism: null, copyError: null } + unprotect(destinationPath) + return result } function copyTreeVerbatim(sourcePath, destinationPath) { diff --git a/config/scripts/space-sharing-copy.test.ts b/config/scripts/space-sharing-copy.test.ts index 3ce35aae25e..351f44b286e 100644 --- a/config/scripts/space-sharing-copy.test.ts +++ b/config/scripts/space-sharing-copy.test.ts @@ -19,6 +19,7 @@ import { copyPrivateTree, hardlinkTree, makeTreeReadOnly, + makeTreeWritable, shareTree } from './space-sharing-copy.mjs' @@ -170,7 +171,41 @@ describe('makeTreeReadOnly', () => { ) }) +describe('makeTreeWritable', () => { + it.runIf(process.platform !== 'win32')('undoes makeTreeReadOnly for the owner', () => { + const { source } = makeTree() + makeTreeReadOnly(source) + makeTreeWritable(source) + const file = path.join(source, 'nested', 'file') + expect(statSync(file).mode & 0o200).toBe(0o200) + expect(() => writeFileSync(file, 'mutated')).not.toThrow() + }) + + it.runIf(process.platform !== 'win32')('adds no write permission beyond the owner', () => { + const { source } = makeTree() + const executable = path.join(source, 'electron') + writeFileSync(executable, 'binary') + chmodSync(executable, 0o555) + makeTreeWritable(source) + expect(statSync(executable).mode & 0o777).toBe(0o755) + }) +}) + describe('copyPrivateTree', () => { + it.runIf(process.platform !== 'win32')( + 'hands back a tree the caller can patch, even from a write-protected source', + () => { + const { root, source } = makeTree() + const destination = path.join(root, 'private') + makeTreeReadOnly(source) + copyPrivateTree(source, destination) + // The regression this guards: the shared Electron dist is read-only, clonefile/reflink/cpSync + // all carry that across, and `pn dev` then died patching the copied bundle's Info.plist. + expect(() => writeFileSync(path.join(destination, 'nested', 'file'), 'patched')).not.toThrow() + expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('contents') + } + ) + it('never hardlinks, because the caller patches what it gets back', () => { const { root, source } = makeTree() const destination = path.join(root, 'private') diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index 2146563373d..73d1bb21cdc 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -105,6 +105,56 @@ function devWrapperTestEnv(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return { ...env, ...extra } } +/** + * What the two cases below wait on: a ~280MB clone of Electron.app, two swiftc + * helper builds, and `codesign --deep` over the result. Six seconds on an idle + * machine; the swiftc builds alone pass fifteen when this file runs inside the + * full suite and every core is taken. The generous ceiling only costs time on a + * run that is already failing. + */ +const PREPARE_TIMEOUT_MS = 90_000 + +/** + * Spawns the wrapper with its output retained. + * + * Why retained: the wrapper reports its own failures on stderr, and discarding + * them turned a crash in prepare into a bare "Timed out waiting for condition" + * with nothing to act on. + */ +function spawnDevWrapper( + args: string[], + env: NodeJS.ProcessEnv +): { wrapper: ChildProcess; readOutput: () => string } { + const wrapper = spawn(process.execPath, args, { + cwd: resolve('.'), + env, + stdio: ['ignore', 'pipe', 'pipe'] + }) + let output = '' + const collect = (chunk: Buffer): void => { + output += chunk.toString() + } + wrapper.stdout?.on('data', collect) + wrapper.stderr?.on('data', collect) + return { wrapper, readOutput: () => output } +} + +async function waitForEnvFile(envFile: string, readOutput: () => string): Promise { + try { + await waitFor(() => { + try { + return readFileSync(envFile, 'utf8').trim().length > 0 + } catch { + return false + } + }, PREPARE_TIMEOUT_MS) + } catch (error) { + throw new Error( + `${(error as Error).message}: the dev wrapper never wrote ${envFile}. Wrapper output:\n${readOutput() || '(none)'}` + ) + } +} + describe('run-electron-vite-dev', () => { afterEach(async () => { for (const pid of processesToCleanUp) { @@ -351,26 +401,19 @@ describe('run-electron-vite-dev', () => { async function runWrapper(runId: string): Promise<{ electronExecPath: string }> { const pidFile = join(tempDir, `${runId}.pid`) const envFile = join(tempDir, `${runId}.json`) - const wrapper = spawn(process.execPath, [wrapperPath, '--remote-debugging-port=9448'], { - cwd: resolve('.'), - env: { + const { wrapper, readOutput } = spawnDevWrapper( + [wrapperPath, '--remote-debugging-port=9448'], + { ...baseEnv, ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile, ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile - }, - stdio: 'ignore' - }) + } + ) expect(wrapper.pid).toBeTypeOf('number') processesToCleanUp.add(wrapper.pid!) - await waitFor(() => { - try { - return readFileSync(envFile, 'utf8').trim().length > 0 - } catch { - return false - } - }, 20000) + await waitForEnvFile(envFile, readOutput) const trackedPids = trackPidFile(pidFile) @@ -409,7 +452,8 @@ describe('run-electron-vite-dev', () => { } } }, - 30000 + // Two full prepares, each budgeted at PREPARE_TIMEOUT_MS. + PREPARE_TIMEOUT_MS * 2 + 30_000 ) it.skipIf(process.platform !== 'darwin')( @@ -421,9 +465,9 @@ describe('run-electron-vite-dev', () => { const wrapperPath = resolve('config/scripts/run-electron-vite-dev.mjs') const fakeCliPath = resolve('src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs') - const wrapper = spawn(process.execPath, [wrapperPath, '--remote-debugging-port=9448'], { - cwd: resolve('.'), - env: devWrapperTestEnv({ + const { wrapper, readOutput } = spawnDevWrapper( + [wrapperPath, '--remote-debugging-port=9448'], + devWrapperTestEnv({ ORCA_ELECTRON_VITE_CLI: fakeCliPath, ORCA_SKIP_DEV_CLI_PREPARE: '1', ORCA_SKIP_DEV_WEB_PREPARE: '1', @@ -431,20 +475,13 @@ describe('run-electron-vite-dev', () => { ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile, ORCA_DEV_BRANCH: 'feature/framework-symlinks', ORCA_DEV_WORKTREE_NAME: 'symlink-ui' - }), - stdio: 'ignore' - }) + }) + ) expect(wrapper.pid).toBeTypeOf('number') processesToCleanUp.add(wrapper.pid!) - await waitFor(() => { - try { - return readFileSync(envFile, 'utf8').trim().length > 0 - } catch { - return false - } - }, 20000) + await waitForEnvFile(envFile, readOutput) const trackedPids = trackPidFile(pidFile) @@ -464,6 +501,6 @@ describe('run-electron-vite-dev', () => { await stopWrapperAndTrackedPids(wrapper, trackedPids) }, - 30000 + PREPARE_TIMEOUT_MS + 30_000 ) }) From a2aea5d0b05db182ae1315f608dfc6d00fa487ed Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:03:06 -0700 Subject: [PATCH 20/94] fix(persistence): sweep rows owned by deregistered repo ids at load Deregistering a project stranded every row it owned. Each pruning path is gated on the repo still being in `state.repos`, so once an id leaves the catalogue its metadata, identity aliases, lineage and session rows became unreachable forever -- and on a paired client they rendered as phantom worktrees under an "Unknown" project. Reconcile against the repo catalogue on load instead: any repo id that owns rows but is absent from `state.repos` has its rows removed through the same path `removeProject` uses. Host-independent and session-independent, because an orphan has no owner that could object -- which is also why this reaches a client's mirror of a remote host's session partition, something no local removal can do. Only a full `::` locator seeds the orphan set; bare keys can be folder workspace ids or repo-keyed revisions, and guessing wrong there would delete live state. `retiredWorktreeNamesByRepo` is deliberately untouched so a re-added repo cannot reissue a name onto a cwd that still holds a prior occupant's agent state. Test fixtures that wrote worktree rows without registering their repo were relying on orphans surviving a reload; they now register the repo they name. Refs #17776 --- .../profile-project-worktree-identity.ts | 19 +- ...ence-cohort-and-identity-migration.test.ts | 2 + ...rsistence-cross-host-pane-identity.test.ts | 6 + ...sistence-deregistered-repo-residue.test.ts | 176 ++++++++++++++++++ ...sistence-host-partitioned-sessions.test.ts | 10 + src/main/persistence-initial-load.test.ts | 2 + ...sistence-native-chat-tab-view-mode.test.ts | 2 +- src/main/persistence-repo-lifecycle.test.ts | 6 +- src/main/persistence-settings-update.test.ts | 2 +- ...sistence-ssh-targets-and-pane-keys.test.ts | 4 +- ...tence-worktree-lineage-and-backups.test.ts | 3 + .../repo-lifecycle-operations.ts | 50 +++++ src/main/persistence/loading-store/store.ts | 10 +- .../deregistered-repo-residue.ts | 82 ++++++++ .../ssh-reattach-pane-cardinality.test.ts | 10 +- .../worktree-identity-persistence.test.ts | 26 ++- 16 files changed, 389 insertions(+), 21 deletions(-) create mode 100644 src/main/persistence-deregistered-repo-residue.test.ts create mode 100644 src/main/persistence/tracking-repos/deregistered-repo-residue.ts diff --git a/src/main/orca-profiles/profile-project-worktree-identity.ts b/src/main/orca-profiles/profile-project-worktree-identity.ts index 1586a0e0120..f4063cbb372 100644 --- a/src/main/orca-profiles/profile-project-worktree-identity.ts +++ b/src/main/orca-profiles/profile-project-worktree-identity.ts @@ -66,15 +66,18 @@ export function rekeyOwnerKey( return null } -export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean { - const rawOwnerKey = isWorktreeHostIdentity(ownerKey) - ? getWorktreeIdFromHostIdentity(ownerKey) - : ownerKey - if (isRepoWorktreeId(repoId, rawOwnerKey)) { - return true +/** The worktree locator an owner key names, or null when the key is not worktree-scoped. */ +export function ownerKeyWorktreeId(ownerKey: string): string | null { + const scope = parseWorkspaceKey(ownerKey) + if (scope) { + return scope.type === 'worktree' ? scope.worktreeId : null } - const parsed = parseWorkspaceKey(ownerKey) - return parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId) + return isWorktreeHostIdentity(ownerKey) ? getWorktreeIdFromHostIdentity(ownerKey) : ownerKey +} + +export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean { + const worktreeId = ownerKeyWorktreeId(ownerKey) + return worktreeId !== null && isRepoWorktreeId(repoId, worktreeId) } export function removeRepoWorktreeRecord( diff --git a/src/main/persistence-cohort-and-identity-migration.test.ts b/src/main/persistence-cohort-and-identity-migration.test.ts index 4081672c821..a894b8a4c63 100644 --- a/src/main/persistence-cohort-and-identity-migration.test.ts +++ b/src/main/persistence-cohort-and-identity-migration.test.ts @@ -397,6 +397,8 @@ describe('Store.migrateWorktreeIdentity', () => { it('moves persisted mobile selections across reloads', async () => { const store = await createStore() + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + store.addRepo(makeRepo({ id: 'repo1', path: '/repo1' })) store.setMobileClientTabSelections({ 'device-a': { [OLD]: { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} } diff --git a/src/main/persistence-cross-host-pane-identity.test.ts b/src/main/persistence-cross-host-pane-identity.test.ts index 2b6a70da934..479d3727837 100644 --- a/src/main/persistence-cross-host-pane-identity.test.ts +++ b/src/main/persistence-cross-host-pane-identity.test.ts @@ -10,6 +10,7 @@ import { createStore, writeDataFile, readDataFile, + makeRepo, makeTerminalTab } from './persistence-test-harness' @@ -53,6 +54,11 @@ describe('cross-host pane identity migration', () => { it('refuses hostless alias and acknowledgement rewrites for a tab id two partitions share', async () => { writeDataFile({ schemaVersion: 1, + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + repos: [ + makeRepo({ id: 'repo-local', path: '/repo-local' }), + makeRepo({ id: 'repo-a', path: '/repo-a' }) + ], workspaceSession: makeLegacyPaneSession('repo-local', 'local-pty'), workspaceSessionsByHostId: { 'ssh:host-a': makeLegacyPaneSession('repo-a', 'pty-a') diff --git a/src/main/persistence-deregistered-repo-residue.test.ts b/src/main/persistence-deregistered-repo-residue.test.ts new file mode 100644 index 00000000000..f1ecbde3213 --- /dev/null +++ b/src/main/persistence-deregistered-repo-residue.test.ts @@ -0,0 +1,176 @@ +// Why this file exists: deregistering a project used to strand every row it owned. No sweeper could +// reach them -- the missing-directory prune is gated on the repo still being registered, and a +// paired client's mirror of a remote host's rows is keyed by ids that client never registers, so the +// owning host's removal never reached it (#17776). +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity' +import { folderWorkspaceKey } from '../shared/workspace-scope' +import type { PersistedState } from '../shared/persisted-state-types' +import { + testState, + createStore, + writeDataFile, + readDataFile, + makeRepo, + makeTerminalTab +} from './persistence-test-harness' + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) + +const LIVE_REPO = 'live-repo' +const GONE_REPO = 'gone-repo' +const LIVE_WORKTREE = `${LIVE_REPO}::/workspace/live` +const GONE_WORKTREE = `${GONE_REPO}::/workspace/orphan` +const RUNTIME_HOST = 'runtime:env-a' + +const sessionFor = (worktreeId: string, tabId = 'tab-1') => ({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: tabId, worktreeId })] + }, + activeTabTypeByWorktree: { [worktreeId]: 'terminal' as const }, + lastVisitedAtByWorktreeId: { [worktreeId]: 123 }, + // The residue `profile-project-session-field-disposition` flags as leaking on repo removal. + sleepingAgentSessionsByPaneKey: { + [`${tabId}:leaf-1`]: { + paneKey: `${tabId}:leaf-1`, + tabId, + worktreeId, + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'sess-1' }, + prompt: 'sleeping', + state: 'waiting' as const, + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' as const + } + } +}) + +describe('deregistered repo residue', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-orphan-sweep-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('drops metadata, identity rows and sessions owned by an unregistered repo id', async () => { + const seed = await createStore() + seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' })) + seed.addRepo(makeRepo({ id: GONE_REPO, path: '/workspace/orphan' })) + seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' }) + seed.setWorktreeMetaForHost(GONE_WORKTREE, 'local', { displayName: 'Orphan' }) + seed.setWorkspaceSession(sessionFor(GONE_WORKTREE), 'local') + seed.flush() + + // Deregister by hand: the point is that a row can outlive its repo however that happened. + const persisted = readDataFile() as PersistedState + persisted.repos = persisted.repos.filter((repo) => repo.id !== GONE_REPO) + writeDataFile(persisted) + + const reloaded = await createStore() + reloaded.flush() + const swept = readDataFile() as PersistedState + + expect(Object.keys(swept.worktreeMeta)).toEqual([LIVE_WORKTREE]) + expect(swept.worktreeIdentityAliases).not.toHaveProperty( + composeWorktreeHostIdentity('local', GONE_WORKTREE) + ) + expect(Object.keys(swept.worktreeMetaByIdentity ?? {})).toHaveLength(1) + const session = swept.workspaceSession + expect(session.tabsByWorktree).toEqual({}) + expect(session.lastVisitedAtByWorktreeId).toEqual({}) + expect(session.activeTabTypeByWorktree).toEqual({}) + expect(session.sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) + }) + + it("sweeps a remote host's session partition the owning host's removal can never reach", async () => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], + worktreeMeta: {}, + workspaceSessionsByHostId: { + [RUNTIME_HOST]: sessionFor(GONE_WORKTREE) + } + }) + + const store = await createStore() + store.flush() + + const partition = store.getWorkspaceSession(RUNTIME_HOST) + expect(partition.tabsByWorktree).toEqual({}) + expect(partition.activeTabTypeByWorktree).toEqual({}) + }) + + it('keeps rows for every registered repo, on any execution host', async () => { + const remoteWorktree = `${LIVE_REPO}::/home/user/remote` + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/home/user/live', executionHostId: RUNTIME_HOST })], + worktreeMeta: { [remoteWorktree]: { hostId: RUNTIME_HOST, status: 'active' } }, + workspaceSessionsByHostId: { [RUNTIME_HOST]: sessionFor(remoteWorktree) } + }) + + const store = await createStore() + + expect(store.getWorktreeMeta(remoteWorktree)).toBeDefined() + const partition = store.getWorkspaceSession(RUNTIME_HOST) + expect(partition.tabsByWorktree[remoteWorktree]).toHaveLength(1) + // Also proves the sleeping-agent fixture is well-formed, so the sweep assertions above bite. + expect(Object.keys(partition.sleepingAgentSessionsByPaneKey ?? {})).toHaveLength(1) + }) + + it('leaves folder-workspace session rows alone: their keys name no repo', async () => { + const workspaceKey = folderWorkspaceKey('folder-1') + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + workspaceSession: { + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { [workspaceKey]: 7 } + } + }) + + const store = await createStore() + + expect(store.getWorkspaceSession('local').lastVisitedAtByWorktreeId).toEqual({ + [workspaceKey]: 7 + }) + }) + + // Why: a sweep that dirtied every launch would rewrite the profile forever and mask real changes. + it('leaves a profile with no orphans byte-identical across reloads', async () => { + const seed = await createStore() + seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' })) + seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' }) + seed.setWorkspaceSession(sessionFor(LIVE_WORKTREE), 'local') + seed.flush() + + const canonicalizing = await createStore() + canonicalizing.flush() + const canonical = JSON.stringify(readDataFile()) + + const reloaded = await createStore() + reloaded.flush() + + expect(JSON.stringify(readDataFile())).toBe(canonical) + }) +}) diff --git a/src/main/persistence-host-partitioned-sessions.test.ts b/src/main/persistence-host-partitioned-sessions.test.ts index 023737ed386..aa2e46e52fd 100644 --- a/src/main/persistence-host-partitioned-sessions.test.ts +++ b/src/main/persistence-host-partitioned-sessions.test.ts @@ -123,6 +123,9 @@ describe('Store host-partitioned workspace sessions', () => { } }) + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + const makeRepos = (...repoIds: string[]) => repoIds.map((id) => makeRepo({ id, path: `/${id}` })) + it('migrates a legacy workspaceSession blob into the local partition', async () => { writeDataFile({ schemaVersion: 1, @@ -194,6 +197,7 @@ describe('Store host-partitioned workspace sessions', () => { writeDataFile({ schemaVersion: 1, workspaceSession: makeHostSession('local-repo'), + repos: makeRepos('repo-ssh'), workspaceSessionsByHostId: { 'ssh:ssh-1': makeLegacyPaneHostSession('repo-ssh', 'remote-pty') }, @@ -224,6 +228,7 @@ describe('Store host-partitioned workspace sessions', () => { writeDataFile({ schemaVersion: 1, workspaceSession: makeHostSession('local-repo'), + repos: makeRepos('repo-a', 'repo-b'), workspaceSessionsByHostId: { 'ssh:host-a': makeLegacyPaneHostSession('repo-a', 'pty-a'), 'ssh:host-b': makeLegacyPaneHostSession('repo-b', 'pty-b') @@ -488,6 +493,7 @@ describe('Store host-partitioned workspace sessions', () => { it('removes one orphaned worktree with a host-scoped topology fence', async () => { const store = await createStore() + store.addRepo(makeRepo({ id: 'repo-gone', path: '/repo-gone' })) const worktreeId = 'repo-gone::/workspace/stale' const session = { ...makeHostSession('repo-gone'), @@ -728,6 +734,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' writeDataFile({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSessionsByHostId: { 'runtime:good': makeHostSession('good-repo'), // activeRepoId must be string|null; a number fails the zod parse. @@ -753,6 +760,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' writeDataFile({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSession: { ...makeHostSession('local-repo'), // A projected/truncated write can leave a top-level field the wrong type; @@ -813,6 +821,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' const profile = await canonicalize({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSession: { ...makeHostSession('local-repo'), tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab-keep', worktreeId })] } @@ -845,6 +854,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' const profile = await canonicalize({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSessionsByHostId: { 'runtime:env-a': { ...makeHostSession('runtime-repo'), diff --git a/src/main/persistence-initial-load.test.ts b/src/main/persistence-initial-load.test.ts index 6d0c0f11d5e..934ab44e1cb 100644 --- a/src/main/persistence-initial-load.test.ts +++ b/src/main/persistence-initial-load.test.ts @@ -150,6 +150,8 @@ describe('Store', () => { it('does not restore a terminal tab after its durable close flush returns', async () => { const store = await createStore() + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + store.addRepo(makeRepo({ id: 'repo-1', path: '/repo-1' })) const worktreeId = 'repo-1::/tmp/worktree-1' const tabId = 'terminal-1' const session: WorkspaceSessionState = { diff --git a/src/main/persistence-native-chat-tab-view-mode.test.ts b/src/main/persistence-native-chat-tab-view-mode.test.ts index 3e3544c7495..9bcaab15494 100644 --- a/src/main/persistence-native-chat-tab-view-mode.test.ts +++ b/src/main/persistence-native-chat-tab-view-mode.test.ts @@ -58,7 +58,7 @@ describe('Store native-chat tab viewMode persistence', () => { const WORKTREE = 'repo1::/worktree' writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: {}, settings: {}, ui: {}, diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index 1653f78297e..66f3fc2c32e 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -737,7 +737,10 @@ describe('Store', () => { it('reassignSshTargetId persists a worktree-meta-only re-point (no matching repo)', async () => { const store = await createStore() - // A meta on the old SSH host with no repo row — the re-point must still be persisted, not memory-only. + // A meta on the old SSH host with no repo row for that host — the re-point must still be + // persisted, not memory-only. The repo id stays registered so the load-time orphan sweep, + // which only reads repo ids, leaves the row alone. + store.addRepo(makeRepo({ id: 'r1', path: '/r1' })) store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') @@ -787,6 +790,7 @@ describe('Store', () => { it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => { const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/r1' })) store.setWorkspaceSession( { activeRepoId: null, diff --git a/src/main/persistence-settings-update.test.ts b/src/main/persistence-settings-update.test.ts index dc3a3c7ebb5..9af63ca7ccd 100644 --- a/src/main/persistence-settings-update.test.ts +++ b/src/main/persistence-settings-update.test.ts @@ -708,7 +708,7 @@ describe('Store', () => { } writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: { 'repo1::/worktree-a': { status: 'active' }, 'repo1::/worktree-b': { status: 'active' } diff --git a/src/main/persistence-ssh-targets-and-pane-keys.test.ts b/src/main/persistence-ssh-targets-and-pane-keys.test.ts index 6c186ade077..c11ecbf0bc2 100644 --- a/src/main/persistence-ssh-targets-and-pane-keys.test.ts +++ b/src/main/persistence-ssh-targets-and-pane-keys.test.ts @@ -346,7 +346,7 @@ describe('Store', () => { const acknowledgedAt = 1_700_000_000_000 writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: {}, settings: {}, ui: { @@ -408,7 +408,7 @@ describe('Store', () => { writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: {}, settings: {}, ui: { diff --git a/src/main/persistence-worktree-lineage-and-backups.test.ts b/src/main/persistence-worktree-lineage-and-backups.test.ts index ef5e83745be..372e8b0e33b 100644 --- a/src/main/persistence-worktree-lineage-and-backups.test.ts +++ b/src/main/persistence-worktree-lineage-and-backups.test.ts @@ -166,6 +166,8 @@ describe('Store', () => { describe('mobileClientTabSelectionsByDeviceId', () => { it('persists device tab selections across reloads and drops malformed payloads', async () => { const store = await createStore() + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + store.addRepo(makeRepo({ id: 'repo-1', path: '/repo-1' })) store.setMobileClientTabSelections({ 'device-a': { 'repo-1::/tmp/wt': { activeTabId: 'tab-1', activeGroupId: 'g1', activeTabIdByGroupId: {} } @@ -188,6 +190,7 @@ describe('Store', () => { it('prunes selections for a removed repo worktree', async () => { const store = await createStore() store.addRepo(makeRepo()) + store.addRepo(makeRepo({ id: 'other-repo', path: '/other-repo' })) store.setMobileClientTabSelections({ 'device-a': { 'r1::/tmp/wt': { diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 095090ee1ca..c7bdbd9de37 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -12,10 +12,13 @@ import { import { mergeProjectHostSetupCompatibilityState } from '../tracking-repos/project-host-compatibility' import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning' +import { collectDeregisteredRepoIds } from '../tracking-repos/deregistered-repo-residue' import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration' import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' @@ -129,6 +132,33 @@ export class RepoLifecycleOperations { scheduleSave(this[repoLifecycleOperationsContext].scheduling) } + /** + * Drop every persisted row owned by a repo id that is no longer registered. + * + * Runs at load because no removal path can: `removeProject` only fires while the repo is still in + * `state.repos`, and a paired client's mirror of a remote host's rows is keyed by ids that client + * never registers, so the owning host's removal never reaches it (#17776). An orphan has no owner + * that could object, so this ignores the session-ownership and local-execution-host gates the + * missing-directory sweeper needs. + */ + sweepDeregisteredRepoResidue(): string[] { + const state = this[repoLifecycleOperationsContext].runtime.state + const orphanRepoIds = collectDeregisteredRepoIds(state) + if (orphanRepoIds.size === 0) { + return [] + } + for (const repoId of orphanRepoIds) { + pruneWorktreeStateForRepo(this, repoId, null) + state.workspaceSession = removeRepoFromWorkspaceSession(state.workspaceSession, repoId) + state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( + state.workspaceSessionsByHostId, + repoId + ) + } + pruneDeregisteredRepoUiResidue(state.ui, orphanRepoIds) + return [...orphanRepoIds] + } + updateRepo( id: string, updates: Partial< @@ -212,6 +242,26 @@ export function pruneMobileClientTabSelections( } } +function pruneDeregisteredRepoUiResidue( + ui: PersistedState['ui'], + orphanRepoIds: ReadonlySet +): void { + const isOrphanWorktree = (worktreeId: string): boolean => + orphanRepoIds.has(getRepoIdFromWorktreeId(worktreeId)) + if (ui.lastActiveRepoId && orphanRepoIds.has(ui.lastActiveRepoId)) { + ui.lastActiveRepoId = null + } + if (ui.lastActiveWorktreeId && isOrphanWorktree(ui.lastActiveWorktreeId)) { + ui.lastActiveWorktreeId = null + } + ui.filterRepoIds = ui.filterRepoIds?.filter((repoId) => !orphanRepoIds.has(repoId)) ?? [] + for (const worktreeId of Object.keys(ui.showDotfilesByWorktree ?? {})) { + if (isOrphanWorktree(worktreeId)) { + delete ui.showDotfilesByWorktree?.[worktreeId] + } + } +} + export function getRepoUpdateOperations( owner: RepoLifecycleOperations ): RepoUpdatePersistenceOperations { diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index 888c0092ed2..017583e8f86 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -64,6 +64,9 @@ export class Store { ) const adaptedProjectGroups = this.domains.adaptation.adaptFlatFolderScanProjectGroups() this.domains.adaptation.hydrateFolderWorkspaceDiffComments() + // Load is the only place an orphaned repo id can be swept: every removal path needs the repo to + // still be registered, so rows outlive their owner without one (#17776). + const sweptRepoIds = this.domains.repos.sweepDeregisteredRepoResidue() for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } @@ -78,7 +81,12 @@ export class Store { this.state.legacyPaneKeyAliasEntries = entries scheduleSave(this.domains.scheduling) }) - if (normalized.changed || this.runtime.loadNeedsSave || adaptedProjectGroups) { + if ( + normalized.changed || + this.runtime.loadNeedsSave || + adaptedProjectGroups || + sweptRepoIds.length > 0 + ) { scheduleSave(this.domains.scheduling) } } diff --git a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts new file mode 100644 index 00000000000..60a77a7ed10 --- /dev/null +++ b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts @@ -0,0 +1,82 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getWorktreeIdFromHostIdentity } from '../../../shared/worktree/host-qualified-identity' +import { splitWorktreeId } from '../../../shared/worktree/id' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition' +import { ownerKeyWorktreeId } from '../../orca-profiles/profile-project-worktree-identity' + +/** + * Repo ids that still own persisted rows but no longer appear in `state.repos`. + * + * Why nothing else finds them: every other sweeper is gated on the repo still being registered, so + * deregistering a project stranded the rows it owned permanently — including a paired client's + * mirror of a remote host's session partition, which no local repo removal can reach (#17776). + */ +export function collectDeregisteredRepoIds(state: PersistedState): Set { + const liveRepoIds = new Set(state.repos.map((repo) => repo.id)) + const orphanRepoIds = new Set() + // Only a full `::` locator seeds the set. A bare key -- a folder workspace id, a + // repo-keyed topology revision, a test-shaped locator -- cannot be told apart from a repo id, and + // guessing wrong here deletes live session state. + const addWorktreeId = (worktreeId: string | null | undefined): void => { + const repoId = worktreeId ? splitWorktreeId(worktreeId)?.repoId : undefined + if (repoId && !liveRepoIds.has(repoId)) { + orphanRepoIds.add(repoId) + } + } + const addOwnerKey = (ownerKey: string): void => { + addWorktreeId(ownerKeyWorktreeId(ownerKey)) + } + + // Deliberately not seeded from `sparsePresetsByRepo` or `retiredWorktreeNamesByRepo`: both are + // bounded, and dropping a retired-name row would let a re-added repo reissue a name onto a cwd + // that still holds a prior occupant's agent state. + for (const worktreeId of Object.keys(state.worktreeMeta)) { + addWorktreeId(worktreeId) + } + for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) { + addWorktreeId(getWorktreeIdFromHostIdentity(alias)) + } + for (const [childId, lineage] of Object.entries(state.worktreeLineageById)) { + addWorktreeId(childId) + addWorktreeId(lineage.parentWorktreeId) + } + for (const [childKey, lineage] of Object.entries(state.workspaceLineageByChildKey)) { + addOwnerKey(childKey) + addOwnerKey(lineage.parentWorkspaceKey) + } + for (const selections of Object.values(state.mobileClientTabSelectionsByDeviceId ?? {})) { + for (const worktreeId of Object.keys(selections)) { + addWorktreeId(worktreeId) + } + } + const sessions: (WorkspaceSessionState | undefined)[] = [ + state.workspaceSession, + ...Object.values(state.workspaceSessionsByHostId ?? {}) + ] + for (const session of sessions) { + if (!session) { + continue + } + for (const field of SESSION_FIELDS_PRUNED_BY_OWNER_KEY) { + for (const ownerKey of Object.keys( + (session[field] as Record | undefined) ?? {} + )) { + addOwnerKey(ownerKey) + } + } + for (const ownerKey of Object.keys(session.tabsByWorktree ?? {})) { + addOwnerKey(ownerKey) + } + for (const ownerKey of Object.keys(session.browserTabsByWorktree ?? {})) { + addOwnerKey(ownerKey) + } + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + addWorktreeId(record.worktreeId) + } + for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { + addWorktreeId(tombstone.worktreeId) + } + } + return orphanRepoIds +} diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index 51cbdb26794..96362ffbfc2 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -2,7 +2,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { testState, createStore, makeTerminalTab, writeDataFile } from './persistence-test-harness' +import { + testState, + createStore, + makeRepo, + makeTerminalTab, + writeDataFile +} from './persistence-test-harness' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' import { getDefaultPersistedState } from '../shared/constants' @@ -196,6 +202,8 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () it('does not clear and rebind a retired surface loaded from an older profile', async () => { const paneKey = `${TAB}:${TEST_LEAF_1}` const persisted = getDefaultPersistedState(testState.dir) + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + persisted.repos = [makeRepo({ id: 'repo1', path: '/repo1' })] persisted.workspaceSession = { ...persisted.workspaceSession, ...sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' }), diff --git a/src/main/worktree-identity-persistence.test.ts b/src/main/worktree-identity-persistence.test.ts index c66a2d72ce8..0b6dd178e84 100644 --- a/src/main/worktree-identity-persistence.test.ts +++ b/src/main/worktree-identity-persistence.test.ts @@ -5,12 +5,26 @@ import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' import { canonicalWorktreeIdentity } from '../shared/worktree/identity' import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity' -import { createStore, readDataFile, testState, writeDataFile } from './persistence-test-harness' +import type { Store } from './persistence/loading-store/store' +import { + createStore, + makeRepo, + readDataFile, + testState, + writeDataFile +} from './persistence-test-harness' describe('host-qualified worktree metadata', () => { const worktreeId = 'repo-1::/workspace/feature' const ROTATED_INSTANCE_ID = '44444444-4444-4444-8444-444444444444' + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + const createStoreWithRepo = (): Store => { + const store = createStore() + store.addRepo(makeRepo({ id: 'repo-1', path: '/workspace' })) + return store + } + beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-worktree-identity-')) }) @@ -52,7 +66,7 @@ describe('host-qualified worktree metadata', () => { }) }) it('reloads host-specific metadata without collapsing it to the legacy locator', () => { - const store = createStore() + const store = createStoreWithRepo() store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) store.flush() @@ -72,7 +86,7 @@ describe('host-qualified worktree metadata', () => { expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.comment).toBe('after') }) it('backfills one stable instance for legacy metadata that omitted it', () => { - const seed = createStore() + const seed = createStoreWithRepo() seed.setWorktreeMeta(worktreeId, { displayName: 'Legacy feature' }) seed.flush() const legacy = readDataFile() as PersistedState @@ -97,7 +111,7 @@ describe('host-qualified worktree metadata', () => { // Fails open on purpose: an ambiguous alias used to brick reads and throw out of the worktree // listing loop, taking every workspace in the repo down with it and never self-healing. it('collapses an ambiguous locator onto its most recently active instance', () => { - const seed = createStore() + const seed = createStoreWithRepo() const first = seed.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'First' }) seed.flush() const persisted = readDataFile() as PersistedState @@ -262,7 +276,7 @@ describe('host-qualified worktree metadata', () => { it('repairs a missing canonical instance id while re-adopting an SSH target', () => { const oldHostId = 'ssh:old-target' as const const newHostId = 'ssh:new-target' as const - const seed = createStore() + const seed = createStoreWithRepo() seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' }) seed.flush() const persisted = readDataFile() as PersistedState @@ -318,7 +332,7 @@ describe('host-qualified worktree metadata', () => { it('deduplicates an equivalent destination during SSH target re-adoption', () => { const oldHostId = 'ssh:old-target' as const const newHostId = 'ssh:new-target' as const - const seed = createStore() + const seed = createStoreWithRepo() seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' }) seed.flush() const persisted = readDataFile() as PersistedState From 2f105b23d17d715bb648ae1809f8c43a3d30a0c6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:19:57 -0700 Subject: [PATCH 21/94] fix(persistence): sweep sleeping-agent-only residue and stop mis-seeding orphans Review found three holes in the load-time sweep. `sleepingAgentSessionsByPaneKey` and `terminalSurfaceTombstonesByPaneKey` are pruned by the worktreeId they name, not by their own key, but `pruneWorktreeStateForRepo` only collected owner keys from `worktreeMeta` and `lastVisitedAtByWorktreeId`. An orphan whose only residue was a sleeping agent therefore survived the sweep and re-seeded it on the next load, so the store never self-cleared and every launch scheduled another save. Collect owner keys from those records too, which fixes `removeProject` for the same shape. `ownerKeyBelongsToRepo` is restored to its original body. Reordering its two readings was not behavior-preserving as claimed: for a repo named `folder` or `worktree`, checking the workspace-key reading first flips the result. The census now uses `ownerKeyWorktreeIds`, which returns both readings, and seeds only when neither names a live repo -- seeding one reading of a key whose other reading is live would hand the removal pass a live row to delete. Seed from `activeWorktreeId`, `activeWorkspaceKey` and `activeWorktreeIdsOnShutdown`, which are pruned by bespoke rules and so were reachable by no owner-key loop, and record why `terminalTopologyRevisionByRepoId` stays excluded. Refs #17776 --- .../profile-project-worktree-identity.ts | 31 ++++++++--- ...sistence-deregistered-repo-residue.test.ts | 54 ++++++++++++++----- .../deregistered-repo-residue.ts | 30 ++++++++++- .../tracking-repos/repo-worktree-pruning.ts | 23 ++++++-- 4 files changed, 111 insertions(+), 27 deletions(-) diff --git a/src/main/orca-profiles/profile-project-worktree-identity.ts b/src/main/orca-profiles/profile-project-worktree-identity.ts index f4063cbb372..8cf58dd1bd5 100644 --- a/src/main/orca-profiles/profile-project-worktree-identity.ts +++ b/src/main/orca-profiles/profile-project-worktree-identity.ts @@ -66,18 +66,33 @@ export function rekeyOwnerKey( return null } -/** The worktree locator an owner key names, or null when the key is not worktree-scoped. */ -export function ownerKeyWorktreeId(ownerKey: string): string | null { +/** + * Every worktree locator an owner key could name. + * + * Two readings, because one key can be both: with a repo literally named `worktree`, + * `worktree::/p` is a `::` locator AND parses as a `worktree:` workspace key naming + * repo `` (empty). `ownerKeyBelongsToRepo` accepts either, so a caller that reasons about a key + * without a repo id in hand has to consider both or it will disagree with the predicate. + */ +export function ownerKeyWorktreeIds(ownerKey: string): string[] { + const rawOwnerKey = isWorktreeHostIdentity(ownerKey) + ? getWorktreeIdFromHostIdentity(ownerKey) + : ownerKey const scope = parseWorkspaceKey(ownerKey) - if (scope) { - return scope.type === 'worktree' ? scope.worktreeId : null - } - return isWorktreeHostIdentity(ownerKey) ? getWorktreeIdFromHostIdentity(ownerKey) : ownerKey + return scope?.type === 'worktree' && scope.worktreeId !== rawOwnerKey + ? [rawOwnerKey, scope.worktreeId] + : [rawOwnerKey] } export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean { - const worktreeId = ownerKeyWorktreeId(ownerKey) - return worktreeId !== null && isRepoWorktreeId(repoId, worktreeId) + const rawOwnerKey = isWorktreeHostIdentity(ownerKey) + ? getWorktreeIdFromHostIdentity(ownerKey) + : ownerKey + if (isRepoWorktreeId(repoId, rawOwnerKey)) { + return true + } + const parsed = parseWorkspaceKey(ownerKey) + return parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId) } export function removeRepoWorktreeRecord( diff --git a/src/main/persistence-deregistered-repo-residue.test.ts b/src/main/persistence-deregistered-repo-residue.test.ts index f1ecbde3213..62d4aab957e 100644 --- a/src/main/persistence-deregistered-repo-residue.test.ts +++ b/src/main/persistence-deregistered-repo-residue.test.ts @@ -38,6 +38,21 @@ const LIVE_WORKTREE = `${LIVE_REPO}::/workspace/live` const GONE_WORKTREE = `${GONE_REPO}::/workspace/orphan` const RUNTIME_HOST = 'runtime:env-a' +const sleepingAgentFor = (worktreeId: string, tabId = 'tab-1') => ({ + [`${tabId}:leaf-1`]: { + paneKey: `${tabId}:leaf-1`, + tabId, + worktreeId, + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'sess-1' }, + prompt: 'sleeping', + state: 'waiting' as const, + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' as const + } +}) + const sessionFor = (worktreeId: string, tabId = 'tab-1') => ({ ...getDefaultWorkspaceSession(), tabsByWorktree: { @@ -46,20 +61,7 @@ const sessionFor = (worktreeId: string, tabId = 'tab-1') => ({ activeTabTypeByWorktree: { [worktreeId]: 'terminal' as const }, lastVisitedAtByWorktreeId: { [worktreeId]: 123 }, // The residue `profile-project-session-field-disposition` flags as leaking on repo removal. - sleepingAgentSessionsByPaneKey: { - [`${tabId}:leaf-1`]: { - paneKey: `${tabId}:leaf-1`, - tabId, - worktreeId, - agent: 'codex' as const, - providerSession: { key: 'session_id' as const, id: 'sess-1' }, - prompt: 'sleeping', - state: 'waiting' as const, - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep' as const - } - } + sleepingAgentSessionsByPaneKey: sleepingAgentFor(worktreeId, tabId) }) describe('deregistered repo residue', () => { @@ -156,6 +158,30 @@ describe('deregistered repo residue', () => { }) }) + // Regression: the pane-keyed records are pruned by the worktreeId they name, not by their own key, + // so an orphan whose ONLY residue is a sleeping agent survived -- and re-seeded the sweep on every + // launch, so the store never self-cleared and every load scheduled another save. + it("drops a sleeping agent that is the orphan repo's only residue, and self-clears", async () => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], + worktreeMeta: {}, + workspaceSession: { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: sleepingAgentFor(GONE_WORKTREE) + } + }) + + const store = await createStore() + store.flush() + expect(store.getWorkspaceSession('local').sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) + + // Self-clearing: with the residue gone nothing re-seeds the orphan id, so the next launch has + // no work. Before the fix this stayed non-empty forever and every load scheduled another save. + const reloaded = await createStore() + expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) + }) + // Why: a sweep that dirtied every launch would rewrite the profile forever and mask real changes. it('leaves a profile with no orphans byte-identical across reloads', async () => { const seed = await createStore() diff --git a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts index 60a77a7ed10..c52bddbb712 100644 --- a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts +++ b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts @@ -3,7 +3,7 @@ import { getWorktreeIdFromHostIdentity } from '../../../shared/worktree/host-qua import { splitWorktreeId } from '../../../shared/worktree/id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition' -import { ownerKeyWorktreeId } from '../../orca-profiles/profile-project-worktree-identity' +import { ownerKeyWorktreeIds } from '../../orca-profiles/profile-project-worktree-identity' /** * Repo ids that still own persisted rows but no longer appear in `state.repos`. @@ -24,8 +24,21 @@ export function collectDeregisteredRepoIds(state: PersistedState): Set { orphanRepoIds.add(repoId) } } + /** + * Seed from an owner key, which can read as two different locators (see `ownerKeyWorktreeIds`). + * All or nothing: if either reading names a live repo the key is that repo's, and seeding the + * other reading would hand the removal pass -- which accepts either -- a live row to delete. + */ const addOwnerKey = (ownerKey: string): void => { - addWorktreeId(ownerKeyWorktreeId(ownerKey)) + const repoIds = ownerKeyWorktreeIds(ownerKey).flatMap((worktreeId) => { + const repoId = splitWorktreeId(worktreeId)?.repoId + return repoId ? [repoId] : [] + }) + if (repoIds.length > 0 && repoIds.every((repoId) => !liveRepoIds.has(repoId))) { + for (const repoId of repoIds) { + orphanRepoIds.add(repoId) + } + } } // Deliberately not seeded from `sparsePresetsByRepo` or `retiredWorktreeNamesByRepo`: both are @@ -71,6 +84,19 @@ export function collectDeregisteredRepoIds(state: PersistedState): Set { for (const ownerKey of Object.keys(session.browserTabsByWorktree ?? {})) { addOwnerKey(ownerKey) } + // Pruned by bespoke rules rather than by owner key, so the loop above never reaches them. + for (const ownerKey of [ + session.activeWorktreeId, + session.activeWorkspaceKey, + ...(session.activeWorktreeIdsOnShutdown ?? []) + ]) { + if (ownerKey) { + addOwnerKey(ownerKey) + } + } + // Not seeded from `terminalTopologyRevisionByRepoId`: its keys are bare repo ids by contract, + // and a bare key is exactly what `addWorktreeId` refuses to trust. Rows there are removed once + // any locator seeds their repo id, which every repo that ever opened a terminal has. for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { addWorktreeId(record.worktreeId) } diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index f6d17b24aa2..a41f7c6319d 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -2,6 +2,7 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal' import { getExecutionHostIdFromWorktreeHostIdentity, @@ -58,10 +59,26 @@ export function pruneWorktreeStateForRepo( } } } - collectPrefixedKeys(Object.keys(state.worktreeMeta)) - collectPrefixedKeys(Object.keys(state.workspaceSession?.lastVisitedAtByWorktreeId ?? {})) - for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { + // Why the pane-keyed records contribute owner keys: they are pruned by the worktreeId they name, + // not by their own key, so a worktree with no meta and no visit row would otherwise keep its + // sleeping agents and tombstones forever -- and keep re-seeding the orphan sweep every load. + const collectScannedRecordOwners = (session: WorkspaceSessionState | undefined): void => { collectPrefixedKeys(Object.keys(session?.lastVisitedAtByWorktreeId ?? {})) + collectPrefixedKeys( + Object.values(session?.sleepingAgentSessionsByPaneKey ?? {}).map( + (record) => record.worktreeId + ) + ) + collectPrefixedKeys( + Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).map( + (tombstone) => tombstone.worktreeId + ) + ) + } + collectPrefixedKeys(Object.keys(state.worktreeMeta)) + collectScannedRecordOwners(state.workspaceSession) + for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { + collectScannedRecordOwners(session) } for (const key of Object.keys(state.worktreeMeta)) { From 87f3e907ddd72abd390897060d6e21d7471d9f51 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:49:04 -0700 Subject: [PATCH 22/94] test(persistence): assert the sleeping-agent cleanup reached disk The self-clearing check loaded a second store, but that constructor runs the sweep itself. If the first flush had not persisted the cleanup, the second load would have redone it in memory and the assertion would have passed without meaning anything. Read the profile back and assert the map is empty there first. Refs #17776 --- src/main/persistence-deregistered-repo-residue.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/persistence-deregistered-repo-residue.test.ts b/src/main/persistence-deregistered-repo-residue.test.ts index 62d4aab957e..03db3ed14fc 100644 --- a/src/main/persistence-deregistered-repo-residue.test.ts +++ b/src/main/persistence-deregistered-repo-residue.test.ts @@ -175,6 +175,10 @@ describe('deregistered repo residue', () => { const store = await createStore() store.flush() expect(store.getWorkspaceSession('local').sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) + // On disk, not just in memory: if the flush had not persisted the cleanup, the next load would + // silently redo it and the self-clearing assertion below would pass without meaning anything. + const persisted = readDataFile() as PersistedState + expect(persisted.workspaceSession.sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) // Self-clearing: with the residue gone nothing re-seeds the orphan id, so the next launch has // no work. Before the fix this stayed non-empty forever and every load scheduled another save. From 1a11f82fcc22dc613947449d285220cab0885f06 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:04:49 -0700 Subject: [PATCH 23/94] test(persistence): cover each session scalar as an orphan's only residue `activeWorktreeId`, `activeWorkspaceKey` and `activeWorktreeIdsOnShutdown` are pruned by bespoke rules rather than by owner key, so no owner-key loop reaches them and each has to be able to seed the sweep alone. The sweep already handles all three -- the census seeds from them and `removeRepoFromWorkspaceSession` clears them -- but nothing pinned it, and dropping that seeding turns all three cases red. The `activeWorkspaceKey` case uses the canonical `worktree:` form, so it also covers unwrapping the workspace key before the repo id is visible. Refs #17776 --- ...sistence-deregistered-repo-residue.test.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/main/persistence-deregistered-repo-residue.test.ts b/src/main/persistence-deregistered-repo-residue.test.ts index 03db3ed14fc..3a7a3372b3c 100644 --- a/src/main/persistence-deregistered-repo-residue.test.ts +++ b/src/main/persistence-deregistered-repo-residue.test.ts @@ -8,7 +8,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity' -import { folderWorkspaceKey } from '../shared/workspace-scope' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' import type { PersistedState } from '../shared/persisted-state-types' import { testState, @@ -186,6 +186,40 @@ describe('deregistered repo residue', () => { expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) }) + // The session scalars are pruned by bespoke rules, not by owner key, so no owner-key loop reaches + // them. Each has to be able to seed the sweep on its own or an orphan named only there is stuck. + it.each([ + { label: 'activeWorktreeId', session: { activeWorktreeId: GONE_WORKTREE } }, + // Canonical `worktree:` form, which needs unwrapping before the repo id is visible. + { + label: 'activeWorkspaceKey', + session: { activeWorkspaceKey: worktreeWorkspaceKey(GONE_WORKTREE) } + }, + { + label: 'activeWorktreeIdsOnShutdown', + session: { activeWorktreeIdsOnShutdown: [GONE_WORKTREE] } + } + ])("clears $label when it is the orphan repo's only residue", async ({ session }) => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], + worktreeMeta: {}, + workspaceSessionsByHostId: { + [RUNTIME_HOST]: { ...getDefaultWorkspaceSession(), ...session } + } + }) + + const store = await createStore() + store.flush() + + const partition = store.getWorkspaceSession(RUNTIME_HOST) + expect(partition.activeWorktreeId ?? null).toBeNull() + expect(partition.activeWorkspaceKey ?? null).toBeNull() + expect(partition.activeWorktreeIdsOnShutdown ?? []).toEqual([]) + const reloaded = await createStore() + expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) + }) + // Why: a sweep that dirtied every launch would rewrite the profile forever and mask real changes. it('leaves a profile with no orphans byte-identical across reloads', async () => { const seed = await createStore() From ff8b4d08ab98c8a4d8cabcadcbfe9628924b9995 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:11:21 -0700 Subject: [PATCH 24/94] fix(runtime): sweep missing local worktree metadata on the host that owns it `pruneMetadataMissingFromAuthoritativeLocalScan` had exactly one caller: `ipcMain.handle('worktrees:listAll')`. A headless runtime host has no renderer, so it never ran, and that host's `worktreeMeta` grew without bound even for its own local repos -- 129 of 139 rows dangling on the profile in #17776. Run it from the runtime's own detected listing instead. That is the same trigger on the same evidence: `listDetected` already prunes lineage on an authoritative scan, and a paired client refreshing a remote repo calls `worktree.detectedList`, so the host now sweeps exactly when the desktop would have. The expectation is captured before the scan, because listing can mutate metadata synchronously before its first await. WSL-routed repos are excluded for the reason the desktop listing excludes them: the listing runs in the distro and reports Linux paths while metadata can hold UNC ones, and v1 cannot prove those aliases equivalent. A runtime needing repair throws rather than resolving routing, which is likewise no basis for deleting rows. The prune's own gates still apply, so an SSH- or otherwise off-host repo is never swept from a local stat -- the execution host owns that verdict. Refs #17776 --- ...me-managed-worktree-metadata-sweep.test.ts | 117 ++++++++++++++++++ .../runtime-managed-worktree-queries.ts | 44 +++++++ src/main/runtime/runtime-store-contract.ts | 3 + 3 files changed, 164 insertions(+) create mode 100644 src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts diff --git a/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts new file mode 100644 index 00000000000..12d5d71e7e0 --- /dev/null +++ b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts @@ -0,0 +1,117 @@ +// Why this file exists: the authoritative missing-metadata prune had exactly one caller, +// `ipcMain.handle('worktrees:listAll')`. A headless runtime host has no renderer, so it never swept +// its own repos and their `worktreeMeta` rows grew without bound (#17776). +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import type { Repo } from '../../shared/repo-types' +import { testState, createStore, makeRepo } from '../persistence-test-harness' +import type { Store } from '../persistence/loading-store/store' +import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries' +import type { RuntimeStore } from './runtime-store-contract' + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) + +const gitWorktree = (path: string): GitWorktreeInfo => ({ + path, + branch: 'main', + head: 'abc1234', + isBare: false, + isMainWorktree: true +}) + +function queries( + store: Store, + repo: Repo, + worktrees: readonly GitWorktreeInfo[], + ok = true +): RuntimeManagedWorktreeQueries { + return new RuntimeManagedWorktreeQueries({ + getStore: () => store as unknown as RuntimeStore, + listResolved: async () => [], + resolveRepo: async () => repo, + selectRepos: () => [repo], + scanRepo: async () => ({ ok, worktrees: [...worktrees] }) + }) +} + +describe('runtime detected-worktree listing sweeps missing local metadata', () => { + let repoPath = '' + + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-runtime-sweep-')) + repoPath = join(testState.dir, 'repo') + mkdirSync(repoPath, { recursive: true }) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('drops a metadata row whose directory is gone and the scan does not list', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath }) + store.addRepo(repo) + const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` + store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) + expect(store.getWorktreeMeta(missingId)).toBeDefined() + + await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) + + expect(store.getWorktreeMeta(missingId)).toBeUndefined() + }) + + it('keeps a row whose directory still exists', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath }) + store.addRepo(repo) + const livePath = join(testState.dir, 'live-worktree') + mkdirSync(livePath, { recursive: true }) + const liveId = `${repo.id}::${livePath}` + store.setWorktreeMetaForHost(liveId, 'local', { displayName: 'Live' }) + + await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) + + expect(store.getWorktreeMeta(liveId)).toBeDefined() + }) + + // A non-authoritative scan is a failed listing, which is no evidence any checkout is gone. + it('keeps every row when the scan is not authoritative', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath }) + store.addRepo(repo) + const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` + store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) + + await queries(store, repo, [], false).listDetected(repo) + + expect(store.getWorktreeMeta(missingId)).toBeDefined() + }) + + // The execution host owns this verdict: a runtime host cannot stat an SSH checkout, so a local + // miss is not evidence of absence. See docs/reference/ssh-execution-boundary.md. + it('never sweeps a repo whose git runs off-host', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath, connectionId: 'build-box' }) + store.addRepo(repo) + const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` + store.setWorktreeMetaForHost(missingId, 'ssh:build-box', { displayName: 'Gone' }) + + await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) + + expect(store.getWorktreeMetaForHost(missingId, 'ssh:build-box')).toBeDefined() + }) +}) diff --git a/src/main/runtime/runtime-managed-worktree-queries.ts b/src/main/runtime/runtime-managed-worktree-queries.ts index d444f024e84..b0ed2bc4a3b 100644 --- a/src/main/runtime/runtime-managed-worktree-queries.ts +++ b/src/main/runtime/runtime-managed-worktree-queries.ts @@ -20,6 +20,10 @@ import { } from '../../shared/worktree/visibility-sources' import { mergeWorktree } from '../ipc/worktree-logic' import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning' +import { pruneMetadataMissingFromAuthoritativeLocalScan } from '../ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning' +import type { NativeLocalWorktreeMetadataScanExpectation } from '../persistence/tracking-repos/missing-local-worktree-metadata-pruning' +import { getLocalWorktreeScanGeneration } from '../local-worktree-scan-generation' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { Store } from '../persistence' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' @@ -36,6 +40,31 @@ type Dependencies = { scanRepo(repo: Repo): Promise } +/** + * The destructive scan expectation for one repo, or undefined when this repo must not carry one. + * + * WSL-routed repos are excluded for the same reason the desktop listing excludes them: the listing + * runs in the distro and reports Linux paths while metadata can hold UNC ones, and v1 cannot prove + * those aliases equivalent. A runtime that needs repair throws rather than resolving routing, which + * is likewise no basis for deleting rows. + */ +function captureLocalMetadataPruneExpectation( + store: RuntimeStore, + repo: Repo +): NativeLocalWorktreeMetadataScanExpectation | undefined { + if (typeof store.captureNativeLocalWorktreeMetadataScanExpectation !== 'function') { + return undefined + } + try { + if (getLocalProjectWorktreeGitOptions(store as unknown as Store, repo).wslDistro) { + return undefined + } + } catch { + return undefined + } + return store.captureNativeLocalWorktreeMetadataScanExpectation(repo) +} + export class RuntimeManagedWorktreeQueries { constructor(private readonly deps: Dependencies) {} @@ -129,6 +158,10 @@ export class RuntimeManagedWorktreeQueries { worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } } + // Why capture before the scan: listing can mutate metadata synchronously before its first + // await, and the prune revalidates against the rows as they stood when the scan was issued. + const metadataScanGeneration = getLocalWorktreeScanGeneration(repo.id) + const metadataPruneExpectation = captureLocalMetadataPruneExpectation(store, repo) let scan: RuntimeWorktreeScanResult try { scan = await this.deps.scanRepo(repo) @@ -136,6 +169,17 @@ export class RuntimeManagedWorktreeQueries { scan = { ok: false, worktrees: [] } } if (scan.ok) { + // Why the runtime sweeps too: the desktop listing that used to own this runs off `ipcMain`, + // so a headless host -- which has no renderer -- never pruned its own repos' rows (#17776). + if (metadataPruneExpectation) { + await pruneMetadataMissingFromAuthoritativeLocalScan({ + store: store as unknown as Store, + repo, + gitWorktrees: scan.worktrees, + scan: metadataPruneExpectation, + scanGeneration: metadataScanGeneration + }) + } pruneLineageForMissingRepoWorktrees(store as unknown as Store, repo, scan.worktrees) } const matcher = createWorktreeVisibilitySourceMatcher( diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 692826b3c29..e160e039533 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -30,6 +30,9 @@ export type RuntimeStore = { removeProjectForHost?: Store['removeProjectForHost'] reorderRepos?: Store['reorderRepos'] getAllWorktreeMeta: Store['getAllWorktreeMeta'] + captureNativeLocalWorktreeMetadataScanExpectation?: Store['captureNativeLocalWorktreeMetadataScanExpectation'] + pruneSessionlessMissingLocalWorktreeMetadataForRepo?: Store['pruneSessionlessMissingLocalWorktreeMetadataForRepo'] + getProfileStorageDirectory?: Store['getProfileStorageDirectory'] getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] setWorktreeMetaForHost?: Store['setWorktreeMetaForHost'] From 05a7d390588362eb7b2eff6922c62d13a3968bc7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:23:44 -0700 Subject: [PATCH 25/94] test(runtime): make the off-host sweep case a real control The row was stamped `ssh:build-box`, which `captureNativeLocalWorktreeMetadataScanExpectation` filters out before the prune runs -- so it survived whether or not any host gate existed and pinned nothing. Stamp it `local` so it is a genuine prune candidate whose directory really is missing, and make the fixture identical to the first case apart from `connectionId`. That pairing is what proves the behavior: the same fixture without a connection loses the row. Deleting any single gate would not show it, since four independent checks derive from `connectionId` on this path. Refs #17776 --- ...runtime-managed-worktree-metadata-sweep.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts index 12d5d71e7e0..6df19517d64 100644 --- a/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts +++ b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts @@ -61,6 +61,7 @@ describe('runtime detected-worktree listing sweeps missing local metadata', () = rmSync(testState.dir, { recursive: true, force: true }) }) + // Paired with the off-host case below: same fixture, no `connectionId`. it('drops a metadata row whose directory is gone and the scan does not list', async () => { const store = createStore() const repo = makeRepo({ id: 'repo-1', path: repoPath }) @@ -101,17 +102,22 @@ describe('runtime detected-worktree listing sweeps missing local metadata', () = expect(store.getWorktreeMeta(missingId)).toBeDefined() }) - // The execution host owns this verdict: a runtime host cannot stat an SSH checkout, so a local - // miss is not evidence of absence. See docs/reference/ssh-execution-boundary.md. + // The execution host owns this verdict: this host cannot stat a checkout that lives behind an SSH + // connection, so a local miss is not evidence of absence. See docs/reference/ssh-execution-boundary.md. + // + // Deliberately identical to the first case except for `connectionId`, and the row is stamped + // `local` so it is a real prune candidate. That pairing is the proof: the same fixture without a + // connection loses the row, so the connection is the only reason this one keeps it. Removing any + // single gate would not show that -- four independent checks derive from `connectionId` here. it('never sweeps a repo whose git runs off-host', async () => { const store = createStore() const repo = makeRepo({ id: 'repo-1', path: repoPath, connectionId: 'build-box' }) store.addRepo(repo) const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` - store.setWorktreeMetaForHost(missingId, 'ssh:build-box', { displayName: 'Gone' }) + store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) - expect(store.getWorktreeMetaForHost(missingId, 'ssh:build-box')).toBeDefined() + expect(store.getWorktreeMeta(missingId)).toBeDefined() }) }) From 398aeccdfea472584d976a62ea83c1d1c6f5ea2b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:22:04 -0700 Subject: [PATCH 26/94] fix(worktrees): retire runtime-host metadata a scan proved gone A paired client's WorktreeMeta for a runtime host is exempt from gcStaleWorktreeMeta -- that GC skips any row that is not local on both the repo and the meta's hostId -- so a scan-proven removal is the only thing that ever retires one. Both halves of that path were gated to `ssh:`, so the client kept a row for every remote worktree it had ever seen and dropped none. The renderer already computed the removals for runtime hosts and purged its own in-memory state with them; only the persisted half bailed. Widen it, and the matching main-side handler, to runtime hosts. `OffHostExecutionHostId` names the set precisely: the hosts the local-only GC skips. Also require `source === 'git'` before retiring anything. `session-fallback` reports `authoritative: true` but is the truncated, visibility-filtered `worktree.list` reply from a host too old for `worktree.detectedList`; its omissions are no evidence a checkout is gone. That guard did not matter while this only ran the in-memory purge, and does now that it deletes rows. A repo that reaches its checkouts over a connection is still never condemned under a runtime host id -- the host that executes owns that verdict. Refs #17776 --- ...orktrees-ssh-repo-owner-resolution.test.ts | 75 ++++++++++++- .../listing/register-host-catalog-handlers.ts | 13 ++- ...s-runtime-host-metadata-retirement.test.ts | 104 ++++++++++++++++++ .../authoritative-worktree-removal-memory.ts | 10 +- .../listing/fetched-worktree-merge.ts | 9 +- .../detected-worktree-provider-contract.ts | 8 +- 6 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/store/slices/worktrees-runtime-host-metadata-retirement.test.ts 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 6a398e9b02d..5a4d775c2d0 100644 --- a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' -import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from '../../shared/execution-host' import { getSshProviderAuthority } from '../ssh/ssh-provider-authority' import { listWorktreesMock, @@ -443,7 +447,74 @@ describe('registerWorktreeHandlers', () => { expect(store.removeWorktreeMeta).not.toHaveBeenCalled() }) - it('refuses to retire metadata for non-SSH hosts and unowned repos', async () => { + // Runtime-host rows are exempt from gcStaleWorktreeMeta exactly as SSH ones are, so a paired + // client needs this path to ever drop them (#17776). + it('retires runtime-host metadata an authoritative scan proved gone', async () => { + const runtimeHostId = toRuntimeExecutionHostId('env-1') + const runtimeRepo = { + id: 'repo-1', + path: '/home/orca/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: runtimeHostId + } + const metaById: Record> = { + 'repo-1::/home/orca/deleted': makeWorktreeMeta({ hostId: runtimeHostId }), + 'repo-1::/home/orca/other-host': makeWorktreeMeta({ + hostId: toSshExecutionHostId('target-a') + }) + } + store.getRepos.mockReturnValue([runtimeRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.removeWorktreeMeta.mockImplementation((worktreeId: string) => { + delete metaById[worktreeId] + }) + + const forgotten = await handlers['worktrees:forgetRemovedForExecutionHost'](null, { + repoId: runtimeRepo.id, + executionHostId: runtimeHostId, + worktreeIds: ['repo-1::/home/orca/deleted', 'repo-1::/home/orca/other-host'] + }) + + // The row stamped to another host needs that host's own scan, not this one's. + expect(forgotten).toEqual({ forgottenWorktreeIds: ['repo-1::/home/orca/deleted'] }) + expect(store.removeWorktreeMeta).toHaveBeenCalledExactlyOnceWith( + 'repo-1::/home/orca/deleted', + runtimeHostId + ) + }) + + // A repo that reaches its checkouts over SSH is not the runtime host's to condemn. + it('refuses to retire a connection-backed repo under a runtime host id', async () => { + const runtimeHostId = toRuntimeExecutionHostId('env-1') + store.getRepos.mockReturnValue([ + { + id: 'repo-1', + path: '/home/orca/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: runtimeHostId, + connectionId: 'target-a' + } + ]) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/home/orca/deleted': makeWorktreeMeta({ hostId: runtimeHostId }) + }) + + expect( + await handlers['worktrees:forgetRemovedForExecutionHost'](null, { + repoId: 'repo-1', + executionHostId: runtimeHostId, + worktreeIds: ['repo-1::/home/orca/deleted'] + }) + ).toEqual({ forgottenWorktreeIds: [] }) + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + + it('refuses to retire metadata for non-executing hosts and unowned repos', async () => { const sshRepo = { id: 'repo-1', path: '/remote/repo-a', diff --git a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts index 0d47f3638c2..56c2f282f37 100644 --- a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts @@ -103,11 +103,20 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' const worktreeIds = Array.isArray(args?.worktreeIds) ? args.worktreeIds : [] const parsedHost = parseExecutionHostId(requestedExecutionHostId) - if (parsedHost?.kind !== 'ssh' || worktreeIds.length === 0) { + // Runtime hosts belong here for the same reason SSH ones do: their rows are exempt from + // gcStaleWorktreeMeta, so a scan-proven removal is the only thing that ever retires them. + if ( + (parsedHost?.kind !== 'ssh' && parsedHost?.kind !== 'runtime') || + worktreeIds.length === 0 + ) { return nothingForgotten } const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId) - if (!repo || repo.connectionId !== parsedHost.targetId) { + // The connection must be the one the host id names, so a caller cannot retire a row belonging + // to a repo that reaches its checkouts some other way. + const connectionMatchesHost = + parsedHost.kind === 'ssh' ? repo?.connectionId === parsedHost.targetId : !repo?.connectionId + if (!repo || !connectionMatchesHost) { return nothingForgotten } // Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips diff --git a/src/renderer/src/store/slices/worktrees-runtime-host-metadata-retirement.test.ts b/src/renderer/src/store/slices/worktrees-runtime-host-metadata-retirement.test.ts new file mode 100644 index 00000000000..eeaf49dd5e4 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees-runtime-host-metadata-retirement.test.ts @@ -0,0 +1,104 @@ +// Why this file exists: a paired client's WorktreeMeta for a runtime host is exempt from +// gcStaleWorktreeMeta (it skips any row that is not local on both the repo and the meta's hostId), +// and `forgetPersistedWorktreeMetaForRemovals` used to bail for every non-SSH host. So the client +// kept a row per remote worktree it had ever seen and dropped none (#17776). +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import { makeWorktree } from './worktrees-slice-test-fixtures' +import { makeDetectedResult } from './worktrees-detected-listing-fixtures' +import { + createTestStore, + forgetRemovedForExecutionHostMock, + resetRemoteRuntimeMocks, + resetWorktreeSliceModuleMemory, + runtimeEnvironmentCall +} from './worktrees-slice-test-harness' + +const REPO_ID = 'repo-runtime' +const HOST_ID = 'runtime:env-1' + +const worktree = (path: string) => + makeWorktree({ id: `${REPO_ID}::${path}`, repoId: REPO_ID, path, hostId: HOST_ID }) + +const live = worktree('/home/orca/live') +const deletedOnHost = worktree('/home/orca/deleted') + +function seedClientWithBothRows(): ReturnType { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [ + { + id: REPO_ID, + path: '/home/orca/repo', + displayName: 'Runtime Repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: HOST_ID + } + ], + worktreesByRepo: { [REPO_ID]: [live, deletedOnHost] } + } as Partial) + return store +} + +beforeEach(resetWorktreeSliceModuleMemory) + +describe('runtime-host persisted metadata retirement', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('retires metadata for rows an authoritative runtime-host scan proved gone', async () => { + const store = seedClientWithBothRows() + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-detected', + ok: true, + result: makeDetectedResult(REPO_ID, [live]), + _meta: { runtimeId: 'runtime-remote' } + }) + + await store.getState().fetchWorktrees(REPO_ID, { executionHostId: HOST_ID }) + + expect(forgetRemovedForExecutionHostMock).toHaveBeenCalledExactlyOnceWith({ + repoId: REPO_ID, + executionHostId: HOST_ID, + worktreeIds: [deletedOnHost.id] + }) + }) + + // A non-authoritative reply is a failed listing, not a report that a checkout is gone. + it('retires nothing when the runtime host could not scan', async () => { + const store = seedClientWithBothRows() + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-detected', + ok: true, + result: makeDetectedResult(REPO_ID, [live], { + authoritative: false, + source: 'metadata-fallback' + }), + _meta: { runtimeId: 'runtime-remote' } + }) + + await store.getState().fetchWorktrees(REPO_ID, { executionHostId: HOST_ID }) + + expect(forgetRemovedForExecutionHostMock).not.toHaveBeenCalled() + }) + + // `session-fallback` claims authoritative but is the truncated, visibility-filtered `worktree.list` + // reply from a host too old for `worktree.detectedList`. Its omissions prove nothing. + it('retires nothing from a legacy session-fallback listing', async () => { + const store = seedClientWithBothRows() + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-detected', + ok: true, + result: makeDetectedResult(REPO_ID, [live], { source: 'session-fallback' }), + _meta: { runtimeId: 'runtime-remote' } + }) + + await store.getState().fetchWorktrees(REPO_ID, { executionHostId: HOST_ID }) + + expect(forgetRemovedForExecutionHostMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/listing/authoritative-worktree-removal-memory.ts b/src/renderer/src/store/slices/worktrees/listing/authoritative-worktree-removal-memory.ts index 9793adb0e16..e2fae6b9f77 100644 --- a/src/renderer/src/store/slices/worktrees/listing/authoritative-worktree-removal-memory.ts +++ b/src/renderer/src/store/slices/worktrees/listing/authoritative-worktree-removal-memory.ts @@ -50,16 +50,18 @@ export function resetAuthoritativelyRemovedWorktreeMemoryForTests(): void { authoritativelyRemovedWorktreeIdsByHost.clear() } -// Why: SSH WorktreeMeta is exempt from gcStaleWorktreeMeta (persistence.ts:407,415) and outlives the remote -// worktree, so a scan-proven removal must retire the metadata itself — otherwise the next launch's fallback -// re-lists the deleted row before the host connects, and the in-memory suppression above is already gone. +// Why: off-host WorktreeMeta is exempt from gcStaleWorktreeMeta -- it skips any row whose repo or hostId is +// not local -- and outlives the remote worktree, so a scan-proven removal must retire the metadata itself. +// Otherwise the next launch's fallback re-lists the deleted row before the host connects, and the in-memory +// suppression above is already gone. Runtime hosts were excluded until #17776, which is why a paired client +// accumulated a row per remote worktree it had ever seen and never dropped one. export function forgetPersistedWorktreeMetaForRemovals( repoId: string, hostId: ExecutionHostId, worktreeIds: readonly string[] ): void { const parsedHost = parseExecutionHostId(hostId) - if (worktreeIds.length === 0 || parsedHost?.kind !== 'ssh') { + if (worktreeIds.length === 0 || (parsedHost?.kind !== 'ssh' && parsedHost?.kind !== 'runtime')) { return } const forget = window.api.worktrees.forgetRemovedForExecutionHost diff --git a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts index 13e6b1572c1..fde831ee2e5 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts @@ -254,7 +254,14 @@ export function mergeFetchedWorktrees( // Why: applied outside the updater so a repeated updater call cannot double-apply the removal memory. forgetAuthoritativelyRemovedWorktrees(args.hostId, authoritativelySeenIds) rememberAuthoritativelyRemovedWorktrees(args.hostId, authoritativelyRemovedIds) - forgetPersistedWorktreeMetaForRemovals(args.repoId, args.hostId, authoritativelyRemovedIds) + // Only a real scan retires persisted metadata. `session-fallback` also reports authoritative, + // but it is the truncated, visibility-filtered `worktree.list` reply from a host too old for + // `worktree.detectedList` -- its omissions are not evidence a checkout is gone. + forgetPersistedWorktreeMetaForRemovals( + args.repoId, + args.hostId, + args.refresh.result.source === 'git' ? authoritativelyRemovedIds : [] + ) } return admitted } diff --git a/src/shared/detected-worktree-provider-contract.ts b/src/shared/detected-worktree-provider-contract.ts index 9f146e35778..99b0f8fdc74 100644 --- a/src/shared/detected-worktree-provider-contract.ts +++ b/src/shared/detected-worktree-provider-contract.ts @@ -41,9 +41,15 @@ export type HostQualifiedKnownWorktreeResult = executionHostId: SshExecutionHostId } +/** + * Hosts whose persisted metadata a scan can retire: exactly those `gcStaleWorktreeMeta` skips, + * because it only ever condemns rows that are local on both the repo and the meta's `hostId`. + */ +export type OffHostExecutionHostId = Extract + export type ForgetRemovedWorktreesForExecutionHostArgs = { repoId: string - executionHostId: SshExecutionHostId + executionHostId: OffHostExecutionHostId /** Ids an authoritative scan of this host proved gone — the only evidence that retires persisted metadata. */ worktreeIds: readonly string[] } From f2fa4a7754e03e975b0da31efa914cf9a552e154 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:06:24 -0700 Subject: [PATCH 27/94] fix(worktrees): drop an unreachable runtime arm from the retirement gate `findExactRepoOwner` already refuses a repo carrying both a runtime `executionHostId` and a `connectionId` -- `resolveRepoOwnershipEvidence` calls that pair contradictory, and one non-owned candidate voids the whole lookup. There is also no way for a `connectionId` to yield a `runtime:` host id, since `toSshExecutionHostId` always emits `ssh:`. The runtime arm of `connectionMatchesHost` could therefore never decide anything, and the test meant to pin it was passing through the contradiction gate instead. Keep the SSH arm, which does gate, and record where the runtime refusal actually comes from. Unreachable code on a destructive path reads as a guarantee it is not making. Refs #17776 --- .../ipc/worktrees-ssh-repo-owner-resolution.test.ts | 4 +++- .../listing/register-host-catalog-handlers.ts | 11 ++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) 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 5a4d775c2d0..b3231da86f6 100644 --- a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts @@ -486,7 +486,9 @@ describe('registerWorktreeHandlers', () => { ) }) - // A repo that reaches its checkouts over SSH is not the runtime host's to condemn. + // A repo that reaches its checkouts over SSH is not the runtime host's to condemn. The refusal + // comes from `findExactRepoOwner`: a runtime `executionHostId` beside a `connectionId` is + // contradictory ownership evidence, so no owner resolves at all. it('refuses to retire a connection-backed repo under a runtime host id', async () => { const runtimeHostId = toRuntimeExecutionHostId('env-1') store.getRepos.mockReturnValue([ diff --git a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts index 56c2f282f37..4467506e790 100644 --- a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts @@ -111,12 +111,13 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { ) { return nothingForgotten } + // No runtime arm in the check below: `findExactRepoOwner` already refuses a repo carrying both + // a runtime `executionHostId` and a `connectionId`, because `resolveRepoOwnershipEvidence` + // calls that pair contradictory and one non-owned candidate voids the whole lookup. A second + // check would be unreachable, and unreachable code on a destructive path reads as a guarantee + // it is not making. const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId) - // The connection must be the one the host id names, so a caller cannot retire a row belonging - // to a repo that reaches its checkouts some other way. - const connectionMatchesHost = - parsedHost.kind === 'ssh' ? repo?.connectionId === parsedHost.targetId : !repo?.connectionId - if (!repo || !connectionMatchesHost) { + if (!repo || (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId)) { return nothingForgotten } // Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips From d48ab9614465f175fcaa6d39beab2d6768b89a7b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:04:45 -0700 Subject: [PATCH 28/94] test: stop two suites failing for reasons unrelated to their subject The zsh wrapper test relocated into a fixed-name directory in shared temp, so a single killed run left it behind and every later run on that machine failed with ENOTEMPTY, permanently. Makes the name unique while keeping the non-ASCII component the test exists for. The palette budget asserted a helper named percentile95 that returns sorted[floor(n * 0.95)] -- the maximum of the batch. Asserting worst-case wall-clock under a parallel runner measures scheduler preemption: the asserted quantity ranged 123-343ms across 20 saturated windows and blew the 220ms budget in 6 of them, while the fastest sample of those same batches held at 19-32ms. Asserts the fastest sample instead and adds a deterministic fan-out ceiling, so the guard counts work rather than time. Budgets are unchanged. --- ...user-config-equivalence.live-shell.test.ts | 7 +- .../lib/palette-match/palette-match-budget.ts | 25 +++-- .../palette-match-performance.test.ts | 95 ++++++++++++------- 3 files changed, 86 insertions(+), 41 deletions(-) diff --git a/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts b/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts index e5e45d2c22a..cbb7d2da7c5 100644 --- a/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts +++ b/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts @@ -16,7 +16,7 @@ */ import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { getShellLaunchConfig } from './providers/local-pty-shell-ready' import { selectShellStartupFeatures } from './shell-startup-features' @@ -382,9 +382,12 @@ describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was bui // value this wrapper cannot use degrades to $HOME, where zsh itself looks. const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) try { + // Unique per run: a fixed name here shares one path with every other run in + // the system temp dir, so a killed run leaves a stale directory behind and + // every later rename onto it fails with ENOTEMPTY. const { values } = await runFromRelocatedRoot( home, - join(dirname(userDataPath), '홍길동-wsl-view') + join(dirname(userDataPath), `홍길동-${basename(userDataPath)}`) ) expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1') diff --git a/src/renderer/src/lib/palette-match/palette-match-budget.ts b/src/renderer/src/lib/palette-match/palette-match-budget.ts index c51144fb39d..864473a65e6 100644 --- a/src/renderer/src/lib/palette-match/palette-match-budget.ts +++ b/src/renderer/src/lib/palette-match/palette-match-budget.ts @@ -1,8 +1,14 @@ /** * Checked-in performance budget for the Cmd+J matcher, measured against the * synthetic corpus in `palette-match-performance.test.ts`. These are ceilings for - * catching order-of-magnitude regressions, not targets — the measured numbers on - * a developer machine sit roughly an order of magnitude under each one. + * catching order-of-magnitude regressions, not targets. + * + * The wall-clock ceilings are asserted against the *fastest* sample of a batch, + * never the slowest: a vitest worker sharing cores with the rest of the suite + * gets preempted mid-measurement, so the slowest sample measures the machine + * while the fastest still approximates the matcher. Fan-out regressions are + * caught by `fieldMatchesPerCandidate` instead, which counts work rather than + * time and so does not depend on machine speed at all. * * Raising any value requires a fresh measurement recorded in the PR. */ @@ -11,10 +17,17 @@ export const PALETTE_MATCH_BUDGET = { candidateCount: 800, /** Unique tokens in the worst supported query. */ tokenCount: 16, - /** p95 milliseconds to normalize every document once (cold open). */ - coldBuildP95Ms: 900, - /** p95 milliseconds to match the whole corpus against one prepared query. */ - warmMatchP95Ms: 220, + /** + * Ceiling on `matchPaletteField` calls per candidate for the worst query. + * Deterministic — it counts work, not time — so it catches a fan-out + * regression (re-matching every field per evidence unit, say) on any machine. + * Measured 45: 15 fields across the 3 tokens scanned before the first miss. + */ + fieldMatchesPerCandidate: 60, + /** Milliseconds to normalize every document once (cold open), fastest sample. */ + coldBuildMs: 900, + /** Milliseconds to match the whole corpus against one prepared query, fastest sample. */ + warmMatchMs: 220, /** * Megabytes of indexed text and offset tables the normalized documents retain. * Measured deterministically rather than from `heapUsed`, which is polluted by diff --git a/src/renderer/src/lib/palette-match/palette-match-performance.test.ts b/src/renderer/src/lib/palette-match/palette-match-performance.test.ts index aa1e6e12047..13fbced916f 100644 --- a/src/renderer/src/lib/palette-match/palette-match-performance.test.ts +++ b/src/renderer/src/lib/palette-match/palette-match-performance.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { PALETTE_MATCH_BUDGET } from './palette-match-budget' import { matchPaletteDocument } from './match-document' +import * as matchFieldModule from './match-field' import { preparePaletteQuery } from './palette-query' import { buildWorktreePaletteDocuments } from '../worktree-palette-document' +import type { PaletteDocument } from './palette-document' +import type { PaletteQueryToken } from './palette-query' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' @@ -89,49 +92,75 @@ const WORST_QUERY = Array.from({ length: tokenCount }, (_, index) => index === 0 ? 'scan' : index === 1 ? 'daily' : `token${index}` ).join(' ') -function percentile95(samples: number[]): number { - const sorted = [...samples].sort((a, b) => a - b) - return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))] +function prepareWorstQuery(): { tokens: readonly PaletteQueryToken[]; normalized: string } { + const prepared = preparePaletteQuery(WORST_QUERY) + if (prepared.state !== 'ready') { + throw new Error(`Expected a ready query, got ${prepared.state}`) + } + return { tokens: prepared.tokens, normalized: prepared.normalized } +} + +const preparedQuery = prepareWorstQuery() + +function matchEveryDocument(documents: ReadonlyMap): void { + for (const document of documents.values()) { + matchPaletteDocument({ + document, + tokens: preparedQuery.tokens, + normalizedQuery: preparedQuery.normalized + }) + } +} + +/** + * Why the fastest sample and not p95: this runs in a vitest worker competing for + * cores with the rest of the suite, so a slow sample records a preemption rather + * than the matcher. The fastest sample is the least contaminated estimate of + * intrinsic cost — measured stable within 1.6x on a fully saturated machine, + * while the slowest of the same batch swung by 17x. + */ +function fastestSample(samples: readonly number[]): number { + return Math.min(...samples) +} + +function timeRepeatedly(work: () => void, rounds: number): number[] { + const samples: number[] = [] + for (let round = 0; round < rounds; round += 1) { + const start = performance.now() + work() + samples.push(performance.now() - start) + } + return samples } describe('palette matcher performance budget', () => { it('normalizes a cold corpus within budget', () => { - const samples: number[] = [] - for (let run = 0; run < 5; run += 1) { - const start = performance.now() - buildWorktreePaletteDocuments(worktrees, sources) - samples.push(performance.now() - start) - } - expect(percentile95(samples)).toBeLessThan(PALETTE_MATCH_BUDGET.coldBuildP95Ms) + const samples = timeRepeatedly(() => buildWorktreePaletteDocuments(worktrees, sources), 5) + expect(fastestSample(samples)).toBeLessThan(PALETTE_MATCH_BUDGET.coldBuildMs) }) it('matches a 16-token query against warm documents within budget', () => { const documents = buildWorktreePaletteDocuments(worktrees, sources) - const prepared = preparePaletteQuery(WORST_QUERY) - expect(prepared.state).toBe('ready') - if (prepared.state !== 'ready') { - return - } - const matchAllDocuments = (): void => { - for (const document of documents.values()) { - matchPaletteDocument({ - document, - tokens: prepared.tokens, - normalizedQuery: prepared.normalized - }) - } - } + // Warm the matcher before timing so JIT compilation is not part of the samples. + matchEveryDocument(documents) - // Warm the matcher before timing so JIT compilation is not part of p95. - matchAllDocuments() - const samples: number[] = [] - for (let run = 0; run < 10; run += 1) { - const start = performance.now() - matchAllDocuments() - samples.push(performance.now() - start) + const samples = timeRepeatedly(() => matchEveryDocument(documents), 10) + expect(fastestSample(samples)).toBeLessThan(PALETTE_MATCH_BUDGET.warmMatchMs) + }) + + it('bounds field-match fan-out per candidate', () => { + const documents = buildWorktreePaletteDocuments(worktrees, sources) + const fieldMatch = vi.spyOn(matchFieldModule, 'matchPaletteField') + try { + matchEveryDocument(documents) + const perCandidate = fieldMatch.mock.calls.length / documents.size + // Guards the ceiling against going vacuous if the spy ever stops intercepting. + expect(perCandidate).toBeGreaterThan(0) + expect(perCandidate).toBeLessThan(PALETTE_MATCH_BUDGET.fieldMatchesPerCandidate) + } finally { + fieldMatch.mockRestore() } - expect(percentile95(samples)).toBeLessThan(PALETTE_MATCH_BUDGET.warmMatchP95Ms) }) it('keeps the retained document payload within budget', () => { From 4efc86a33c55948f4e3b2389adb7c99d9674c693 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:35:38 -0700 Subject: [PATCH 29/94] feat(app): open Markdown files from the OS in the floating workspace (#17906) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): open Markdown files from the OS in the floating workspace Registers Orca as a Markdown handler on macOS, Windows and Linux, and opens an OS-handed .md/.markdown/.mdx file as a floating-workspace editor tab — the one editor surface that needs no project. Works cold-start and when Orca is already running. Main buffers the paths and both pushes to a live renderer and answers a pull on renderer mount, mirroring SkillShareDeepLinkState. The buffer is only released once delivery is possible: the renderer's pull is what proves its ui:openMarkdownFiles listener is attached, because a push into a window whose renderer has not subscribed is dropped by Electron with no error. Both the push and the pull restore an undelivered batch, and a renderer reload clears the latch so the fresh renderer re-proves itself. Paths are stat'd and proven to be files before authorizeExternalPath sees them. Windows association is registered by hand in the NSIS include rather than through electron-builder's `fileAssociations`: app-builder-lib emits APP_ASSOCIATE, whose first line overwrites Software\Classes\.md's default value with no backup — silently taking .md from whichever editor owns it, for every existing user on their next update — and APP_UNASSOCIATE never restores it. The hand-rolled registration is additive (ProgID + OpenWithProgids + SupportedTypes) and leaves the user's default alone; verified end to end on a real Windows 11 host. Co-authored-by: Wooseong Kim Co-authored-by: Jaydev Closes #10138 * fix(os-open): register the new listener in the IPC inventory, and guard a non-array payload CI caught two things the local run did not. useIpcEvents-lifecycle.test.ts is an inventory of every App-lifetime IPC listener and the exact order they register in; ui.onOpenMarkdownFiles now appears there, positioned after the workspace-shortcut bridge's last listener, which is where it actually registers. Chasing that failure surfaced a real gap: the pending-open payload crosses the preload boundary, so a stale or mismatched preload can resolve with something that is not an array, and reading .length off it threw inside the promise chain instead of failing at the boundary. Array.isArray now gates it, with a regression test. --- config/electron-builder.config.cjs | 31 +- config/nsis/daemon-host-uninstall.nsh | 23 -- config/nsis/orca-installer-hooks.nsh | 79 +++++ ...ron-builder-markdown-associations.test.mjs | 116 +++++++ src/main/daemon/daemon-host-relocation.ts | 2 +- src/main/index.ts | 50 +++ .../startup/main-process-ipc-bootstrap.ts | 15 + src/main/startup/main-process-state.ts | 7 + src/main/startup/main-window-controller.ts | 3 + .../os-opened-markdown-delivery.test.ts | 68 ++++ .../startup/os-opened-markdown-files.test.ts | 306 ++++++++++++++++++ src/main/startup/os-opened-markdown-files.ts | 149 +++++++++ .../startup/os-opened-markdown-wiring.test.ts | 57 ++++ .../api/ui-bridge-state-and-menu-commands.ts | 9 + src/preload/api/ui-command-event-api.ts | 5 + .../use-floating-terminal-create-actions.ts | 20 +- .../ipc-events/app-lifetime-ipc-bridge.ts | 2 + .../os-markdown-file-open-bridge.test.ts | 300 +++++++++++++++++ .../os-markdown-file-open-bridge.ts | 72 +++++ .../src/hooks/useIpcEvents-lifecycle.test.ts | 2 + src/renderer/src/i18n/locales/en.json | 11 + ...pen-markdown-in-floating-workspace.test.ts | 81 +++++ .../open-markdown-in-floating-workspace.ts | 32 ++ .../src/web/preload-api/web-ui-api.ts | 3 + 24 files changed, 1399 insertions(+), 44 deletions(-) delete mode 100644 config/nsis/daemon-host-uninstall.nsh create mode 100644 config/nsis/orca-installer-hooks.nsh create mode 100644 config/scripts/electron-builder-markdown-associations.test.mjs create mode 100644 src/main/startup/os-opened-markdown-delivery.test.ts create mode 100644 src/main/startup/os-opened-markdown-files.test.ts create mode 100644 src/main/startup/os-opened-markdown-files.ts create mode 100644 src/main/startup/os-opened-markdown-wiring.test.ts create mode 100644 src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.test.ts create mode 100644 src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.ts create mode 100644 src/renderer/src/lib/open-markdown-in-floating-workspace.test.ts create mode 100644 src/renderer/src/lib/open-markdown-in-floating-workspace.ts diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 1a31af9dfaf..13633ed8e01 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -105,6 +105,11 @@ const winSpeechNativeResource = { to: 'node_modules/sherpa-onnx-win-x64' } +// Why mirrored, not imported: this config is CJS loaded by electron-builder outside the TS build. +// Keep in sync with isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts and with +// config/nsis/orca-installer-hooks.nsh, which registers the same set on Windows. +const MARKDOWN_FILE_EXTENSIONS = ['md', 'markdown', 'mdx'] + /** @type {import('electron-builder').Configuration} */ module.exports = { appId, @@ -376,12 +381,24 @@ module.exports = { shortcutName: '${productName}', uninstallDisplayName: '${productName}', createDesktopShortcut: 'always', - // Why: on a real uninstall, stop and remove the relocated terminal daemon - // (which lives outside the install dir under LOCALAPPDATA by design). Guarded - // by ${isUpdated} inside so it never runs during an update's uninstallOldVersion. - include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh') + // Why: electron-builder allows one include, so both Windows installer hooks live in it - + // the relocated-daemon uninstall sweep (guarded by ${isUpdated} so it never runs during an + // update's uninstallOldVersion) and the additive markdown "Open with" registration. + // Windows markdown association is deliberately NOT done via `fileAssociations`; see the + // header comment in that file for why that would steal the user's default .md handler. + include: resolve(__dirname, 'nsis', 'orca-installer-hooks.nsh') }, mac: { + // Why rank Alternate: Orca joins Finder's "Open With" list for Markdown without claiming + // LSHandlerRank ownership, so whichever editor the user already prefers stays the default. + // Why one entry per extension: app-builder-lib globs `*.${ext}`, which an array would break. + fileAssociations: MARKDOWN_FILE_EXTENSIONS.map((ext) => ({ + ext, + name: 'Markdown Document', + description: 'Markdown Document', + role: 'Editor', + rank: 'Alternate' + })), icon: 'resources/build/icon.icns', entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', @@ -468,6 +485,12 @@ module.exports = { artifactName: 'orca-macos-${arch}.${ext}' }, linux: { + // Why mimeTypes and not fileAssociations: shared-mime-info already maps *.md/*.markdown to + // text/markdown, so reusing that type puts Orca in the Open With list without shipping a glob + // override. A desktop entry's MimeType only adds a handler - mimeapps.list still owns the + // default. .mdx is deliberately absent: Ubuntu 24.04's mime database maps it to + // application/x-genesis-32x-rom, so claiming it here would need a glob override. + mimeTypes: ['text/markdown'], // Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca. // The Linux installer should not claim those system package/file names. executableName: 'orca-ide', diff --git a/config/nsis/daemon-host-uninstall.nsh b/config/nsis/daemon-host-uninstall.nsh deleted file mode 100644 index dc3a497ce67..00000000000 --- a/config/nsis/daemon-host-uninstall.nsh +++ /dev/null @@ -1,23 +0,0 @@ -; Clean up the relocated terminal daemon on a REAL uninstall. -; -; Why: the daemon host is deliberately copied to a distinct image name -; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app -; UPDATES cannot kill it — that relocation is what keeps terminals alive across -; updates. The same design means a normal uninstall's process sweep and file -; removal both miss it, leaving an orphaned daemon plus its runtime copy behind. -; -; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as -; part of uninstallOldVersion on EVERY update, and killing the daemon there would -; defeat the whole feature. Only clean up on a genuine uninstall. -; -; The image name and the LOCALAPPDATA folder name must stay in sync with -; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in -; src/main/daemon/daemon-host-relocation.ts. -!macro customUnInstall - ${ifNot} ${isUpdated} - nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe' - ; Give the OS a moment to release the image lock before removing the tree. - Sleep 500 - RMDir /r "$LOCALAPPDATA\Orca\daemon-host" - ${endIf} -!macroend diff --git a/config/nsis/orca-installer-hooks.nsh b/config/nsis/orca-installer-hooks.nsh new file mode 100644 index 00000000000..ca80c99fc6d --- /dev/null +++ b/config/nsis/orca-installer-hooks.nsh @@ -0,0 +1,79 @@ +; electron-builder NSIS hooks for the Orca Windows installer. +; +; electron-builder accepts exactly ONE `nsis.include` file, so every customInstall / +; customUnInstall hook Orca needs lives here. + +; --------------------------------------------------------------------------- +; Markdown "Open with Orca" (issue #10138) +; +; Why hand-rolled instead of electron-builder's `fileAssociations` on Windows: +; app-builder-lib emits !insertmacro APP_ASSOCIATE, whose first line is +; WriteRegStr SHELL_CONTEXT "Software\Classes\.md" "" "" +; That overwrites whichever editor currently owns .md, with no backup, for every +; existing user on their next UPDATE - and APP_UNASSOCIATE never restores it, so +; uninstalling Orca would leave .md pointing at a deleted ProgID. +; +; These writes are additive only. Registering a ProgID plus an OpenWithProgids +; hint and an Applications\\SupportedTypes entry puts Orca in Explorer's +; "Open with" list and in "Choose another app", while the default handler stays +; exactly where the user left it. Never add a `Software\Classes\.` default +; value here. +; +; MARKDOWN_PROGID must stay in sync with the extension list handled by +; isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts. +; --------------------------------------------------------------------------- +!define MARKDOWN_PROGID "Orca.Markdown" + +!macro ORCA_REGISTER_MARKDOWN_OPEN_WITH EXT + WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}" + WriteRegStr SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" "" +!macroend + +!macro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH EXT + DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}" + DeleteRegValue SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" +!macroend + +!macro customInstall + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" "" "Markdown Document" + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\DefaultIcon" "" "$appExe,0" + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open\command" "" '"$appExe" "%1"' + !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".md" + !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".markdown" + !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".mdx" + ; Why: Explorer caches the association list until told otherwise. + System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)" +!macroend + +; --------------------------------------------------------------------------- +; Clean up the relocated terminal daemon on a REAL uninstall. +; +; Why: the daemon host is deliberately copied to a distinct image name +; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app +; UPDATES cannot kill it — that relocation is what keeps terminals alive across +; updates. The same design means a normal uninstall's process sweep and file +; removal both miss it, leaving an orphaned daemon plus its runtime copy behind. +; +; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as +; part of uninstallOldVersion on EVERY update, and killing the daemon there would +; defeat the whole feature. Only clean up on a genuine uninstall. +; +; The image name and the LOCALAPPDATA folder name must stay in sync with +; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in +; src/main/daemon/daemon-host-relocation.ts. +!macro customUnInstall + ${ifNot} ${isUpdated} + nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe' + ; Give the OS a moment to release the image lock before removing the tree. + Sleep 500 + RMDir /r "$LOCALAPPDATA\Orca\daemon-host" + ${endIf} + ; Why outside the ${isUpdated} guard: customInstall rewrites these on every update, so + ; dropping them during uninstallOldVersion is correct and keeps the pair symmetric. + DeleteRegKey SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" + !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".md" + !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".markdown" + !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".mdx" + System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)" +!macroend diff --git a/config/scripts/electron-builder-markdown-associations.test.mjs b/config/scripts/electron-builder-markdown-associations.test.mjs new file mode 100644 index 00000000000..7ae3b1c9428 --- /dev/null +++ b/config/scripts/electron-builder-markdown-associations.test.mjs @@ -0,0 +1,116 @@ +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { basename } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const electronBuilderConfig = require('../electron-builder.config.cjs') + +const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdx'] + +// The exact shape app-builder-lib's APP_ASSOCIATE emits: a write to the DEFAULT ("") +// value of Software\Classes\.. Additive `WriteRegNone ...\OpenWithProgids` must not +// match, or the guard below would be unfalsifiable. +const DEFAULT_HANDLER_WRITE = /WriteRegStr\s+SHELL_CONTEXT\s+"Software\\Classes\\\.[a-z]+"\s+""/i + +// The hooks file documents the forbidden line in prose, so match executable script only. +const stripNsisCommentLines = (source) => + source + .split('\n') + .filter((line) => !/^\s*[;#]/.test(line)) + .join('\n') + +const readInstallerHooks = () => readFile(electronBuilderConfig.nsis.include, 'utf8') + +describe('electron-builder markdown file associations', () => { + // Why: any top-level (or `win.`) fileAssociations entry makes app-builder-lib's NSIS + // packager emit `!insertmacro APP_ASSOCIATE`, whose first line writes that DEFAULT value + // — silently taking .md from whichever editor owns it, for every existing user on their + // next UPDATE, with APP_UNASSOCIATE never restoring it. `rank: 'Alternate'` cannot + // prevent this; it is LSHandlerRank and applies to macOS only. So the mac block must + // stay under `mac.` — hoisting it up "to share it with Windows" is what this test blocks. + it('never claims the Windows default markdown handler', () => { + expect(electronBuilderConfig.fileAssociations).toBeUndefined() + expect(electronBuilderConfig.win?.fileAssociations).toBeUndefined() + }) + + it('joins the macOS Open With list for every markdown extension without owning it', () => { + const associations = electronBuilderConfig.mac.fileAssociations + // One entry per extension: an array `ext` would break the Linux packager's `*.${ext}` glob. + expect([...associations].map((association) => association.ext).sort()).toEqual( + [...MARKDOWN_EXTENSIONS].sort() + ) + for (const association of associations) { + expect(association).toMatchObject({ role: 'Editor', rank: 'Alternate' }) + } + }) + + // Why mimeTypes and not linux.fileAssociations: shared-mime-info already maps markdown to + // text/markdown, so the desktop entry only adds a handler and mimeapps.list keeps owning + // the default. A fileAssociations entry would ship a redundant glob override instead. + it('reuses the existing shared-mime-info markdown type on Linux', () => { + expect(electronBuilderConfig.linux.mimeTypes).toContain('text/markdown') + expect(electronBuilderConfig.linux.fileAssociations).toBeUndefined() + }) + + it('points the single NSIS include at the installer hooks file on disk', () => { + const includePath = electronBuilderConfig.nsis.include + expect(existsSync(includePath)).toBe(true) + expect(basename(includePath)).toBe('orca-installer-hooks.nsh') + }) + + // Guard for the guard: proves DEFAULT_HANDLER_WRITE really matches a takeover line, so + // the assertion below is a live check rather than a regex that can never fire. + it('recognizes an APP_ASSOCIATE-style default-handler write', () => { + for (const takeover of [ + ' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "Orca.Markdown"', + 'WriteRegStr SHELL_CONTEXT "Software\\Classes\\.markdown" "" "$0"' + ]) { + expect(takeover).toMatch(DEFAULT_HANDLER_WRITE) + } + expect( + 'WriteRegNone SHELL_CONTEXT "Software\\Classes\\.md\\OpenWithProgids" "Orca.Markdown"' + ).not.toMatch(DEFAULT_HANDLER_WRITE) + // Comment stripping must drop prose that quotes the bad line without swallowing a real + // one that happens to carry a trailing comment. + const stripped = stripNsisCommentLines( + [ + '; WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" ""', + ' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "$0" ; oops' + ].join('\n') + ) + expect(stripped.split('\n')).toHaveLength(1) + expect(stripped).toMatch(DEFAULT_HANDLER_WRITE) + }) + + it('registers Windows markdown Open With additively, never as the default', async () => { + const hooks = await readInstallerHooks() + + expect(stripNsisCommentLines(hooks)).not.toMatch(DEFAULT_HANDLER_WRITE) + // The additive hint that puts Orca in Explorer's "Open with" list. + expect(hooks).toMatch( + /WriteRegNone\s+SHELL_CONTEXT\s+"Software\\Classes\\\$\{EXT\}\\OpenWithProgids"/ + ) + expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_MARKDOWN_OPEN_WITH\s+EXT/) + for (const ext of MARKDOWN_EXTENSIONS) { + expect(hooks).toContain(`ORCA_REGISTER_MARKDOWN_OPEN_WITH ".${ext}"`) + expect(hooks).toContain(`ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".${ext}"`) + } + expect(hooks).toMatch(/!macro\s+customInstall\b/) + expect(hooks).toMatch(/!macro\s+customUnInstall\b/) + }) + + // Why: this include was renamed from daemon-host-uninstall.nsh to carry the markdown + // hooks too. electron-builder allows only one include, so a merge that drops the daemon + // sweep would silently orphan a running orca-terminal-daemon.exe on every uninstall. + it('keeps the daemon-host uninstall sweep across the include rename', async () => { + const hooks = await readInstallerHooks() + + expect(hooks).toContain('orca-terminal-daemon.exe') + expect(hooks).toContain('$LOCALAPPDATA\\Orca\\daemon-host') + // Without this guard, uninstallOldVersion would kill the daemon on every update — + // defeating the relocation that keeps terminals alive across updates. + expect(hooks).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/) + }) +}) diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index a7e8f2b6db2..6d94bea06e4 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -34,7 +34,7 @@ export type RelocatedDaemonHost = { const HOST_SUBDIR = 'daemon-host' const MARKER_NAME = '.materialized.json' -// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/daemon-host-uninstall.nsh) — keep in sync. +// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync. const LOCAL_HOST_ROOT_NAME = 'Orca' // Copy of Orca.exe renamed to a distinct image name so the NSIS updater's `taskkill /IM Orca.exe` can't match it. diff --git a/src/main/index.ts b/src/main/index.ts index acf913b2c7a..522e59b908f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,6 +12,7 @@ import { registerMainProcessIpcHandlers } from './startup/main-process-ipc-boots import { initializeMainProcessReady } from './startup/main-process-ready' import { installMainProcessQuitHandlers } from './startup/main-process-quit' import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock' +import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files' function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { return openMainWindowController(options) @@ -27,6 +28,7 @@ function requestDesktopActivation(argv: readonly string[] = []): void { state.skillShareDeepLinks.capture(argv, (shareId) => { state.mainWindow?.webContents.send('ui:openSkillShare', shareId) }) + state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles) // Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935). if (!shouldActivateDesktopForSecondInstance(argv)) { return @@ -34,6 +36,39 @@ function requestDesktopActivation(argv: readonly string[] = []): void { state.desktopActivationGate?.requestActivation() } +/** + * Hands buffered OS-opened markdown paths to a renderer that has proven it is listening. + * + * Until that proof arrives the paths stay buffered, because `webContents.send` to a renderer + * with no listener attached is dropped silently and the queue would be gone. + */ +function publishOsOpenedMarkdownFiles(): void { + const targetWindow = state.mainWindow + if (!state.markdownFileOpenListenerReady || !targetWindow || targetWindow.isDestroyed()) { + return + } + // Why consumed before the await: a renderer pull racing this resolve must not take the same + // batch again. The restore() calls hand it back if delivery turns out to be impossible. + const filePaths = state.osOpenedMarkdownFiles.consume() + if (filePaths.length === 0) { + return + } + void resolveOpenedMarkdownDocuments(filePaths) + .then((documents) => { + if (targetWindow.isDestroyed() || targetWindow.webContents.isDestroyed()) { + state.osOpenedMarkdownFiles.restore(filePaths) + return + } + if (documents.length > 0) { + targetWindow.webContents.send('ui:openMarkdownFiles', documents) + } + }) + .catch((error) => { + state.osOpenedMarkdownFiles.restore(filePaths) + console.warn('[os-open] Failed to resolve OS-opened markdown files:', error) + }) +} + const handleMacAppActivation = createMacAppActivationHandler({ getWindow: () => state.mainWindow, requestActivation: requestDesktopActivation @@ -53,7 +88,22 @@ if (preflightReady) { event.preventDefault() requestDesktopActivation([url]) }) + // Why: macOS delivers "Open With" as open-file, often before `ready`, and only to a handler + // that claims the event. Non-markdown paths stay unclaimed so the OS default handler wins. + app.on('open-file', (event, filePath) => { + if (!state.osOpenedMarkdownFiles.captureFilePaths([filePath], publishOsOpenedMarkdownFiles)) { + return + } + event.preventDefault() + // Why gated on isReady: pre-ready the cold-start window is already on its way, and + // activating the gate here would try to open one before Electron can. + if (app.isReady()) { + requestDesktopActivation() + } + }) state.skillShareDeepLinks.capture(process.argv) + // Why no publish: nothing is listening this early, so the first renderer pulls these on mount. + state.osOpenedMarkdownFiles.capture(process.argv) registerMainProcessIpcHandlers() installMainProcessQuitHandlers() void app.whenReady().then(async () => { diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index 3f364a9335c..89be84d2119 100644 --- a/src/main/startup/main-process-ipc-bootstrap.ts +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron' import { recoverLegacyWorkerTerminalsForRendererStartup } from './legacy-worker-renderer-recovery' import { logStartupMilestone } from './startup-diagnostics' import { mainProcessState as state } from './main-process-state' +import { resolveOpenedMarkdownDocuments } from './os-opened-markdown-files' export function registerMainProcessIpcHandlers(): void { ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { @@ -36,6 +37,20 @@ export function registerMainProcessIpcHandlers(): void { state.pendingOpenSettings.matches(event.sender.id, { consume: true }) ) ipcMain.handle('ui:consumePendingSkillShare', () => state.skillShareDeepLinks.consume()) + // Why: the renderer pulls this once its ui:openMarkdownFiles listener attaches, so a + // cold-start "Open With" queued before mount still opens. The pull doubles as the proof + // that the listener is live, which is what lets main start pushing. + ipcMain.handle('ui:consumePendingMarkdownFileOpens', async () => { + state.markdownFileOpenListenerReady = true + const filePaths = state.osOpenedMarkdownFiles.consume() + try { + return await resolveOpenedMarkdownDocuments(filePaths) + } catch (error) { + // Why restored: the renderer never received these, so a later mount must still get them. + state.osOpenedMarkdownFiles.restore(filePaths) + throw error + } + }) ipcMain.handle( 'app:startupDiagnostic', (_event, event: string, details?: Record) => { diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index ea1e0a33299..05c45d5b567 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -36,6 +36,7 @@ import type { ServeOptions } from './main-process-serve' import type { HangDetectionMarker } from '../hang-watchdog/hang-detection-marker' import { ServeReadinessPublisher } from '../server/serve-readiness' import { SkillShareDeepLinkState } from './skill-share-deep-link-state' +import { OsOpenedMarkdownFileState } from './os-opened-markdown-files' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, @@ -90,6 +91,12 @@ export const mainProcessState = { // Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount. pendingOpenSettings: createWebContentsTimedFlag(), skillShareDeepLinks: new SkillShareDeepLinkState(), + // Why: a Finder/Explorer "Open With" can land before any window exists; the renderer pulls this buffer on mount. + osOpenedMarkdownFiles: new OsOpenedMarkdownFileState(), + // Why a latch and not just "a window exists": a window can be up while its renderer has not + // attached the ui:openMarkdownFiles listener yet, and a push into that gap is dropped by + // Electron with no error. Only the renderer's own pull proves the listener is live. + markdownFileOpenListenerReady: false, firstWindowStartupServicesReady: Promise.resolve(), managedWslCliReconciliationReady: Promise.resolve(), managedWslCliStartupBarrierReady: Promise.resolve(), diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts index 57763b23ab1..0d935d5f84a 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -145,6 +145,9 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} clearExpectedRendererReload(rendererWebContentsId) recordCrashBreadcrumb('main_window_loaded') logStartupMilestone('did-finish-load') + // Why cleared here: a reload drops the old ui:openMarkdownFiles listener, and the fresh + // renderer re-attaches by pulling. Pushing into the gap between would be silently lost. + state.markdownFileOpenListenerReady = false const currentStore = state.store if (currentStore && resolveConsent(currentStore.getSettings()).effective === 'enabled') { trackAppOpenedOnce() diff --git a/src/main/startup/os-opened-markdown-delivery.test.ts b/src/main/startup/os-opened-markdown-delivery.test.ts new file mode 100644 index 00000000000..0647516e3fa --- /dev/null +++ b/src/main/startup/os-opened-markdown-delivery.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import { OsOpenedMarkdownFileState } from './os-opened-markdown-files' + +/** + * The two ways a queued "Open With" can be lost between main and the renderer. Both are + * about ownership: main must not drop paths it has not proven the renderer received. + */ +describe('os-opened markdown delivery ownership', () => { + it('keeps the batch when resolution rejects on the pull path', async () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths(['/notes/a.md']) + const resolve = vi.fn().mockRejectedValue(new Error('floating root unavailable')) + + // Mirrors the ipcMain.handle('ui:consumePendingMarkdownFileOpens') body. + const pull = async (): Promise => { + const filePaths = state.consume() + try { + return await resolve(filePaths) + } catch (error) { + state.restore(filePaths) + throw error + } + } + + await expect(pull()).rejects.toThrow('floating root unavailable') + // Without the restore the file would be gone and no later mount could ever open it. + expect(state.consume()).toEqual(['/notes/a.md']) + }) + + it('holds the batch while the renderer listener is not yet attached', () => { + const state = new OsOpenedMarkdownFileState() + const send = vi.fn() + let listenerReady = false + + // Mirrors publishOsOpenedMarkdownFiles()'s guard. + const publish = (): void => { + if (!listenerReady) { + return + } + const filePaths = state.consume() + if (filePaths.length > 0) { + send(filePaths) + } + } + + // A window exists, but the renderer has not mounted its bridge yet: send() here would be + // dropped by Electron with no error, and consuming would destroy the queue. + state.captureFilePaths(['/notes/a.md'], publish) + expect(send).not.toHaveBeenCalled() + + // The renderer's pull is what proves the listener is live. + listenerReady = true + state.captureFilePaths(['/notes/b.md'], publish) + expect(send).toHaveBeenCalledExactlyOnceWith(['/notes/a.md', '/notes/b.md']) + expect(state.consume()).toEqual([]) + }) + + it('restores a batch the window could no longer receive', () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths(['/notes/a.md']) + const filePaths = state.consume() + + // Window died between consume and send. + state.restore(filePaths) + + expect(state.consume()).toEqual(['/notes/a.md']) + }) +}) diff --git a/src/main/startup/os-opened-markdown-files.test.ts b/src/main/startup/os-opened-markdown-files.test.ts new file mode 100644 index 00000000000..f174e10d834 --- /dev/null +++ b/src/main/startup/os-opened-markdown-files.test.ts @@ -0,0 +1,306 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve, sep } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isMarkdownDocumentName } from '../ipc/markdown-documents' +import { + MAX_PENDING_OS_OPENED_MARKDOWN_FILES, + OsOpenedMarkdownFileState, + markdownPathsFromArguments, + resolveOpenedMarkdownDocuments +} from './os-opened-markdown-files' + +vi.mock('../ipc/filesystem-auth', () => ({ + authorizeExternalPath: vi.fn() +})) +vi.mock('../ipc/floating-workspace-directory', () => ({ + ensureDefaultFloatingWorkspacePath: vi.fn() +})) + +const { authorizeExternalPath } = await import('../ipc/filesystem-auth') +const { ensureDefaultFloatingWorkspacePath } = await import('../ipc/floating-workspace-directory') + +describe('markdownPathsFromArguments', () => { + it('keeps absolute markdown paths and drops other extensions', () => { + expect( + markdownPathsFromArguments( + [ + '/Users/dev/notes/a.md', + '/Users/dev/notes/b.markdown', + '/Users/dev/notes/c.mdx', + '/Users/dev/notes/d.txt', + '/Users/dev/src/e.tsx', + '/Users/dev/notes/README' + ], + 'darwin' + ) + ).toEqual(['/Users/dev/notes/a.md', '/Users/dev/notes/b.markdown', '/Users/dev/notes/c.mdx']) + }) + + it('drops switches, including Chromium-style ones that would otherwise look like values', () => { + expect( + markdownPathsFromArguments( + ['--serve', '-v', '--allow-file-access-from-files', '/Users/dev/notes/a.md'], + 'darwin' + ) + ).toEqual(['/Users/dev/notes/a.md']) + }) + + it('drops the executable and dev entries because none of them end in a markdown extension', () => { + const nonDocumentEntries = [ + '/Applications/Orca.app/Contents/MacOS/Orca', + '/Users/dev/orca/out/main/index.js', + '/Applications/Orca.app/Contents/Resources/app.asar' + ] + // The module documents that the extension check alone excludes these; hold it to that. + for (const entry of nonDocumentEntries) { + expect(isMarkdownDocumentName(entry), entry).toBe(false) + } + expect( + markdownPathsFromArguments([...nonDocumentEntries, '/Users/dev/notes/a.md'], 'darwin') + ).toEqual(['/Users/dev/notes/a.md']) + }) + + it('drops relative paths because a second instance has no meaningful cwd', () => { + expect( + markdownPathsFromArguments(['readme.md', './docs/a.md', '../up.md', ''], 'darwin') + ).toEqual([]) + }) + + it('accepts win32 drive-letter and UNC paths', () => { + expect( + markdownPathsFromArguments( + ['C:\\Users\\dev\\todo.md', '\\\\server\\share\\a.md', 'C:\\Users\\dev\\todo.txt'], + 'win32' + ) + ).toEqual(['C:\\Users\\dev\\todo.md', '\\\\server\\share\\a.md']) + }) + + it('dedupes case-insensitively on win32 and keeps the first spelling', () => { + expect(markdownPathsFromArguments(['C:\\notes\\A.md', 'c:\\notes\\a.md'], 'win32')).toEqual([ + 'C:\\notes\\A.md' + ]) + }) + + it('normalizes parent segments before deduping', () => { + expect( + markdownPathsFromArguments(['C:\\notes\\sub\\..\\a.md', 'C:\\notes\\a.md'], 'win32') + ).toEqual(['C:\\notes\\a.md']) + expect(markdownPathsFromArguments(['/docs/../notes/a.md', '/notes/a.md'], 'darwin')).toEqual([ + '/notes/a.md' + ]) + }) + + it('does not dedupe case-insensitively on posix, where casing is a different file', () => { + expect(markdownPathsFromArguments(['/a/A.md', '/a/a.md'], 'linux')).toEqual([ + '/a/A.md', + '/a/a.md' + ]) + }) + + it('accepts a file:// URI, which the desktop entry %U field code permits', () => { + // Why defensive rather than load-bearing: GLib decodes a local file:// URI to a plain + // path before spawning (measured on Ubuntu 24.04), so Linux hits the plain-path branch + // today. The %U spec still allows a URI, and a launcher that passes one literally would + // otherwise be dropped without a trace. + expect( + markdownPathsFromArguments( + ['file:///home/me/notes/a.md', 'file:///home/me/notes/b.txt'], + 'linux' + ) + ).toEqual(['/home/me/notes/a.md']) + }) + + it('percent-decodes a file:// URI so a path with spaces still opens', () => { + expect(markdownPathsFromArguments(['file:///home/me/design%20notes.md'], 'linux')).toEqual([ + '/home/me/design notes.md' + ]) + }) + + it('decodes win32 file:// URIs, including UNC authority form', () => { + expect( + markdownPathsFromArguments( + ['file:///C:/Users/me/todo.md', 'file://server/share/a.md'], + 'win32' + ) + ).toEqual(['C:\\Users\\me\\todo.md', '\\\\server\\share\\a.md']) + }) + + it('dedupes a path delivered as both a URI and a bare path', () => { + expect(markdownPathsFromArguments(['file:///home/me/a.md', '/home/me/a.md'], 'linux')).toEqual([ + '/home/me/a.md' + ]) + }) + + it('drops a malformed or non-file URL instead of throwing', () => { + expect(() => + markdownPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux') + ).not.toThrow() + expect( + markdownPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux') + ).toEqual([]) + }) + + it('honours the platform argument rather than the host OS', () => { + const argv = ['C:\\notes\\a.md', '/notes/b.md'] + // Same argv, two platforms: a win32 path is not absolute to posix, and posix input is + // renormalized to backslashes on win32. Neither result may depend on where the suite runs. + expect(markdownPathsFromArguments(argv, 'darwin')).toEqual(['/notes/b.md']) + expect(markdownPathsFromArguments(argv, 'win32')).toEqual(['C:\\notes\\a.md', '\\notes\\b.md']) + }) +}) + +// Why resolve(): the state uses the host platform by default, so fixture paths must already be +// spelled the way the host's path module normalizes them (`\n\a.md` and a drive on Windows). +const hostPath = (name: string): string => resolve(sep, 'notes', name) + +describe('OsOpenedMarkdownFileState', () => { + it('reports no capture and does not publish when argv carries no markdown', () => { + const state = new OsOpenedMarkdownFileState() + const publish = vi.fn() + + expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', '--serve'], publish)).toBe( + false + ) + expect(publish).not.toHaveBeenCalled() + expect(state.consume()).toEqual([]) + }) + + it('buffers and publishes when argv carries markdown', () => { + const state = new OsOpenedMarkdownFileState() + const publish = vi.fn() + const filePath = hostPath('a.md') + + expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', filePath], publish)).toBe( + true + ) + expect(publish).toHaveBeenCalledTimes(1) + expect(state.consume()).toEqual([filePath]) + }) + + it('captures a single macOS open-file path', () => { + const state = new OsOpenedMarkdownFileState() + const publish = vi.fn() + const filePath = hostPath('a.md') + + expect(state.captureFilePaths([filePath], publish)).toBe(true) + expect(state.captureFilePaths([hostPath('a.png')], publish)).toBe(false) + expect(publish).toHaveBeenCalledTimes(1) + expect(state.consume()).toEqual([filePath]) + }) + + it('does not duplicate a path captured twice', () => { + const state = new OsOpenedMarkdownFileState() + const filePath = hostPath('a.md') + + state.captureFilePaths([filePath]) + state.captureFilePaths([filePath]) + state.capture(['orca', filePath]) + + expect(state.consume()).toEqual([filePath]) + }) + + it('drains the buffer on consume', () => { + const state = new OsOpenedMarkdownFileState() + const paths = [hostPath('a.md'), hostPath('b.md')] + state.captureFilePaths(paths) + + expect(state.consume()).toEqual(paths) + expect(state.consume()).toEqual([]) + }) + + it('restores an undelivered batch at the front of the buffer', () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths([hostPath('later.md')]) + + state.restore([hostPath('undelivered.md')]) + + expect(state.consume()).toEqual([hostPath('undelivered.md'), hostPath('later.md')]) + }) + + it('caps the buffer when captures overflow it', () => { + const state = new OsOpenedMarkdownFileState() + const overflow = MAX_PENDING_OS_OPENED_MARKDOWN_FILES + 5 + const paths = Array.from({ length: overflow }, (_, index) => hostPath(`file-${index}.md`)) + + expect(state.captureFilePaths(paths)).toBe(true) + + expect(state.consume()).toEqual(paths.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES)) + }) + + it('caps the buffer when a restore overflows it', () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths([hostPath('pending.md')]) + const restored = Array.from({ length: MAX_PENDING_OS_OPENED_MARKDOWN_FILES }, (_, index) => + hostPath(`restored-${index}.md`) + ) + + state.restore(restored) + + const pending = state.consume() + expect(pending).toHaveLength(MAX_PENDING_OS_OPENED_MARKDOWN_FILES) + expect(pending).toEqual(restored) + }) +}) + +describe('resolveOpenedMarkdownDocuments', () => { + let floatingRoot: string + let fileRoot: string + + beforeEach(async () => { + vi.mocked(authorizeExternalPath).mockClear() + vi.mocked(ensureDefaultFloatingWorkspacePath).mockClear() + floatingRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-root-')) + fileRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-files-')) + vi.mocked(ensureDefaultFloatingWorkspacePath).mockResolvedValue(floatingRoot) + }) + + afterEach(async () => { + await rm(floatingRoot, { recursive: true, force: true }) + await rm(fileRoot, { recursive: true, force: true }) + }) + + it('resolves a real file outside the floating root to a basename-relative document', async () => { + const filePath = join(fileRoot, 'design notes.md') + await writeFile(filePath, '# hi\n', 'utf8') + + const documents = await resolveOpenedMarkdownDocuments([filePath]) + + expect(documents).toEqual([ + { + filePath, + relativePath: 'design notes.md', + basename: 'design notes.md', + name: 'design notes' + } + ]) + expect(authorizeExternalPath).toHaveBeenCalledWith(filePath) + }) + + it('drops a directory that merely looks like a markdown file', async () => { + const bundlePath = join(fileRoot, 'bundle.md') + await mkdir(bundlePath) + const filePath = join(fileRoot, 'real.md') + await writeFile(filePath, '# hi\n', 'utf8') + + const documents = await resolveOpenedMarkdownDocuments([bundlePath, filePath]) + + expect(documents.map((document) => document.filePath)).toEqual([filePath]) + // Security contract: a path we never validated must never be authorized for renderer reads. + expect(authorizeExternalPath).toHaveBeenCalledTimes(1) + expect(authorizeExternalPath).toHaveBeenCalledWith(filePath) + }) + + it('drops a path that no longer exists without authorizing it', async () => { + const missingPath = join(fileRoot, 'gone.md') + + expect(await resolveOpenedMarkdownDocuments([missingPath])).toEqual([]) + expect(authorizeExternalPath).not.toHaveBeenCalled() + }) + + it('returns nothing for an empty input without touching the filesystem', async () => { + expect(await resolveOpenedMarkdownDocuments([])).toEqual([]) + expect(ensureDefaultFloatingWorkspacePath).not.toHaveBeenCalled() + expect(authorizeExternalPath).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/startup/os-opened-markdown-files.ts b/src/main/startup/os-opened-markdown-files.ts new file mode 100644 index 00000000000..dae27fb7a78 --- /dev/null +++ b/src/main/startup/os-opened-markdown-files.ts @@ -0,0 +1,149 @@ +import { stat } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' +import { authorizeExternalPath } from '../ipc/filesystem-auth' +import { ensureDefaultFloatingWorkspacePath } from '../ipc/floating-workspace-directory' +import { isMarkdownDocumentName, markdownDocumentFromFilePath } from '../ipc/markdown-documents' + +// Why: a shell can only ever hand over the files the user selected; anything past this is a +// runaway argv, and buffering it unbounded would pin the paths for the whole session. +export const MAX_PENDING_OS_OPENED_MARKDOWN_FILES = 32 + +/** + * Resolves one argv entry to a local absolute path, or null if it is not one. + * + * Why file:// is accepted defensively: electron-builder appends the `%U` field code to the + * generated Linux `Exec=` line, and `%U` is specified as "URLs". GLib turns out to decode a + * local `file://` URI back to a plain path before spawning (measured on Ubuntu 24.04, via + * the same `launch_uris` call a file manager makes), so the branch below is not what fires + * there today — but the spec permits a URI, and a launcher that honours it literally would + * otherwise be silently dropped. macOS `open-file` and the Windows shell `%1` pass paths. + */ +function localPathFromArgument(argument: string, platform: NodeJS.Platform): string | null { + const pathApi = platform === 'win32' ? path.win32 : path.posix + if (argument.startsWith('file://')) { + try { + // Why the explicit windows flag: this must decode the same way on any host so the + // behaviour is testable, and it is what turns `file://server/share` back into a UNC path. + return fileURLToPath(argument, { windows: platform === 'win32' }) + } catch { + return null + } + } + return pathApi.isAbsolute(argument) ? argument : null +} + +/** + * Absolute markdown paths an OS "Open With" put on a launch or second-instance argv. + * + * Why no executable/asar/dev-entry filtering: none of those argv entries end in a markdown + * extension, so the extension check already excludes them. Relative entries are dropped + * because the shell always passes absolute paths and `cwd` is meaningless for a second instance. + */ +export function markdownPathsFromArguments( + argv: readonly string[], + platform: NodeJS.Platform = process.platform +): string[] { + const pathApi = platform === 'win32' ? path.win32 : path.posix + const seen = new Set() + const paths: string[] = [] + for (const rawArgument of argv) { + if (!rawArgument || rawArgument.startsWith('-')) { + continue + } + const argument = localPathFromArgument(rawArgument, platform) + if (!argument || !isMarkdownDocumentName(argument)) { + continue + } + const normalized = pathApi.normalize(argument) + // Why lowercased on win32: the shell round-trips drive letters and 8.3 casing + // inconsistently, and two spellings of one path must not open two tabs. + const key = platform === 'win32' ? normalized.toLowerCase() : normalized + if (seen.has(key)) { + continue + } + seen.add(key) + paths.push(normalized) + } + return paths +} + +/** + * Buffers markdown paths the OS handed us until a renderer can receive them. + * + * Mirrors SkillShareDeepLinkState: main pushes when a window is already live, and the + * renderer pulls the same buffer when its listener attaches, so a cold-start "Open With" + * that lands before mount is not dropped. + */ +export class OsOpenedMarkdownFileState { + private pending: string[] = [] + + /** Returns true when argv carried at least one markdown path. */ + capture(argv: readonly string[], publish?: () => void): boolean { + return this.add(markdownPathsFromArguments(argv), publish) + } + + /** Returns true when at least one path was a markdown document. */ + captureFilePaths(filePaths: readonly string[], publish?: () => void): boolean { + return this.add(markdownPathsFromArguments(filePaths), publish) + } + + consume(): string[] { + const pending = this.pending + this.pending = [] + return pending + } + + /** Puts an undelivered batch back at the front so the next renderer still receives it. */ + restore(filePaths: readonly string[]): void { + this.pending = [...filePaths, ...this.pending].slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES) + } + + private add(filePaths: readonly string[], publish?: () => void): boolean { + if (filePaths.length === 0) { + return false + } + const merged = [...this.pending] + for (const filePath of filePaths) { + if (!merged.includes(filePath)) { + merged.push(filePath) + } + } + this.pending = merged.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES) + publish?.() + return true + } +} + +/** + * Turns OS-handed paths into the same `MarkdownDocument` shape the floating workspace's own + * file picker produces, authorizing each one for the renderer's later read. + */ +export async function resolveOpenedMarkdownDocuments( + filePaths: readonly string[] +): Promise { + if (filePaths.length === 0) { + return [] + } + const floatingRoot = await ensureDefaultFloatingWorkspacePath() + const documents: MarkdownDocument[] = [] + for (const filePath of filePaths) { + try { + // Why: the shell can hand over a bundle directory named `*.md`, or a path already + // deleted by the time we resolve. Authorize only something that is really a file. + if (!(await stat(filePath)).isFile()) { + continue + } + } catch { + continue + } + authorizeExternalPath(filePath) + documents.push( + markdownDocumentFromFilePath(floatingRoot, filePath, { + outsideRootRelativePath: 'basename' + }) + ) + } + return documents +} diff --git a/src/main/startup/os-opened-markdown-wiring.test.ts b/src/main/startup/os-opened-markdown-wiring.test.ts new file mode 100644 index 00000000000..179ca0820ca --- /dev/null +++ b/src/main/startup/os-opened-markdown-wiring.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const read = (relativePath: string): string => + // Why source text: this wiring is module-scope side effects in the entry point, which no + // unit test can import without booting Electron. These guards pin the call shapes instead. + readFileSync(join(process.cwd(), relativePath), 'utf8').replaceAll('"', "'") + +describe('os-opened markdown wiring', () => { + const index = read('src/main/index.ts') + const bootstrap = read('src/main/startup/main-process-ipc-bootstrap.ts') + const controller = read('src/main/startup/main-window-controller.ts') + + it('captures argv before the serve-duplicate early return', () => { + const captureIndex = index.indexOf( + 'state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles)' + ) + const serveGuardIndex = index.indexOf('if (!shouldActivateDesktopForSecondInstance(argv)) {') + + expect(captureIndex).toBeGreaterThanOrEqual(0) + expect(serveGuardIndex).toBeGreaterThanOrEqual(0) + // A duplicate `orca serve` returns early; capturing after that would drop the user's files. + expect(captureIndex).toBeLessThan(serveGuardIndex) + }) + + it('claims the macOS open-file event so the default handler does not win it', () => { + const handlerIndex = index.indexOf("app.on('open-file'") + expect(handlerIndex).toBeGreaterThanOrEqual(0) + + const preventDefaultIndex = index.indexOf('event.preventDefault()', handlerIndex) + const nextRegistrationIndex = index.indexOf('app.on(', handlerIndex + 1) + expect(preventDefaultIndex).toBeGreaterThan(handlerIndex) + if (nextRegistrationIndex !== -1) { + expect(preventDefaultIndex).toBeLessThan(nextRegistrationIndex) + } + }) + + it('captures the cold-start argv and lets the renderer pull it after mount', () => { + expect(index).toContain('state.osOpenedMarkdownFiles.capture(process.argv)') + expect(bootstrap).toContain("ipcMain.handle('ui:consumePendingMarkdownFileOpens'") + }) + + // Why: `webContents.send` to a renderer that has not attached the listener is dropped with no + // error, so publishing on "a window exists" alone would consume the queue into a void. + it('only pushes once the renderer has proven its listener is attached', () => { + expect(index).toContain('!state.markdownFileOpenListenerReady') + expect(bootstrap).toContain('state.markdownFileOpenListenerReady = true') + // A reload drops the listener; the fresh renderer re-proves itself by pulling again. + expect(controller).toContain('state.markdownFileOpenListenerReady = false') + }) + + it('restores an undelivered batch on both the push and the pull path', () => { + expect(index).toContain('state.osOpenedMarkdownFiles.restore(filePaths)') + expect(bootstrap).toContain('state.osOpenedMarkdownFiles.restore(filePaths)') + }) +}) diff --git a/src/preload/api/ui-bridge-state-and-menu-commands.ts b/src/preload/api/ui-bridge-state-and-menu-commands.ts index 25476165cfb..34eb83886c8 100644 --- a/src/preload/api/ui-bridge-state-and-menu-commands.ts +++ b/src/preload/api/ui-bridge-state-and-menu-commands.ts @@ -1,3 +1,4 @@ +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import { ipcRenderer } from 'electron' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { KeybindingActionId } from '../../shared/keybindings' @@ -26,6 +27,14 @@ export const uiStateAndMenuCommandsApi = { }, consumePendingSkillShare: (): Promise => ipcRenderer.invoke('ui:consumePendingSkillShare'), + onOpenMarkdownFiles: (callback: (documents: MarkdownDocument[]) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, documents: MarkdownDocument[]): void => + callback(documents) + ipcRenderer.on('ui:openMarkdownFiles', listener) + return () => ipcRenderer.removeListener('ui:openMarkdownFiles', listener) + }, + consumePendingMarkdownFileOpens: (): Promise => + ipcRenderer.invoke('ui:consumePendingMarkdownFileOpens'), onOpenSetupGuide: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openSetupGuide', listener) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index e63034b5233..0876104e471 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -1,3 +1,4 @@ +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { TuiAgent } from '../../shared/tui-agent' import type { @@ -48,6 +49,10 @@ export type UiCommandEventApi = { consumePendingOpenSettings: () => Promise onOpenSkillShare: (callback: (shareId: string) => void) => () => void consumePendingSkillShare: () => Promise + /** OS "Open With" markdown paths pushed while a renderer is already listening. */ + onOpenMarkdownFiles: (callback: (documents: MarkdownDocument[]) => void) => () => void + /** Drains the "Open With" paths queued before this renderer's listener attached. */ + consumePendingMarkdownFileOpens: () => Promise onOpenSetupGuide: (callback: () => void) => () => void onOpenFeatureTour: (callback: () => void) => () => void onOpenCrashReport: (callback: () => void) => () => void diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts index a60cdc7d93e..8ef85f7e556 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts @@ -4,7 +4,7 @@ import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-v import { getConnectionId } from '@/lib/connection-context' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action-error' -import { detectLanguage } from '@/lib/language-detect' +import { openMarkdownDocumentInFloatingWorkspace } from '@/lib/open-markdown-in-floating-workspace' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { translate } from '@/i18n/i18n' @@ -123,21 +123,9 @@ export function useFloatingTerminalCreateActions({ if (!document) { return } - openFile( - { - filePath: document.filePath, - relativePath: document.relativePath, - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - language: detectLanguage(document.relativePath), - mode: 'edit', - runtimeEnvironmentId: null - }, - { - preview: false, - targetGroupId: activeGroup?.id, - suppressActiveRuntimeFallback: true - } - ) + openMarkdownDocumentInFloatingWorkspace(openFile, document, { + targetGroupId: activeGroup?.id + }) } catch (error) { toast.error(extractIpcErrorMessage(error, 'Failed to open markdown file.')) } diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index 7aa5dd418cf..d105db1d3e9 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -12,6 +12,7 @@ import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge' import { registerMobileDriverIpcBridge } from './mobile-driver-ipc-bridge' +import { registerOsMarkdownFileOpenBridge } from './os-markdown-file-open-bridge' import { registerProjectCatalogIpcBridge } from './project-catalog-ipc-bridge' import { registerRateLimitIpcBridge } from './rate-limit-ipc-bridge' import { registerRemoteWorkspaceIpcBridge } from './remote-workspace-ipc-bridge' @@ -77,6 +78,7 @@ export function installAppLifetimeIpcEvents( ) registerSettingsAndSidebarIpcBridge(unsubs) registerWorkspaceShortcutIpcBridge(unsubs) + registerOsMarkdownFileOpenBridge(unsubs) unsubs.push( window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup, defaultTabs }) => { void worktreeRuntime diff --git a/src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.test.ts b/src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.test.ts new file mode 100644 index 00000000000..d4825e89206 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.test.ts @@ -0,0 +1,300 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import type { EditorFilesSlice } from '@/store/slices/editor/types/editor-files-slice' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { registerOsMarkdownFileOpenBridge } from './os-markdown-file-open-bridge' + +const mocks = vi.hoisted(() => ({ + openFile: vi.fn(() => 'file-1'), + updateSettings: vi.fn(async () => {}), + isFloatingWorkspacePanelVisible: vi.fn(() => false), + toastError: vi.fn() +})) + +let storeState: { + openFile: typeof mocks.openFile + updateSettings: typeof mocks.updateSettings + settings: { floatingTerminalEnabled?: boolean } | undefined +} + +vi.mock('../../store', () => ({ useAppStore: { getState: () => storeState } })) +vi.mock('@/lib/floating-workspace-terminal-actions', () => ({ + isFloatingWorkspacePanelVisible: mocks.isFloatingWorkspacePanelVisible +})) +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +type MarkdownFileOpenListener = (documents: MarkdownDocument[]) => void + +let frames: FrameRequestCallback[] = [] +let dispatchEvent = vi.fn() +let unhandledRejections: unknown[] = [] +const recordUnhandledRejection = (reason: unknown): void => void unhandledRejections.push(reason) + +function markdownDocument(overrides: Partial = {}): MarkdownDocument { + return { + filePath: '/Users/me/notes/README.md', + relativePath: 'README.md', + basename: 'README.md', + name: 'README', + ...overrides + } +} + +function stubPreload(ui: Record): void { + dispatchEvent = vi.fn() + vi.stubGlobal('window', { api: { ui }, dispatchEvent }) +} + +/** Runs the callbacks the bridge deferred to the next frame. */ +function runFrames(): void { + const pending = frames + frames = [] + for (const frame of pending) { + frame(0) + } +} + +/** Drains microtasks and lets Node emit any unhandled rejection the bridge leaked. */ +async function settle(): Promise { + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) +} + +describe('registerOsMarkdownFileOpenBridge', () => { + beforeEach(() => { + vi.clearAllMocks() + frames = [] + unhandledRejections = [] + storeState = { + openFile: mocks.openFile, + updateSettings: mocks.updateSettings, + settings: { floatingTerminalEnabled: true } + } + mocks.openFile.mockReturnValue('file-1') + mocks.updateSettings.mockResolvedValue(undefined) + mocks.isFloatingWorkspacePanelVisible.mockReturnValue(false) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + frames.push(callback) + ) + vi.spyOn(console, 'error').mockImplementation(() => {}) + process.on('unhandledRejection', recordUnhandledRejection) + }) + + afterEach(() => { + process.off('unhandledRejection', recordUnhandledRejection) + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('opens every document main queued before the listener attached', async () => { + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => + Promise.resolve([ + markdownDocument(), + markdownDocument({ filePath: '/Users/me/notes/plan.md', relativePath: 'plan.md' }) + ]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + + expect(mocks.openFile).toHaveBeenCalledTimes(2) + expect(mocks.openFile.mock.calls.map((call) => call[0].filePath)).toEqual([ + '/Users/me/notes/README.md', + '/Users/me/notes/plan.md' + ]) + expect(mocks.openFile.mock.calls[0][0].worktreeId).toBe(FLOATING_TERMINAL_WORKTREE_ID) + }) + + it('opens documents pushed after startup and hands back the unsubscribe', async () => { + const listeners: MarkdownFileOpenListener[] = [] + const unsubscribe = vi.fn() + stubPreload({ + onOpenMarkdownFiles: (next: MarkdownFileOpenListener) => { + listeners.push(next) + return unsubscribe + }, + consumePendingMarkdownFileOpens: () => Promise.resolve([]) + }) + + const unsubs: (() => void)[] = [] + registerOsMarkdownFileOpenBridge(unsubs) + expect(unsubs).toEqual([unsubscribe]) + + listeners[0]([ + markdownDocument({ filePath: '/Users/me/notes/live.md', relativePath: 'live.md' }) + ]) + await settle() + + expect(mocks.openFile).toHaveBeenCalledTimes(1) + expect(mocks.openFile.mock.calls[0][0].filePath).toBe('/Users/me/notes/live.md') + + unsubs.forEach((teardown) => teardown()) + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + it('enables the floating workspace when the setting is off', async () => { + storeState.settings = { floatingTerminalEnabled: false } + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => Promise.resolve([markdownDocument()]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + + expect(mocks.updateSettings).toHaveBeenCalledWith({ floatingTerminalEnabled: true }) + }) + + it('leaves settings alone when the floating workspace is already enabled', async () => { + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => Promise.resolve([markdownDocument()]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('defers the reveal a frame and toggles only while the panel is hidden', async () => { + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => Promise.resolve([markdownDocument()]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + + expect(dispatchEvent).not.toHaveBeenCalled() + runFrames() + + expect(dispatchEvent).toHaveBeenCalledTimes(1) + expect(dispatchEvent.mock.calls[0][0].type).toBe(TOGGLE_FLOATING_TERMINAL_EVENT) + }) + + it('does not toggle when the panel is already visible', async () => { + mocks.isFloatingWorkspacePanelVisible.mockReturnValue(true) + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => Promise.resolve([markdownDocument()]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + runFrames() + + expect(dispatchEvent).not.toHaveBeenCalled() + }) + + it('ignores an empty batch', async () => { + storeState.settings = { floatingTerminalEnabled: false } + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => Promise.resolve([]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + runFrames() + + expect(mocks.openFile).not.toHaveBeenCalled() + expect(mocks.updateSettings).not.toHaveBeenCalled() + expect(dispatchEvent).not.toHaveBeenCalled() + }) + + it('reports a rejected pending drain without leaking an unhandled rejection', async () => { + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => Promise.reject(new Error('ipc unavailable')) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + + expect(mocks.toastError).toHaveBeenCalledWith('Failed to open the Markdown file.') + // Why: App.tsx awaits hydration around this registration and treats any throw as + // "session restore failed", so the bridge must swallow its own failures. + expect(unhandledRejections).toEqual([]) + }) + + it('reports a throwing openFile without leaking an unhandled rejection', async () => { + mocks.openFile.mockImplementation(() => { + throw new Error('editor slice exploded') + }) + const listeners: MarkdownFileOpenListener[] = [] + stubPreload({ + onOpenMarkdownFiles: (next: MarkdownFileOpenListener) => { + listeners.push(next) + return () => {} + }, + consumePendingMarkdownFileOpens: () => Promise.resolve([]) + }) + + registerOsMarkdownFileOpenBridge([]) + expect(() => listeners[0]([markdownDocument()])).not.toThrow() + await settle() + + expect(mocks.toastError).toHaveBeenCalledWith('Failed to open the Markdown file.') + expect(unhandledRejections).toEqual([]) + expect(dispatchEvent).not.toHaveBeenCalled() + }) + + it('keeps opening the rest of a batch when one document fails', async () => { + mocks.openFile.mockImplementationOnce(() => { + throw new Error('first document exploded') + }) + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + consumePendingMarkdownFileOpens: () => + Promise.resolve([ + markdownDocument({ filePath: '/Users/me/notes/bad.md', relativePath: 'bad.md' }), + markdownDocument({ filePath: '/Users/me/notes/good.md', relativePath: 'good.md' }) + ]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + runFrames() + + // Why: a multi-file selection arrives as one batch; one bad file must not cost the rest. + expect(mocks.openFile).toHaveBeenCalledTimes(2) + expect(mocks.toastError).toHaveBeenCalledTimes(1) + expect(dispatchEvent).toHaveBeenCalledTimes(1) + expect(unhandledRejections).toEqual([]) + }) + + it('ignores a non-array payload from a mismatched preload', async () => { + stubPreload({ + onOpenMarkdownFiles: () => () => {}, + // Why: the payload crosses the preload boundary, so a stale preload can resolve with + // something that is not an array. Reading .length off it would throw inside the chain. + consumePendingMarkdownFileOpens: () => Promise.resolve(null as unknown as MarkdownDocument[]) + }) + + registerOsMarkdownFileOpenBridge([]) + await settle() + runFrames() + + expect(mocks.openFile).not.toHaveBeenCalled() + expect(mocks.updateSettings).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + expect(unhandledRejections).toEqual([]) + }) + + it('tolerates a preload without the markdown open channel', async () => { + stubPreload({}) + + const unsubs: (() => void)[] = [] + expect(() => registerOsMarkdownFileOpenBridge(unsubs)).not.toThrow() + await settle() + + expect(unsubs).toEqual([]) + expect(mocks.openFile).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.ts b/src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.ts new file mode 100644 index 00000000000..3dd345da07c --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.ts @@ -0,0 +1,72 @@ +import { toast } from 'sonner' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' +import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { isFloatingWorkspacePanelVisible } from '@/lib/floating-workspace-terminal-actions' +import { openMarkdownDocumentInFloatingWorkspace } from '@/lib/open-markdown-in-floating-workspace' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' + +/** + * Opens markdown files the OS shell handed to Orca ("Open With" / double-click) in the + * floating workspace, which is the one editor surface that needs no project. + */ +async function openOsRequestedMarkdownFiles(documents: MarkdownDocument[]): Promise { + // Why the shape check: this payload crosses the preload boundary, so a stale or mismatched + // preload can hand back something that is not an array. Reading .length off that throws + // inside the promise chain rather than failing loudly at the boundary. + if (!Array.isArray(documents) || documents.length === 0) { + return + } + const store = useAppStore.getState() + let opened = 0 + for (const document of documents) { + // Why isolated: selecting several files hands us one batch, and one unopenable file + // must not cost the user the rest of the selection. + try { + openMarkdownDocumentInFloatingWorkspace(store.openFile, document) + opened += 1 + } catch (error) { + reportOsRequestedMarkdownFailure(error) + } + } + if (opened === 0) { + return + } + // Why enabled here: the user asked the OS for this file, and the tabs above are already in a + // surface a disabled floating workspace never renders. Same enable-then-reveal as the + // Settings "Edit keybindings in Orca" action. + if (store.settings?.floatingTerminalEnabled !== true) { + await store.updateSettings({ floatingTerminalEnabled: true }) + } + // Why deferred a frame: the panel only honors the toggle once the enabled flag has reached React. + requestAnimationFrame(() => { + if (!isFloatingWorkspacePanelVisible()) { + window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT)) + } + }) +} + +function reportOsRequestedMarkdownFailure(error: unknown): void { + console.error('Failed to open markdown files requested by the OS:', error) + toast.error( + translate( + 'auto.hooks.ipc.events.os.markdown.file.open.bridge.1e9a1a63c4', + 'Failed to open the Markdown file.' + ) + ) +} + +export function registerOsMarkdownFileOpenBridge(unsubs: (() => void)[]): void { + const unsubscribe = window.api.ui.onOpenMarkdownFiles?.((documents) => { + void openOsRequestedMarkdownFiles(documents).catch(reportOsRequestedMarkdownFailure) + }) + if (unsubscribe) { + unsubs.push(unsubscribe) + } + + // Why: a cold-start "Open With" resolves before this listener attaches; drain what main queued. + const pending = window.api.ui.consumePendingMarkdownFileOpens?.() + if (pending && typeof pending.then === 'function') { + void pending.then(openOsRequestedMarkdownFiles).catch(reportOsRequestedMarkdownFailure) + } +} diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 5155aa6881f..67872949590 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -55,6 +55,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'ui.onOpenDiffFromMobile', 'ui.onOpenFeatureTour', 'ui.onOpenFileFromMobile', + 'ui.onOpenMarkdownFiles', 'ui.onOpenNewWorkspace', 'ui.onOpenQuickOpen', 'ui.onOpenSettings', @@ -135,6 +136,7 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ui.onJumpToTabIndex', 'ui.onWorktreeHistoryNavigate', 'ui.onToggleStatusBar', + 'ui.onOpenMarkdownFiles', 'ui.onActivateWorktree', 'ui.onCreateTerminal', 'ui.onRequestTerminalTabMount', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c04b2f260e9..7fc9c837ebc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1112,6 +1112,17 @@ "events": { "browserStateIpcBridge": { "docPreviewLinkFailed": "Could not open this link in Orca Browser." + }, + "os": { + "markdown": { + "file": { + "open": { + "bridge": { + "1e9a1a63c4": "Failed to open the Markdown file." + } + } + } + } } } } diff --git a/src/renderer/src/lib/open-markdown-in-floating-workspace.test.ts b/src/renderer/src/lib/open-markdown-in-floating-workspace.test.ts new file mode 100644 index 00000000000..62fc278fc7a --- /dev/null +++ b/src/renderer/src/lib/open-markdown-in-floating-workspace.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import type { MarkdownDocument } from '../../../shared/filesystem-entry-types' +import type { EditorFilesSlice } from '@/store/slices/editor/types/editor-files-slice' +import { openMarkdownDocumentInFloatingWorkspace } from './open-markdown-in-floating-workspace' + +function openFileMock(): ReturnType> { + return vi.fn(() => 'file-1') +} + +function markdownDocument(overrides: Partial = {}): MarkdownDocument { + return { + filePath: '/Users/me/notes/README.md', + relativePath: 'README.md', + basename: 'README.md', + name: 'README', + ...overrides + } +} + +describe('openMarkdownDocumentInFloatingWorkspace', () => { + it('opens the document as a permanent floating-workspace edit tab', () => { + const openFile = openFileMock() + + const fileId = openMarkdownDocumentInFloatingWorkspace(openFile, markdownDocument()) + + expect(fileId).toBe('file-1') + expect(openFile).toHaveBeenCalledTimes(1) + expect(openFile.mock.calls[0][0]).toEqual({ + filePath: '/Users/me/notes/README.md', + relativePath: 'README.md', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + language: 'markdown', + mode: 'edit', + runtimeEnvironmentId: null + }) + expect(openFile.mock.calls[0][1]).toEqual({ + preview: false, + targetGroupId: undefined, + suppressActiveRuntimeFallback: true + }) + }) + + it('pins the open to this machine instead of the active runtime', () => { + const openFile = openFileMock() + + openMarkdownDocumentInFloatingWorkspace(openFile, markdownDocument()) + + // Why: the caller already resolved an absolute local path, so a null runtime plus the + // fallback suppression is what keeps the read off a remote SSH host the user is focused on. + // Dropping either one silently reads the file on the wrong machine. + expect(openFile.mock.calls[0][0].runtimeEnvironmentId).toBeNull() + expect(openFile.mock.calls[0][1]?.suppressActiveRuntimeFallback).toBe(true) + }) + + it('derives the language from the relative path', () => { + const openFile = openFileMock() + + openMarkdownDocumentInFloatingWorkspace( + openFile, + markdownDocument({ + filePath: '/Users/me/notes/plan.mdx', + relativePath: 'plan.mdx', + basename: 'plan.mdx', + name: 'plan' + }) + ) + + expect(openFile.mock.calls[0][0].language).toBe('markdown') + }) + + it('forwards a requested target group', () => { + const openFile = openFileMock() + + openMarkdownDocumentInFloatingWorkspace(openFile, markdownDocument(), { + targetGroupId: 'group-2' + }) + + expect(openFile.mock.calls[0][1]?.targetGroupId).toBe('group-2') + }) +}) diff --git a/src/renderer/src/lib/open-markdown-in-floating-workspace.ts b/src/renderer/src/lib/open-markdown-in-floating-workspace.ts new file mode 100644 index 00000000000..3b1bd4cd08b --- /dev/null +++ b/src/renderer/src/lib/open-markdown-in-floating-workspace.ts @@ -0,0 +1,32 @@ +import type { MarkdownDocument } from '../../../shared/filesystem-entry-types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import type { EditorFilesSlice } from '@/store/slices/editor/types/editor-files-slice' +import { detectLanguage } from './language-detect' + +/** + * Opens a markdown file that belongs to no workspace as a floating-workspace editor tab. + * + * Why local-only: every caller resolves an absolute path on this machine (a native picker or + * the OS shell), so routing it through the active runtime would read it on the wrong host. + */ +export function openMarkdownDocumentInFloatingWorkspace( + openFile: EditorFilesSlice['openFile'], + document: MarkdownDocument, + options: { targetGroupId?: string } = {} +): string { + return openFile( + { + filePath: document.filePath, + relativePath: document.relativePath, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + language: detectLanguage(document.relativePath), + mode: 'edit', + runtimeEnvironmentId: null + }, + { + preview: false, + targetGroupId: options.targetGroupId, + suppressActiveRuntimeFallback: true + } + ) +} diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index 8c6e4d73a95..ca67f5664a9 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -157,6 +157,9 @@ export function createWebUiApi(): NonNullable['ui']> { consumePendingOpenSettings: () => Promise.resolve(false), onOpenSkillShare: () => noopUnsubscribe, consumePendingSkillShare: () => Promise.resolve(null), + // Why: the web client has no OS shell handing it files, so there is never a queued open. + onOpenMarkdownFiles: () => noopUnsubscribe, + consumePendingMarkdownFileOpens: () => Promise.resolve([]), onOpenSetupGuide: () => noopUnsubscribe, onOpenFeatureTour: () => noopUnsubscribe, onOpenCrashReport: () => noopUnsubscribe, From 894c5fe36a7755325407ccc745ce450fbf7c737a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:06:16 -0700 Subject: [PATCH 30/94] test(orchestration): fail loudly on an unexpected second detection call The mock overwrote resolveDetection on every call, so a second invocation would strand the first promise and hang to a 30s timeout instead of naming what changed. A test that hangs rather than fails is how a real bug gets mistaken for infrastructure noise. --- .../orchestration-legacy-coordinator-race.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts index 9236d643e40..3040c37a9ef 100644 --- a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts @@ -656,9 +656,21 @@ describe('legacy coordinator takeover races', () => { const detectionStarted = new Promise((resolve) => { signalDetectionStarted = resolve }) + let detectionCalls = 0 vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockImplementation( () => - new Promise((resolve) => { + new Promise((resolve, reject) => { + detectionCalls += 1 + // Why reject instead of re-arming: a second call would overwrite resolveDetection and + // strand the first promise, hanging to a timeout instead of naming what changed. + if (detectionCalls > 1) { + reject( + new Error( + `isTerminalRunningAgent was called ${detectionCalls} times; this test drives exactly one detection.` + ) + ) + return + } resolveDetection = resolve signalDetectionStarted?.() }) From 401664298faf07f1e704d60f9ba689d7a962a2f8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:11:12 -0700 Subject: [PATCH 31/94] fix(preload): make a dropped bridge key a compile error The split silently dropped jira.searchUsers and runtimeEnvironments.retryControlConnection. Neither failed typecheck: the bridge modules carried no satisfies annotation and the composed api object was unannotated, so a missing key was only a runtime TypeError in the renderer. Annotates each module against PreloadApi, the type window.api is already declared as, so the contract supplies the shape rather than a parallel copy. Deleting jira.searchUsers now fails with TS2741 naming the key. Turning this on surfaced 106 places where a bridge locally annotated Promise or unknown[] over a contract that declares concrete types -- the bridge was erasing types the renderer relied on. Those annotations are gone. Also exposes app.awaitBeforeUnloadCheckpoint, which was declared and called but never actually on the bridge, so the lazy-chunk recovery reload optional-chained to a no-op and navigated without joining the checkpoint. The missing key was caught by the new annotation rather than by hand. --- src/preload/api/agent-status-bridge.ts | 3 +- src/preload/api/agent-trust-bridge.ts | 3 +- src/preload/api/ai-vault-bridge.ts | 14 ++-- src/preload/api/app-bridge.ts | 3 +- src/preload/api/automations-bridge.ts | 4 +- src/preload/api/bitbucket-bridge.ts | 5 +- ...bridge-guest-registration-and-downloads.ts | 3 +- ...er-bridge-page-interaction-and-sessions.ts | 45 ++++-------- src/preload/api/browser-bridge.ts | 3 +- src/preload/api/claude-accounts-bridge.ts | 14 ++-- src/preload/api/claude-usage-bridge.ts | 6 +- src/preload/api/cli-bridge.ts | 3 +- src/preload/api/codex-accounts-bridge.ts | 18 +++-- src/preload/api/codex-config-sync-bridge.ts | 3 +- src/preload/api/codex-usage-bridge.ts | 6 +- .../api/computer-use-permissions-bridge.ts | 9 +-- src/preload/api/crash-reports-bridge.ts | 3 +- src/preload/api/dashboard-bridge.ts | 3 +- .../api/developer-permissions-bridge.ts | 10 +-- src/preload/api/diagnostics-bridge.ts | 9 +-- src/preload/api/doc-preview-bridge.ts | 3 +- src/preload/api/e2e-bridge.ts | 3 +- src/preload/api/emulator-bridge.ts | 3 +- src/preload/api/export-bridge.ts | 3 +- src/preload/api/feedback-bridge.ts | 3 +- src/preload/api/fs-bridge.ts | 3 +- .../api/gh-bridge-mutations-and-projects.ts | 25 +++---- .../gh-bridge-pull-requests-and-work-items.ts | 71 ++++++++++--------- src/preload/api/gh-bridge.ts | 6 +- src/preload/api/git-bash-bridge.ts | 3 +- src/preload/api/git-bridge.ts | 35 ++++----- src/preload/api/gl-bridge.ts | 3 +- src/preload/api/grok-accounts-bridge.ts | 3 +- src/preload/api/hooks-bridge.ts | 20 ++---- src/preload/api/hosted-review-bridge.ts | 12 ++-- src/preload/api/jira-bridge.ts | 66 ++++++----------- src/preload/api/keybindings-bridge.ts | 3 +- src/preload/api/linear-bridge.ts | 58 ++++++--------- src/preload/api/macos-tcc-prompts-bridge.ts | 11 +-- src/preload/api/memory-bridge.ts | 3 +- src/preload/api/minimax-credentials-bridge.ts | 3 +- src/preload/api/mobile-bridge.ts | 3 +- src/preload/api/native-chat-bridge.ts | 5 +- src/preload/api/notebook-bridge.ts | 3 +- src/preload/api/notifications-bridge.ts | 3 +- src/preload/api/onboarding-bridge.ts | 3 +- src/preload/api/open-code-usage-bridge.ts | 6 +- src/preload/api/pet-bridge.ts | 3 +- src/preload/api/plugins-bridge.ts | 7 +- src/preload/api/preflight-bridge.ts | 4 +- src/preload/api/pty-bridge-session-control.ts | 3 +- .../pty-bridge-stream-and-serialization.ts | 3 +- src/preload/api/pty-bridge.ts | 6 +- src/preload/api/pwsh-bridge.ts | 3 +- src/preload/api/rate-limits-bridge.ts | 3 +- src/preload/api/runtime-bridge.ts | 3 +- .../api/runtime-environments-bridge.ts | 3 +- src/preload/api/settings-bridge.ts | 16 ++--- src/preload/api/shell-bridge.ts | 3 +- src/preload/api/skills-bridge.ts | 3 +- src/preload/api/speech-bridge.ts | 3 +- src/preload/api/ssh-bridge.ts | 3 +- src/preload/api/star-nag-bridge.ts | 3 +- src/preload/api/stats-bridge.ts | 3 +- src/preload/api/terminal-preview-bridge.ts | 3 +- ...ui-bridge-clipboard-and-window-controls.ts | 3 +- .../api/ui-bridge-state-and-menu-commands.ts | 3 +- .../api/ui-bridge-tab-and-browser-commands.ts | 3 +- .../ui-bridge-terminal-and-session-tabs.ts | 3 +- src/preload/api/wsl-bridge.ts | 3 +- .../app-restart-checkpoint-routing.test.ts | 14 ++++ src/preload/gitlab.ts | 52 ++++++-------- src/preload/index.ts | 3 +- 73 files changed, 350 insertions(+), 339 deletions(-) diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts index 5bc0757c5ca..b5ac5c8b5b2 100644 --- a/src/preload/api/agent-status-bridge.ts +++ b/src/preload/api/agent-status-bridge.ts @@ -6,6 +6,7 @@ import type { } from '../../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent' +import type { PreloadApi } from '../api-types' export const agentStatusApi = { /** Listen for agent status updates forwarded from native hook receivers. */ @@ -85,4 +86,4 @@ export const agentStatusApi = { }): void => { ipcRenderer.send('agentStatus:transferPaneAuthority', args) } -} +} satisfies PreloadApi['agentStatus'] diff --git a/src/preload/api/agent-trust-bridge.ts b/src/preload/api/agent-trust-bridge.ts index f27146d9cd3..5aca3fd805c 100644 --- a/src/preload/api/agent-trust-bridge.ts +++ b/src/preload/api/agent-trust-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const agentTrustApi = { markTrusted: (args: { @@ -6,4 +7,4 @@ export const agentTrustApi = { workspacePath: string connectionId?: string }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) -} +} satisfies PreloadApi['agentTrust'] diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts index ea9b2e1be14..917c9f02b63 100644 --- a/src/preload/api/ai-vault-bridge.ts +++ b/src/preload/api/ai-vault-bridge.ts @@ -10,19 +10,19 @@ import type { } from '../../shared/ai-vault-types' import type { AiVaultSessionTitlesArgs } from '../../shared/ai-vault-session-title' import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' +import type { PreloadApi } from '../api-types' export const aiVaultApi = { - listSessions: (args?: AiVaultListArgs): Promise => - ipcRenderer.invoke('aiVault:listSessions', args), - resolveSessionTitles: (args: AiVaultSessionTitlesArgs): Promise => + listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args), + resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => ipcRenderer.invoke('aiVault:resolveSessionTitles', args), cancelListSessions: (args: { requestToken: string }): Promise => ipcRenderer.invoke('aiVault:cancelListSessions', args), - prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise => + prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) => ipcRenderer.invoke('aiVault:prepareSessionResume', args), - listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => + listSubagentSessions: (args: AiVaultSubagentListArgs) => ipcRenderer.invoke('aiVault:listSubagentSessions', args), - getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => + getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), deleteSession: (args: AiVaultDeleteSessionArgs): Promise => ipcRenderer.invoke('aiVault:deleteSession', args), @@ -31,4 +31,4 @@ export const aiVaultApi = { ipcRenderer.on('aiVault:windowFocused', listener) return () => ipcRenderer.removeListener('aiVault:windowFocused', listener) } -} +} satisfies PreloadApi['aiVault'] diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts index 705d24e4bda..22d46cc40d2 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -40,6 +40,7 @@ export const appApi = { throw new Error('Failed to stage renderer state before unload.') } }, + awaitBeforeUnloadCheckpoint: () => awaitBeforeUnloadCheckpoint(), awaitFirstWindowStartupServices: (): Promise => ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), prepareTerminalStartupRestoration: (): Promise => @@ -73,4 +74,4 @@ export const appApi = { ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory'), writeTerminalRenderDesyncEvidence: (args: WriteTerminalRenderDesyncEvidenceArgs) => ipcRenderer.invoke('terminal:writeRenderDesyncEvidence', args) -} +} satisfies PreloadApi['app'] diff --git a/src/preload/api/automations-bridge.ts b/src/preload/api/automations-bridge.ts index 87bec12a8e9..43c3df528d8 100644 --- a/src/preload/api/automations-bridge.ts +++ b/src/preload/api/automations-bridge.ts @@ -1,5 +1,5 @@ import { ipcRenderer } from 'electron' -import type { ExternalAutomationManagerResult } from '../api-types' +import type { ExternalAutomationManagerResult, PreloadApi } from '../api-types' import type { AutomationDispatchRequest, AutomationDispatchResult, @@ -56,4 +56,4 @@ export const automationsApi = { ipcRenderer.on('automations:changed', listener) return () => ipcRenderer.removeListener('automations:changed', listener) } -} +} satisfies PreloadApi['automations'] diff --git a/src/preload/api/bitbucket-bridge.ts b/src/preload/api/bitbucket-bridge.ts index cf51ce453df..dd683b1387b 100644 --- a/src/preload/api/bitbucket-bridge.ts +++ b/src/preload/api/bitbucket-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const bitbucketApi = { connect: (args: { @@ -12,5 +13,5 @@ export const bitbucketApi = { disconnect: (): Promise => ipcRenderer.invoke('bitbucket:disconnect'), - status: (): Promise => ipcRenderer.invoke('bitbucket:status') -} + status: () => ipcRenderer.invoke('bitbucket:status') +} satisfies PreloadApi['bitbucket'] diff --git a/src/preload/api/browser-bridge-guest-registration-and-downloads.ts b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts index 3714a3bca7c..9d782971d96 100644 --- a/src/preload/api/browser-bridge-guest-registration-and-downloads.ts +++ b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts @@ -6,6 +6,7 @@ import type { } from '../../shared/browser-webauthn-account' import { readBrowserClientHostIdArgument } from '../../shared/browser-client-host-id-argument' import { browserClientPageRendererRequests } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' export const browserGuestRegistrationAndDownloadsApi = { onClientPageRendererRequest: browserClientPageRendererRequests.subscribe, @@ -194,4 +195,4 @@ export const browserGuestRegistrationAndDownloadsApi = { ipcRenderer.on('browser:download-finished', listener) return () => ipcRenderer.removeListener('browser:download-finished', listener) } -} +} satisfies Partial diff --git a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts index 0e959e0af4f..93d6001e61c 100644 --- a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const browserPageInteractionAndSessionsApi = { onContextMenuRequested: ( @@ -81,23 +82,17 @@ export const browserPageInteractionAndSessionsApi = { }, cancelDownload: (args: { downloadId: string }): Promise => ipcRenderer.invoke('browser:cancelDownload', args), - setGrabMode: (args: { - browserPageId: string - enabled: boolean - }): Promise<{ ok: true } | { ok: false; reason: string }> => + setGrabMode: (args: { browserPageId: string; enabled: boolean }) => ipcRenderer.invoke('browser:setGrabMode', args), - awaitGrabSelection: (args: { browserPageId: string; opId: string }): Promise => + awaitGrabSelection: (args: { browserPageId: string; opId: string }) => ipcRenderer.invoke('browser:awaitGrabSelection', args), cancelGrab: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:cancelGrab', args), captureSelectionScreenshot: (args: { browserPageId: string rect: { x: number; y: number; width: number; height: number } - }): Promise<{ ok: true; screenshot: unknown } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:captureSelectionScreenshot', args), - extractHoverPayload: (args: { - browserPageId: string - }): Promise<{ ok: true; payload: unknown } | { ok: false; reason: string }> => + }) => ipcRenderer.invoke('browser:captureSelectionScreenshot', args), + extractHoverPayload: (args: { browserPageId: string }) => ipcRenderer.invoke('browser:extractHoverPayload', args), onGrabModeToggle: (callback: (browserPageId: string) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, browserPageId: string) => @@ -115,7 +110,7 @@ export const browserPageInteractionAndSessionsApi = { ipcRenderer.on('browser:grabActionShortcut', listener) return () => ipcRenderer.removeListener('browser:grabActionShortcut', listener) }, - sessionListProfiles: (): Promise => ipcRenderer.invoke('browser:session:listProfiles'), + sessionListProfiles: () => ipcRenderer.invoke('browser:session:listProfiles'), prepareSshWorkspacePartition: (args: { targetId: string browserProfileId?: string @@ -126,40 +121,28 @@ export const browserPageInteractionAndSessionsApi = { scope: 'default' | 'isolated' | 'imported' label: string userAgentMode?: 'clean' | 'native' - }): Promise => ipcRenderer.invoke('browser:session:createProfile', args), + }) => ipcRenderer.invoke('browser:session:createProfile', args), sessionDeleteProfile: (args: { profileId: string }): Promise => ipcRenderer.invoke('browser:session:deleteProfile', args), - sessionImportCookies: (args: { - profileId: string - }): Promise<{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string }> => + sessionImportCookies: (args: { profileId: string }) => ipcRenderer.invoke('browser:session:importCookies', args), sessionResolvePartition: (args: { profileId: string | null }): Promise => ipcRenderer.invoke('browser:session:resolvePartition', args), - sessionDetectBrowsers: (): Promise => - ipcRenderer.invoke('browser:session:detectBrowsers'), - sessionDetectBrowsersForClientHost: (args: { - environmentId: string - }): Promise => + sessionDetectBrowsers: () => ipcRenderer.invoke('browser:session:detectBrowsers'), + sessionDetectBrowsersForClientHost: (args: { environmentId: string }) => ipcRenderer.invoke('browser:session:detectBrowsersForClientHost', args), - sessionImportFromBrowser: (args: { - profileId: string - browserFamily: string - }): Promise<{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string }> => + sessionImportFromBrowser: (args: { profileId: string; browserFamily: string }) => ipcRenderer.invoke('browser:session:importFromBrowser', args), sessionImportFromBrowserForClientHost: (args: { environmentId: string profileId: string browserFamily: string browserProfile?: string - }): Promise< - { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } | null - > => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args), - sessionClientRouteImportSources: (args: { - environmentId: string - }): Promise> => + }) => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args), + sessionClientRouteImportSources: (args: { environmentId: string }) => ipcRenderer.invoke('browser:session:clientRouteImportSources', args), sessionClearDefaultCookies: (): Promise => ipcRenderer.invoke('browser:session:clearDefaultCookies'), notifyActiveTabChanged: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:activeTabChanged', args) -} +} satisfies Partial diff --git a/src/preload/api/browser-bridge.ts b/src/preload/api/browser-bridge.ts index dca222c5843..d29b7365dc2 100644 --- a/src/preload/api/browser-bridge.ts +++ b/src/preload/api/browser-bridge.ts @@ -1,7 +1,8 @@ import { browserGuestRegistrationAndDownloadsApi } from './browser-bridge-guest-registration-and-downloads' import { browserPageInteractionAndSessionsApi } from './browser-bridge-page-interaction-and-sessions' +import type { PreloadApi } from '../api-types' export const browserApi = { ...browserGuestRegistrationAndDownloadsApi, ...browserPageInteractionAndSessionsApi -} +} satisfies PreloadApi['browser'] diff --git a/src/preload/api/claude-accounts-bridge.ts b/src/preload/api/claude-accounts-bridge.ts index 8200c17791d..69586525962 100644 --- a/src/preload/api/claude-accounts-bridge.ts +++ b/src/preload/api/claude-accounts-bridge.ts @@ -1,18 +1,18 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const claudeAccountsApi = { - list: (): Promise => ipcRenderer.invoke('claudeAccounts:list'), - add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + list: () => ipcRenderer.invoke('claudeAccounts:list'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }) => ipcRenderer.invoke('claudeAccounts:add', args), cancelPendingLogin: (): Promise => ipcRenderer.invoke('claudeAccounts:cancelPendingLogin'), - reauthenticate: (args: { accountId: string }): Promise => + reauthenticate: (args: { accountId: string }) => ipcRenderer.invoke('claudeAccounts:reauthenticate', args), - remove: (args: { accountId: string }): Promise => - ipcRenderer.invoke('claudeAccounts:remove', args), + remove: (args: { accountId: string }) => ipcRenderer.invoke('claudeAccounts:remove', args), select: (args: { accountId: string | null runtime?: 'host' | 'wsl' wslDistro?: string | null - }): Promise => ipcRenderer.invoke('claudeAccounts:select', args) -} + }) => ipcRenderer.invoke('claudeAccounts:select', args) +} satisfies PreloadApi['claudeAccounts'] diff --git a/src/preload/api/claude-usage-bridge.ts b/src/preload/api/claude-usage-bridge.ts index 0c81e35e3e8..1b98202d88c 100644 --- a/src/preload/api/claude-usage-bridge.ts +++ b/src/preload/api/claude-usage-bridge.ts @@ -1,4 +1,8 @@ import { ipcRenderer } from 'electron' import { createUsageProviderApi } from '../usage-provider-api' +import type { PreloadApi } from '../api-types' -export const claudeUsageApi = createUsageProviderApi(ipcRenderer, 'claudeUsage') +export const claudeUsageApi = createUsageProviderApi( + ipcRenderer, + 'claudeUsage' +) satisfies PreloadApi['claudeUsage'] diff --git a/src/preload/api/cli-bridge.ts b/src/preload/api/cli-bridge.ts index 8f811cbdd40..76b574a2f44 100644 --- a/src/preload/api/cli-bridge.ts +++ b/src/preload/api/cli-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { CliInstallStatus } from '../../shared/cli-install-types' +import type { PreloadApi } from '../api-types' export const cliApi = { getInstallStatus: (): Promise => ipcRenderer.invoke('cli:getInstallStatus'), @@ -11,4 +12,4 @@ export const cliApi = { ipcRenderer.invoke('cli:installWsl', args), removeWsl: (args?: { distro?: string | null }): Promise => ipcRenderer.invoke('cli:removeWsl', args) -} +} satisfies PreloadApi['cli'] diff --git a/src/preload/api/codex-accounts-bridge.ts b/src/preload/api/codex-accounts-bridge.ts index ecd32e4b923..d085de45855 100644 --- a/src/preload/api/codex-accounts-bridge.ts +++ b/src/preload/api/codex-accounts-bridge.ts @@ -1,20 +1,18 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const codexAccountsApi = { - list: (): Promise => ipcRenderer.invoke('codexAccounts:list'), - add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + list: () => ipcRenderer.invoke('codexAccounts:list'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }) => ipcRenderer.invoke('codexAccounts:add', args), - reauthenticate: (args: { - accountId: string - activateIfSelectionWasEmpty?: boolean - }): Promise => ipcRenderer.invoke('codexAccounts:reauthenticate', args), - remove: (args: { accountId: string }): Promise => - ipcRenderer.invoke('codexAccounts:remove', args), + reauthenticate: (args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) => + ipcRenderer.invoke('codexAccounts:reauthenticate', args), + remove: (args: { accountId: string }) => ipcRenderer.invoke('codexAccounts:remove', args), select: (args: { accountId: string | null runtime?: 'host' | 'wsl' wslDistro?: string | null - }): Promise => ipcRenderer.invoke('codexAccounts:select', args), + }) => ipcRenderer.invoke('codexAccounts:select', args), listStalePanes: (args: { ptyIds: string[] }): Promise< @@ -29,4 +27,4 @@ export const codexAccountsApi = { ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), forgetStalePanes: (args: { ptyIds: string[] }): Promise => ipcRenderer.invoke('codexAccounts:forgetStalePanes', args) -} +} satisfies PreloadApi['codexAccounts'] diff --git a/src/preload/api/codex-config-sync-bridge.ts b/src/preload/api/codex-config-sync-bridge.ts index e6c8903a698..82eedfaf0ec 100644 --- a/src/preload/api/codex-config-sync-bridge.ts +++ b/src/preload/api/codex-config-sync-bridge.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' +import type { PreloadApi } from '../api-types' export const codexConfigSyncApi = { status: (): Promise => ipcRenderer.invoke('codexConfigSync:status') -} +} satisfies PreloadApi['codexConfigSync'] diff --git a/src/preload/api/codex-usage-bridge.ts b/src/preload/api/codex-usage-bridge.ts index 9dba4b72f82..2575f2bb0e9 100644 --- a/src/preload/api/codex-usage-bridge.ts +++ b/src/preload/api/codex-usage-bridge.ts @@ -1,4 +1,8 @@ import { ipcRenderer } from 'electron' import { createUsageProviderApi } from '../usage-provider-api' +import type { PreloadApi } from '../api-types' -export const codexUsageApi = createUsageProviderApi(ipcRenderer, 'codexUsage') +export const codexUsageApi = createUsageProviderApi( + ipcRenderer, + 'codexUsage' +) satisfies PreloadApi['codexUsage'] diff --git a/src/preload/api/computer-use-permissions-bridge.ts b/src/preload/api/computer-use-permissions-bridge.ts index be441a8682e..bd36efbdcc3 100644 --- a/src/preload/api/computer-use-permissions-bridge.ts +++ b/src/preload/api/computer-use-permissions-bridge.ts @@ -1,8 +1,9 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const computerUsePermissionsApi = { - getStatus: (): Promise => ipcRenderer.invoke('computerUsePermissions:getStatus'), - openSetup: (args?: { id?: string }): Promise => + getStatus: () => ipcRenderer.invoke('computerUsePermissions:getStatus'), + openSetup: (args?: { id?: string }) => ipcRenderer.invoke('computerUsePermissions:openSetup', args), - reset: (): Promise => ipcRenderer.invoke('computerUsePermissions:reset') -} + reset: () => ipcRenderer.invoke('computerUsePermissions:reset') +} satisfies PreloadApi['computerUsePermissions'] diff --git a/src/preload/api/crash-reports-bridge.ts b/src/preload/api/crash-reports-bridge.ts index 19d7044601d..a77ad412eb7 100644 --- a/src/preload/api/crash-reports-bridge.ts +++ b/src/preload/api/crash-reports-bridge.ts @@ -11,6 +11,7 @@ import type { RendererHeapStatistics } from '../../shared/renderer-heap-statisti import type { RendererProcessMemory } from '../../shared/renderer-process-memory' import { readRendererHeapStatistics } from '../renderer-heap-statistics-reader' import { readRendererProcessMemory } from '../renderer-process-memory-reader' +import type { PreloadApi } from '../api-types' export const crashReportsApi = { getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'), @@ -28,4 +29,4 @@ export const crashReportsApi = { ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics(), readProcessMemory: (): Promise => readRendererProcessMemory() -} +} satisfies PreloadApi['crashReports'] diff --git a/src/preload/api/dashboard-bridge.ts b/src/preload/api/dashboard-bridge.ts index 17241630857..e6862504da9 100644 --- a/src/preload/api/dashboard-bridge.ts +++ b/src/preload/api/dashboard-bridge.ts @@ -5,6 +5,7 @@ import type { DashboardSnapshot, DashboardSpawnAgentArgs } from '../../shared/dashboard-snapshot' +import type { PreloadApi } from '../api-types' export const dashboardApi = { // Open the pop-out dashboard window, or focus it if already open. @@ -71,4 +72,4 @@ export const dashboardApi = { ipcRenderer.invoke('dashboardPopout:spawnAgent', args), sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise => ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args) -} +} satisfies PreloadApi['dashboard'] diff --git a/src/preload/api/developer-permissions-bridge.ts b/src/preload/api/developer-permissions-bridge.ts index 1aaa51deed3..94158cd7818 100644 --- a/src/preload/api/developer-permissions-bridge.ts +++ b/src/preload/api/developer-permissions-bridge.ts @@ -1,11 +1,11 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const developerPermissionsApi = { - getStatus: (): Promise => ipcRenderer.invoke('developerPermissions:getStatus'), - request: (args: { id: string }): Promise => - ipcRenderer.invoke('developerPermissions:request', args), + getStatus: () => ipcRenderer.invoke('developerPermissions:getStatus'), + request: (args: { id: string }) => ipcRenderer.invoke('developerPermissions:request', args), openSettings: (args: { id: string }): Promise => ipcRenderer.invoke('developerPermissions:openSettings', args), - testLocalNetworkConnection: (args: { host: string; port: number }): Promise => + testLocalNetworkConnection: (args: { host: string; port: number }) => ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args) -} +} satisfies PreloadApi['developerPermissions'] diff --git a/src/preload/api/diagnostics-bridge.ts b/src/preload/api/diagnostics-bridge.ts index 6d274f9b08a..bff79fe5817 100644 --- a/src/preload/api/diagnostics-bridge.ts +++ b/src/preload/api/diagnostics-bridge.ts @@ -1,15 +1,16 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const diagnosticsApi = { - getStatus: (): Promise => ipcRenderer.invoke('diagnostics:getStatus'), - collectBundle: (lookbackMinutes?: number): Promise => + getStatus: () => ipcRenderer.invoke('diagnostics:getStatus'), + collectBundle: (lookbackMinutes?: number) => ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes), openBundlePreview: (bundleSubmissionId: string): Promise => ipcRenderer.invoke('diagnostics:openBundlePreview', bundleSubmissionId), discardBundlePreview: (bundleSubmissionId: string): Promise => ipcRenderer.invoke('diagnostics:discardBundlePreview', bundleSubmissionId), - uploadBundle: (bundleSubmissionId: string): Promise => + uploadBundle: (bundleSubmissionId: string) => ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId), deleteBundle: (ticketId: string): Promise => ipcRenderer.invoke('diagnostics:deleteBundle', ticketId) -} +} satisfies PreloadApi['diagnostics'] diff --git a/src/preload/api/doc-preview-bridge.ts b/src/preload/api/doc-preview-bridge.ts index 68a099d5ed2..97fbb8da975 100644 --- a/src/preload/api/doc-preview-bridge.ts +++ b/src/preload/api/doc-preview-bridge.ts @@ -8,6 +8,7 @@ import { type DocPreviewFailure } from '../../shared/doc-preview-scheme' import type { DocPreviewGrantRequest } from '../api/doc-preview-api' +import type { PreloadApi } from '../api-types' export const docPreviewApi = { mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> => @@ -28,4 +29,4 @@ export const docPreviewApi = { ipcRenderer.on(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) return () => ipcRenderer.removeListener(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) } -} +} satisfies PreloadApi['docPreview'] diff --git a/src/preload/api/e2e-bridge.ts b/src/preload/api/e2e-bridge.ts index 2876b72a265..900b17a9fcf 100644 --- a/src/preload/api/e2e-bridge.ts +++ b/src/preload/api/e2e-bridge.ts @@ -1,5 +1,6 @@ import { preloadE2EConfig } from '../e2e-config' +import type { PreloadApi } from '../api-types' export const e2eApi = { getConfig: () => preloadE2EConfig -} +} satisfies PreloadApi['e2e'] diff --git a/src/preload/api/emulator-bridge.ts b/src/preload/api/emulator-bridge.ts index f13e99fc52a..8ab56471a42 100644 --- a/src/preload/api/emulator-bridge.ts +++ b/src/preload/api/emulator-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const emulatorApi = { startFrameStream: (args: { @@ -95,4 +96,4 @@ export const emulatorApi = { ipcRenderer.on('ui:emulatorAutoAttach', listener) return () => ipcRenderer.removeListener('ui:emulatorAutoAttach', listener) } -} +} satisfies PreloadApi['emulator'] diff --git a/src/preload/api/export-bridge.ts b/src/preload/api/export-bridge.ts index 637d4bea2ef..67ffbfae109 100644 --- a/src/preload/api/export-bridge.ts +++ b/src/preload/api/export-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const exportApi = { htmlToPdf: (args: { @@ -7,4 +8,4 @@ export const exportApi = { }): Promise< { success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string } > => ipcRenderer.invoke('export:html-to-pdf', args) -} +} satisfies PreloadApi['export'] diff --git a/src/preload/api/feedback-bridge.ts b/src/preload/api/feedback-bridge.ts index 55b3b5fcaa7..4241c5cacf9 100644 --- a/src/preload/api/feedback-bridge.ts +++ b/src/preload/api/feedback-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const feedbackApi = { submit: (args: { @@ -10,4 +11,4 @@ export const feedbackApi = { }): Promise< { ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string } > => ipcRenderer.invoke('feedback:submit', args) -} +} satisfies PreloadApi['feedback'] diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 538480ce2f5..c67e9abd9e3 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -8,6 +8,7 @@ import type { LocalLogTailReadResult, LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' +import type { PreloadApi } from '../api-types' export const fsApi = { readDir: (args: { @@ -216,4 +217,4 @@ export const fsApi = { ipcRenderer.on('fs:changed', listener) return () => ipcRenderer.removeListener('fs:changed', listener) } -} +} satisfies PreloadApi['fs'] diff --git a/src/preload/api/gh-bridge-mutations-and-projects.ts b/src/preload/api/gh-bridge-mutations-and-projects.ts index 3103cb432ec..80b746bfb79 100644 --- a/src/preload/api/gh-bridge-mutations-and-projects.ts +++ b/src/preload/api/gh-bridge-mutations-and-projects.ts @@ -35,11 +35,12 @@ import type { UpdateProjectItemFieldArgs } from '../../shared/github/project-request-types' import type { AppStarSource } from '../../shared/gh-star-source' +import type { PreloadApi } from '../api-types' export const ghMutationsAndProjectsApi = { setPRAutoMerge: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number enabled: boolean @@ -49,7 +50,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:setPRAutoMerge', args), updatePRState: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number updates: { state: 'open' | 'closed' } @@ -58,7 +59,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:updatePRState', args), markPRReadyForReview: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null @@ -66,7 +67,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:markPRReadyForReview', args), requestPRReviewers: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] @@ -75,7 +76,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:requestPRReviewers', args), removePRReviewers: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] @@ -84,7 +85,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:removePRReviewers', args), updateIssue: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number updates: unknown @@ -92,7 +93,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:updateIssue', args), addIssueComment: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number body: string @@ -101,7 +102,7 @@ export const ghMutationsAndProjectsApi = { }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), addPRReviewCommentReply: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number commentId: number @@ -113,7 +114,7 @@ export const ghMutationsAndProjectsApi = { }): Promise => ipcRenderer.invoke('gh:addPRReviewCommentReply', args), addPRReviewComment: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null @@ -125,12 +126,12 @@ export const ghMutationsAndProjectsApi = { }): Promise => ipcRenderer.invoke('gh:addPRReviewComment', args), listLabels: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null }): Promise => ipcRenderer.invoke('gh:listLabels', args), listAssignableUsers: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null }): Promise => ipcRenderer.invoke('gh:listAssignableUsers', args), onWorkItemMutated: ( @@ -199,4 +200,4 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:listIssueTypesBySlug', args), updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs): Promise => ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) -} +} satisfies Partial diff --git a/src/preload/api/gh-bridge-pull-requests-and-work-items.ts b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts index a4f3e1d45ef..3e4a5f6ce2a 100644 --- a/src/preload/api/gh-bridge-pull-requests-and-work-items.ts +++ b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts @@ -9,33 +9,34 @@ import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/work-item-types' import type { GitHubCreateIssueResult } from '../../shared/issue-mutation-types' import type { TaskSourceContext } from '../../shared/task-source-context' +import type { PreloadApi } from '../api-types' export const ghPullRequestsAndWorkItemsApi = { - viewer: (): Promise => ipcRenderer.invoke('gh:viewer'), - repoSlug: (args: { repoPath: string; repoId?: string }): Promise => + viewer: () => ipcRenderer.invoke('gh:viewer'), + repoSlug: (args: { repoPath: string; repoId?: string }) => ipcRenderer.invoke('gh:repoSlug', args), - repoUpstream: (args: { repoPath: string; repoId?: string }): Promise => + repoUpstream: (args: { repoPath: string; repoId?: string }) => ipcRenderer.invoke('gh:repoUpstream', args), prForBranch: (args: { repoPath: string - repoId?: string + repoId?: string | null branch: string linkedPRNumber?: number | null fallbackPRNumber?: number | null acceptMergedFallbackPR?: boolean currentHeadOid?: string | null - }): Promise => ipcRenderer.invoke('gh:prForBranch', args), - refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise => + }) => ipcRenderer.invoke('gh:prForBranch', args), + refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) => ipcRenderer.invoke('gh:refreshPRNow', args), enqueuePRRefresh: (args: { candidate: GitHubPRRefreshCandidate reason: GitHubPRRefreshReason priority?: number - }): Promise => ipcRenderer.invoke('gh:enqueuePRRefresh', args), + }) => ipcRenderer.invoke('gh:enqueuePRRefresh', args), reportVisiblePRRefreshCandidates: (args: { candidates: GitHubPRRefreshCandidate[] generation: number - }): Promise => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args), + }) => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args), onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, event: GitHubPRRefreshEvent): void => callback(event) @@ -44,42 +45,42 @@ export const ghPullRequestsAndWorkItemsApi = { }, issue: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number - }): Promise => ipcRenderer.invoke('gh:issue', args), + }) => ipcRenderer.invoke('gh:issue', args), workItem: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItem', args), + }) => ipcRenderer.invoke('gh:workItem', args), workItemByOwnerRepo: (args: { repoPath: string - repoId?: string + repoId?: string | null owner: string repo: string host?: string number: number type: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), + }) => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), workItemDetails: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItemDetails', args), + }) => ipcRenderer.invoke('gh:workItemDetails', args), notifyWorkItemMutated: (args: { repoPath: string - repoId?: string + repoId?: string | null type: 'issue' | 'pr' number: number }): Promise => ipcRenderer.invoke('gh:notifyWorkItemMutated', args), prFileContents: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null @@ -88,12 +89,12 @@ export const ghPullRequestsAndWorkItemsApi = { status: string headSha: string baseSha: string - }): Promise => ipcRenderer.invoke('gh:prFileContents', args), - listIssues: (args: { repoPath: string; repoId?: string; limit?: number }): Promise => + }) => ipcRenderer.invoke('gh:prFileContents', args), + listIssues: (args: { repoPath: string; repoId?: string; limit?: number }) => ipcRenderer.invoke('gh:listIssues', args), createIssue: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null title: string body: string @@ -104,7 +105,7 @@ export const ghPullRequestsAndWorkItemsApi = { ipcRenderer.invoke('gh:countWorkItems', args), listWorkItems: (args: { repoPath: string - repoId?: string + repoId?: string | null limit?: number query?: string page?: number @@ -113,26 +114,26 @@ export const ghPullRequestsAndWorkItemsApi = { ipcRenderer.invoke('gh:listWorkItems', args), prChecks: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number headSha?: string prRepo?: GitHubOwnerRepo | null noCache?: boolean - }): Promise => ipcRenderer.invoke('gh:prChecks', args), + }) => ipcRenderer.invoke('gh:prChecks', args), prCheckDetails: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null checkRunId?: number workflowRunId?: number checkName?: string url?: string | null prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), + }) => ipcRenderer.invoke('gh:prCheckDetails', args), rerunPRChecks: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number headSha?: string @@ -142,15 +143,15 @@ export const ghPullRequestsAndWorkItemsApi = { ipcRenderer.invoke('gh:rerunPRChecks', args), prComments: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null noCache?: boolean - }): Promise => ipcRenderer.invoke('gh:prComments', args), + }) => ipcRenderer.invoke('gh:prComments', args), setPRCommentReaction: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null reactionSubjectId: string content: GitHubReactionContent @@ -159,7 +160,7 @@ export const ghPullRequestsAndWorkItemsApi = { }): Promise => ipcRenderer.invoke('gh:setPRCommentReaction', args), resolveReviewThread: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null threadId: string resolve: boolean @@ -167,7 +168,7 @@ export const ghPullRequestsAndWorkItemsApi = { }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), setPRFileViewed: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null @@ -177,17 +178,17 @@ export const ghPullRequestsAndWorkItemsApi = { }): Promise => ipcRenderer.invoke('gh:setPRFileViewed', args), updatePRTitle: (args: { repoPath: string - repoId?: string + repoId?: string | null prNumber: number title: string prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args), mergePR: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number method?: 'merge' | 'squash' | 'rebase' prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args) -} +} satisfies Partial diff --git a/src/preload/api/gh-bridge.ts b/src/preload/api/gh-bridge.ts index 52c21a966a7..c7da93698e6 100644 --- a/src/preload/api/gh-bridge.ts +++ b/src/preload/api/gh-bridge.ts @@ -1,4 +1,8 @@ +import type { PreloadApi } from '../api-types' import { ghPullRequestsAndWorkItemsApi } from './gh-bridge-pull-requests-and-work-items' import { ghMutationsAndProjectsApi } from './gh-bridge-mutations-and-projects' -export const ghApi = { ...ghPullRequestsAndWorkItemsApi, ...ghMutationsAndProjectsApi } +export const ghApi = { + ...ghPullRequestsAndWorkItemsApi, + ...ghMutationsAndProjectsApi +} satisfies PreloadApi['gh'] diff --git a/src/preload/api/git-bash-bridge.ts b/src/preload/api/git-bash-bridge.ts index bd62dfc614a..c2186d12aa3 100644 --- a/src/preload/api/git-bash-bridge.ts +++ b/src/preload/api/git-bash-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const gitBashApi = { isAvailable: (): Promise => ipcRenderer.invoke('gitBash:isAvailable') -} +} satisfies PreloadApi['gitBash'] diff --git a/src/preload/api/git-bridge.ts b/src/preload/api/git-bridge.ts index 89dfc8db5ae..987abab14fc 100644 --- a/src/preload/api/git-bridge.ts +++ b/src/preload/api/git-bridge.ts @@ -3,6 +3,7 @@ import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../share import type { GitStagingArea, GitUpstreamStatus } from '../../shared/git-status-types' import type { GitPushTarget } from '../../shared/worktree/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { PreloadApi } from '../api-types' export const gitApi = { status: (args: { @@ -13,7 +14,7 @@ export const gitApi = { reuseLineStats?: boolean branchLineTotalMergeBase?: string requestToken?: string - }): Promise => ipcRenderer.invoke('git:status', args), + }) => ipcRenderer.invoke('git:status', args), cancelStatus: (args: { requestToken: string }): Promise => ipcRenderer.invoke('git:cancelStatus', args), setStatusUpstreamRefWatch: (args: { @@ -29,7 +30,7 @@ export const gitApi = { submodulePath: string connectionId?: string area?: GitStagingArea - }): Promise => ipcRenderer.invoke('git:submoduleStatus', args), + }) => ipcRenderer.invoke('git:submoduleStatus', args), checkIgnored: (args: { worktreePath: string paths: string[] @@ -42,7 +43,7 @@ export const gitApi = { history: ( args: { worktreePath: string; connectionId?: string } & GitHistoryOptions ): Promise => ipcRenderer.invoke('git:history', args), - conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => + conflictOperation: (args: { worktreePath: string; connectionId?: string }) => ipcRenderer.invoke('git:conflictOperation', args), abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('git:abortMerge', args), @@ -54,17 +55,11 @@ export const gitApi = { staged: boolean compareAgainstHead?: boolean connectionId?: string - }): Promise => ipcRenderer.invoke('git:diff', args), - branchCompare: (args: { - worktreePath: string - baseRef: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:branchCompare', args), - commitCompare: (args: { - worktreePath: string - commitId: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:commitCompare', args), + }) => ipcRenderer.invoke('git:diff', args), + branchCompare: (args: { worktreePath: string; baseRef: string; connectionId?: string }) => + ipcRenderer.invoke('git:branchCompare', args), + commitCompare: (args: { worktreePath: string; commitId: string; connectionId?: string }) => + ipcRenderer.invoke('git:commitCompare', args), upstreamStatus: (args: { worktreePath: string connectionId?: string @@ -108,7 +103,7 @@ export const gitApi = { filePath: string oldPath?: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:branchDiff', args), + }) => ipcRenderer.invoke('git:branchDiff', args), commitDiff: (args: { worktreePath: string commitOid: string @@ -116,7 +111,7 @@ export const gitApi = { filePath: string oldPath?: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:commitDiff', args), + }) => ipcRenderer.invoke('git:commitDiff', args), commit: (args: { worktreePath: string message: string @@ -130,12 +125,12 @@ export const gitApi = { sourceControlAiResolvedParams?: unknown sourceControlAi?: unknown agentCmdOverrides?: Record - }): Promise => ipcRenderer.invoke('git:generateCommitMessage', args), + }) => ipcRenderer.invoke('git:generateCommitMessage', args), discoverCommitMessageModels: (args: { agentId: string worktreePath?: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:discoverCommitMessageModels', args), + }) => ipcRenderer.invoke('git:discoverCommitMessageModels', args), cancelGenerateCommitMessage: (args: { worktreePath: string connectionId?: string @@ -154,7 +149,7 @@ export const gitApi = { sourceControlAiResolvedParams?: unknown sourceControlAi?: unknown agentCmdOverrides?: Record - }): Promise => ipcRenderer.invoke('git:generatePullRequestFields', args), + }) => ipcRenderer.invoke('git:generatePullRequestFields', args), cancelGeneratePullRequestFields: (args: { worktreePath: string connectionId?: string @@ -197,4 +192,4 @@ export const gitApi = { sha: string connectionId?: string }): Promise => ipcRenderer.invoke('git:remoteCommitUrl', args) -} +} satisfies PreloadApi['git'] diff --git a/src/preload/api/gl-bridge.ts b/src/preload/api/gl-bridge.ts index c48794a4976..3977536a838 100644 --- a/src/preload/api/gl-bridge.ts +++ b/src/preload/api/gl-bridge.ts @@ -1,3 +1,4 @@ import { glApi } from '../gitlab' +import type { PreloadApi } from '../api-types' -export const glApiBridge = glApi +export const glApiBridge = glApi satisfies PreloadApi['gl'] diff --git a/src/preload/api/grok-accounts-bridge.ts b/src/preload/api/grok-accounts-bridge.ts index b4719905ec7..246fc97bc56 100644 --- a/src/preload/api/grok-accounts-bridge.ts +++ b/src/preload/api/grok-accounts-bridge.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { GrokAccountStatus } from '../../shared/rate-limit-types' +import type { PreloadApi } from '../api-types' export const grokAccountsApi = { getStatus: (): Promise => ipcRenderer.invoke('grokAccounts:getStatus') -} +} satisfies PreloadApi['grokAccounts'] diff --git a/src/preload/api/hooks-bridge.ts b/src/preload/api/hooks-bridge.ts index 59a6fee71b6..75c48281b16 100644 --- a/src/preload/api/hooks-bridge.ts +++ b/src/preload/api/hooks-bridge.ts @@ -1,22 +1,14 @@ import { ipcRenderer } from 'electron' import type { WorktreeSetupLaunch } from '../../shared/worktree/launch-types' import type { ExecutionHostId } from '../../shared/execution-host' +import type { PreloadApi } from '../api-types' export const hooksApi = { - check: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise<{ - status?: 'ok' | 'error' - hasHooks: boolean - hooks: unknown - mayNeedUpdate: boolean - }> => ipcRenderer.invoke('hooks:check', args), + check: (args: { repoId: string; hostId?: ExecutionHostId }) => + ipcRenderer.invoke('hooks:check', args), - inspectSetupScriptImports: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), + inspectSetupScriptImports: (args: { repoId: string; hostId?: ExecutionHostId }) => + ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), createIssueCommandRunner: (args: { repoId: string @@ -41,4 +33,4 @@ export const hooksApi = { content: string hostId?: ExecutionHostId }): Promise => ipcRenderer.invoke('hooks:writeIssueCommand', args) -} +} satisfies PreloadApi['hooks'] diff --git a/src/preload/api/hosted-review-bridge.ts b/src/preload/api/hosted-review-bridge.ts index dc8323b5bf3..b7e0d12af5c 100644 --- a/src/preload/api/hosted-review-bridge.ts +++ b/src/preload/api/hosted-review-bridge.ts @@ -1,12 +1,12 @@ import { ipcRenderer } from 'electron' import type { HostedReviewForBranchArgs } from '../../shared/hosted-review' +import type { PreloadApi } from '../api-types' export const hostedReviewApi = { - forBranch: (args: HostedReviewForBranchArgs): Promise => + forBranch: (args: HostedReviewForBranchArgs) => ipcRenderer.invoke('hostedReview:forBranch', args), - getCreationEligibility: (args: unknown): Promise => + getCreationEligibility: (args: unknown) => ipcRenderer.invoke('hostedReview:getCreationEligibility', args), - create: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:create', args), - createStacked: (args: unknown): Promise => - ipcRenderer.invoke('hostedReview:createStacked', args) -} + create: (args: unknown) => ipcRenderer.invoke('hostedReview:create', args), + createStacked: (args: unknown) => ipcRenderer.invoke('hostedReview:createStacked', args) +} satisfies PreloadApi['hostedReview'] diff --git a/src/preload/api/jira-bridge.ts b/src/preload/api/jira-bridge.ts index 47a27b77f68..4b6b991095d 100644 --- a/src/preload/api/jira-bridge.ts +++ b/src/preload/api/jira-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { JiraProjectStatusOrder } from '../../shared/jira-types' +import type { PreloadApi } from '../api-types' export const jiraApi = { connect: (args: { @@ -7,30 +8,21 @@ export const jiraApi = { email: string apiToken: string authType?: 'cloud' | 'server' - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:connect', args), + }) => ipcRenderer.invoke('jira:connect', args), disconnect: (args?: { siteId?: string }): Promise => ipcRenderer.invoke('jira:disconnect', args), - selectSite: (args: { siteId: string }): Promise => - ipcRenderer.invoke('jira:selectSite', args), + selectSite: (args: { siteId: string }) => ipcRenderer.invoke('jira:selectSite', args), - status: (): Promise => ipcRenderer.invoke('jira:status'), + status: () => ipcRenderer.invoke('jira:status'), - readStatus: (): Promise => ipcRenderer.invoke('jira:readStatus'), + readStatus: () => ipcRenderer.invoke('jira:readStatus'), - testConnection: (args?: { - siteId?: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:testConnection', args), + testConnection: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:testConnection', args), - searchIssues: (args: { - jql: string - limit?: number - siteId?: string - requestId?: string - }): Promise => ipcRenderer.invoke('jira:searchIssues', args), + searchIssues: (args: { jql: string; limit?: number; siteId?: string; requestId?: string }) => + ipcRenderer.invoke('jira:searchIssues', args), cancelSearchIssues: (args: { requestId: string }): Promise => ipcRenderer.invoke('jira:cancelSearchIssues', args), @@ -38,16 +30,12 @@ export const jiraApi = { filter?: 'assigned' | 'reported' | 'all' | 'done' limit?: number siteId?: string - }): Promise => ipcRenderer.invoke('jira:listIssues', args), + }) => ipcRenderer.invoke('jira:listIssues', args), - getIssue: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:getIssue', args), + getIssue: (args: { key: string; siteId?: string }) => ipcRenderer.invoke('jira:getIssue', args), - lookupIssueSummary: (args: { - key: string - siteId: string - requestId?: string - }): Promise => ipcRenderer.invoke('jira:lookupIssueSummary', args), + lookupIssueSummary: (args: { key: string; siteId: string; requestId?: string }) => + ipcRenderer.invoke('jira:lookupIssueSummary', args), cancelIssueSummary: (args: { requestId: string }): Promise => ipcRenderer.invoke('jira:cancelIssueSummary', args), @@ -75,36 +63,28 @@ export const jiraApi = { }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => ipcRenderer.invoke('jira:addIssueComment', args), - issueComments: (args: { key: string; siteId?: string }): Promise => + issueComments: (args: { key: string; siteId?: string }) => ipcRenderer.invoke('jira:issueComments', args), - listProjects: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:listProjects', args), + listProjects: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:listProjects', args), - listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }): Promise => + listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }) => ipcRenderer.invoke('jira:listIssueTypes', args), - listCreateFields: (args: { - projectIdOrKey: string - issueTypeId: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listCreateFields', args), + listCreateFields: (args: { projectIdOrKey: string; issueTypeId: string; siteId?: string }) => + ipcRenderer.invoke('jira:listCreateFields', args), - listPriorities: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:listPriorities', args), + listPriorities: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:listPriorities', args), - listAssignableUsers: (args: { - key: string - query?: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listAssignableUsers', args), - searchUsers: (args?: { query?: string; siteId?: string }): Promise => + listAssignableUsers: (args: { key: string; query?: string; siteId?: string }) => + ipcRenderer.invoke('jira:listAssignableUsers', args), + searchUsers: (args?: { query?: string; siteId?: string }) => ipcRenderer.invoke('jira:searchUsers', args), - listTransitions: (args: { key: string; siteId?: string }): Promise => + listTransitions: (args: { key: string; siteId?: string }) => ipcRenderer.invoke('jira:listTransitions', args), getProjectStatusOrder: (args: { projectKey: string siteId?: string }): Promise => ipcRenderer.invoke('jira:getProjectStatusOrder', args) -} +} satisfies PreloadApi['jira'] diff --git a/src/preload/api/keybindings-bridge.ts b/src/preload/api/keybindings-bridge.ts index 3111ddd6676..e91e587313d 100644 --- a/src/preload/api/keybindings-bridge.ts +++ b/src/preload/api/keybindings-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { KeybindingActionId, KeybindingFileSnapshot } from '../../shared/keybindings' +import type { PreloadApi } from '../api-types' export const keybindingsApi = { get: (): Promise => ipcRenderer.invoke('keybindings:get'), @@ -17,4 +18,4 @@ export const keybindingsApi = { ipcRenderer.on('keybindings:changed', listener) return () => ipcRenderer.removeListener('keybindings:changed', listener) } -} +} satisfies PreloadApi['keybindings'] diff --git a/src/preload/api/linear-bridge.ts b/src/preload/api/linear-bridge.ts index cd6ec0e9c15..8092a8e70e7 100644 --- a/src/preload/api/linear-bridge.ts +++ b/src/preload/api/linear-bridge.ts @@ -1,37 +1,30 @@ import { ipcRenderer } from 'electron' import type { LinearProjectDetail } from '../../shared/linear/project-types' +import type { PreloadApi } from '../api-types' export const linearApi = { - connect: (args: { - apiKey: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:connect', args), + connect: (args: { apiKey: string }) => ipcRenderer.invoke('linear:connect', args), disconnect: (args?: { workspaceId?: string }): Promise => ipcRenderer.invoke('linear:disconnect', args), - selectWorkspace: (args: { workspaceId: string }): Promise => + selectWorkspace: (args: { workspaceId: string }) => ipcRenderer.invoke('linear:selectWorkspace', args), - status: (): Promise => ipcRenderer.invoke('linear:status'), + status: () => ipcRenderer.invoke('linear:status'), - testConnection: (args?: { - workspaceId?: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => + testConnection: (args?: { workspaceId?: string }) => ipcRenderer.invoke('linear:testConnection', args), - searchIssues: (args: { - query: string - limit?: number - workspaceId?: string - }): Promise => ipcRenderer.invoke('linear:searchIssues', args), + searchIssues: (args: { query: string; limit?: number; workspaceId?: string }) => + ipcRenderer.invoke('linear:searchIssues', args), listIssues: (args?: { filter?: 'assigned' | 'created' | 'all' | 'completed' limit?: number workspaceId?: string attributeFilter?: unknown - }): Promise => ipcRenderer.invoke('linear:listIssues', args), + }) => ipcRenderer.invoke('linear:listIssues', args), createIssue: (args: { teamId: string @@ -49,7 +42,7 @@ export const linearApi = { | { ok: false; error: string } > => ipcRenderer.invoke('linear:createIssue', args), - getIssue: (args: { id: string; workspaceId?: string }): Promise => + getIssue: (args: { id: string; workspaceId?: string }) => ipcRenderer.invoke('linear:getIssue', args), updateIssue: (args: { @@ -66,18 +59,17 @@ export const linearApi = { }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => ipcRenderer.invoke('linear:addIssueComment', args), - issueComments: (args: { issueId: string; workspaceId?: string }): Promise => + issueComments: (args: { issueId: string; workspaceId?: string }) => ipcRenderer.invoke('linear:issueComments', args), - listTeams: (args?: { workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:listTeams', args), + listTeams: (args?: { workspaceId?: string }) => ipcRenderer.invoke('linear:listTeams', args), listProjects: (args?: { query?: string limit?: number workspaceId?: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listProjects', args), + }) => ipcRenderer.invoke('linear:listProjects', args), createProject: (args: { name: string @@ -94,7 +86,7 @@ export const linearApi = { }): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> => ipcRenderer.invoke('linear:createProject', args), - getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise => + getProject: (args: { id: string; workspaceId: string; force?: boolean }) => ipcRenderer.invoke('linear:getProject', args), listProjectIssues: (args: { @@ -102,42 +94,38 @@ export const linearApi = { limit?: number workspaceId: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listProjectIssues', args), + }) => ipcRenderer.invoke('linear:listProjectIssues', args), listCustomViews: (args: { model: string limit?: number workspaceId?: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViews', args), + }) => ipcRenderer.invoke('linear:listCustomViews', args), - getCustomView: (args: { - viewId: string - model: string - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:getCustomView', args), + getCustomView: (args: { viewId: string; model: string; workspaceId: string; force?: boolean }) => + ipcRenderer.invoke('linear:getCustomView', args), listCustomViewIssues: (args: { viewId: string limit?: number workspaceId: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViewIssues', args), + }) => ipcRenderer.invoke('linear:listCustomViewIssues', args), listCustomViewProjects: (args: { viewId: string limit?: number workspaceId: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViewProjects', args), + }) => ipcRenderer.invoke('linear:listCustomViewProjects', args), - teamStates: (args: { teamId: string; workspaceId?: string }): Promise => + teamStates: (args: { teamId: string; workspaceId?: string }) => ipcRenderer.invoke('linear:teamStates', args), - teamLabels: (args: { teamId: string; workspaceId?: string }): Promise => + teamLabels: (args: { teamId: string; workspaceId?: string }) => ipcRenderer.invoke('linear:teamLabels', args), - teamMembers: (args: { teamId: string; workspaceId?: string }): Promise => + teamMembers: (args: { teamId: string; workspaceId?: string }) => ipcRenderer.invoke('linear:teamMembers', args) -} +} satisfies PreloadApi['linear'] diff --git a/src/preload/api/macos-tcc-prompts-bridge.ts b/src/preload/api/macos-tcc-prompts-bridge.ts index 0c6c6b184fc..ef24b9e203e 100644 --- a/src/preload/api/macos-tcc-prompts-bridge.ts +++ b/src/preload/api/macos-tcc-prompts-bridge.ts @@ -1,11 +1,14 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const macosTccPromptsApi = { - onThreshold: (callback: (payload: unknown) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: unknown): void => + onThreshold: (callback: (payload: { promptCount: number }) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { promptCount: number }): void => callback(payload) ipcRenderer.on('macosTccPrompts:threshold', listener) - return () => ipcRenderer.removeListener('macosTccPrompts:threshold', listener) + return (): void => { + ipcRenderer.removeListener('macosTccPrompts:threshold', listener) + } }, consumePending: (): Promise<{ claimId: number; promptCount: number } | null> => ipcRenderer.invoke('macosTccPrompts:consumePending'), @@ -14,4 +17,4 @@ export const macosTccPromptsApi = { releasePending: (claimId: number): Promise => ipcRenderer.invoke('macosTccPrompts:releasePending', claimId), dismiss: (): Promise => ipcRenderer.invoke('macosTccPrompts:dismiss') -} +} satisfies PreloadApi['macosTccPrompts'] diff --git a/src/preload/api/memory-bridge.ts b/src/preload/api/memory-bridge.ts index 15af55cb09c..c1735f86e31 100644 --- a/src/preload/api/memory-bridge.ts +++ b/src/preload/api/memory-bridge.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { MemorySnapshot } from '../../shared/process-stats-types' +import type { PreloadApi } from '../api-types' export const memoryApi = { getSnapshot: (): Promise => ipcRenderer.invoke('memory:getSnapshot') -} +} satisfies PreloadApi['memory'] diff --git a/src/preload/api/minimax-credentials-bridge.ts b/src/preload/api/minimax-credentials-bridge.ts index a758d9e2e5b..e99bd843909 100644 --- a/src/preload/api/minimax-credentials-bridge.ts +++ b/src/preload/api/minimax-credentials-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const minimaxCredentialsApi = { getStatus: (): Promise<{ configured: boolean }> => @@ -7,4 +8,4 @@ export const minimaxCredentialsApi = { ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie), clearCookie: (): Promise<{ configured: boolean }> => ipcRenderer.invoke('minimaxCredentials:clearCookie') -} +} satisfies PreloadApi['minimaxCredentials'] diff --git a/src/preload/api/mobile-bridge.ts b/src/preload/api/mobile-bridge.ts index 836f27f6f37..a1ad9a4c716 100644 --- a/src/preload/api/mobile-bridge.ts +++ b/src/preload/api/mobile-bridge.ts @@ -3,6 +3,7 @@ import type { MobileRelayStatus } from '../../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' import type { MobileRelayMintFailure } from '../../shared/mobile-relay-mint-failure' +import type { PreloadApi } from '../api-types' export const mobileApi = { listNetworkInterfaces: (): Promise<{ @@ -92,4 +93,4 @@ export const mobileApi = { ipcRenderer.on('mobile:unpairedDeviceAuthFailure', listener) return () => ipcRenderer.removeListener('mobile:unpairedDeviceAuthFailure', listener) } -} +} satisfies PreloadApi['mobile'] diff --git a/src/preload/api/native-chat-bridge.ts b/src/preload/api/native-chat-bridge.ts index 16c2906349e..3a0a5d9161a 100644 --- a/src/preload/api/native-chat-bridge.ts +++ b/src/preload/api/native-chat-bridge.ts @@ -2,7 +2,8 @@ import { ipcRenderer } from 'electron' import type { NativeChatAppendedPayload, NativeChatReadSessionResult, - NativeChatSubscriptionFrame + NativeChatSubscriptionFrame, + PreloadApi } from '../api-types' import type { AgentType } from '../../shared/native-chat-types' @@ -37,4 +38,4 @@ export const nativeChatApi = { ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId }) } } -} +} satisfies PreloadApi['nativeChat'] diff --git a/src/preload/api/notebook-bridge.ts b/src/preload/api/notebook-bridge.ts index ee307b9f0e2..436726b7783 100644 --- a/src/preload/api/notebook-bridge.ts +++ b/src/preload/api/notebook-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const notebookApi = { runPythonCell: (args: { @@ -8,4 +9,4 @@ export const notebookApi = { connectionId?: string | null }): Promise<{ stdout: string; stderr: string; exitCode: number | null; error?: string }> => ipcRenderer.invoke('notebook:runPythonCell', args) -} +} satisfies PreloadApi['notebook'] diff --git a/src/preload/api/notifications-bridge.ts b/src/preload/api/notifications-bridge.ts index aa84fb1a8a6..70c64d4ce0d 100644 --- a/src/preload/api/notifications-bridge.ts +++ b/src/preload/api/notifications-bridge.ts @@ -8,6 +8,7 @@ import type { NotificationSoundPathResult, NotificationSoundResult } from '../../shared/notification-settings-types' +import type { PreloadApi } from '../api-types' // Why: cache one shared Audio + blob URL per sound path so notifications do not re-read large files. let cachedNotificationSound: { @@ -117,4 +118,4 @@ export const notificationsApi = { return { played: false, reason: 'playback-failed' } } } -} +} satisfies PreloadApi['notifications'] diff --git a/src/preload/api/onboarding-bridge.ts b/src/preload/api/onboarding-bridge.ts index 1b4393268ac..7939ab64810 100644 --- a/src/preload/api/onboarding-bridge.ts +++ b/src/preload/api/onboarding-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { OnboardingState } from '../../shared/onboarding-state-types' +import type { PreloadApi } from '../api-types' export const onboardingApi = { get: (): Promise => ipcRenderer.invoke('onboarding:get'), @@ -8,4 +9,4 @@ export const onboardingApi = { checklist?: Partial } ): Promise => ipcRenderer.invoke('onboarding:update', updates) -} +} satisfies PreloadApi['onboarding'] diff --git a/src/preload/api/open-code-usage-bridge.ts b/src/preload/api/open-code-usage-bridge.ts index 5cc668e6e00..cd3564d2b32 100644 --- a/src/preload/api/open-code-usage-bridge.ts +++ b/src/preload/api/open-code-usage-bridge.ts @@ -1,4 +1,8 @@ import { ipcRenderer } from 'electron' import { createUsageProviderApi } from '../usage-provider-api' +import type { PreloadApi } from '../api-types' -export const openCodeUsageApi = createUsageProviderApi(ipcRenderer, 'openCodeUsage') +export const openCodeUsageApi = createUsageProviderApi( + ipcRenderer, + 'openCodeUsage' +) satisfies PreloadApi['openCodeUsage'] diff --git a/src/preload/api/pet-bridge.ts b/src/preload/api/pet-bridge.ts index 8a5d3309c5c..c51751b3161 100644 --- a/src/preload/api/pet-bridge.ts +++ b/src/preload/api/pet-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { CustomPet } from '../../shared/pet-types' +import type { PreloadApi } from '../api-types' export const petApi = { import: (): Promise => ipcRenderer.invoke('pet:import'), @@ -8,4 +9,4 @@ export const petApi = { ipcRenderer.invoke('pet:read', id, fileName, kind), delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => ipcRenderer.invoke('pet:delete', id, fileName, kind) -} +} satisfies PreloadApi['pet'] diff --git a/src/preload/api/plugins-bridge.ts b/src/preload/api/plugins-bridge.ts index 2488d4cfdb7..0e529e74d96 100644 --- a/src/preload/api/plugins-bridge.ts +++ b/src/preload/api/plugins-bridge.ts @@ -24,11 +24,8 @@ export const pluginsApi = { pluginKey: string panelId: string }): Promise => ipcRenderer.invoke('plugins:readPanelEntry', args), - invokeCommand: (args: { - pluginKey: string - commandId: string - args?: unknown - }): Promise => ipcRenderer.invoke('plugins:invokeCommand', args), + invokeCommand: (args: { pluginKey: string; commandId: string; args?: unknown }) => + ipcRenderer.invoke('plugins:invokeCommand', args), panelAction: (args: { sessionToken: string action: string diff --git a/src/preload/api/preflight-bridge.ts b/src/preload/api/preflight-bridge.ts index c05721a2d3b..64d317d139c 100644 --- a/src/preload/api/preflight-bridge.ts +++ b/src/preload/api/preflight-bridge.ts @@ -1,5 +1,5 @@ import { ipcRenderer } from 'electron' -import type { PreflightRuntimeContext, RefreshAgentsResult } from '../api-types' +import type { PreflightRuntimeContext, PreloadApi, RefreshAgentsResult } from '../api-types' export const preflightApi = { check: (args?: { @@ -40,4 +40,4 @@ export const preflightApi = { gitBashAvailable: boolean hostPlatform: NodeJS.Platform | null }> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args) -} +} satisfies PreloadApi['preflight'] diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 7e761738256..ef6002e11c8 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -15,6 +15,7 @@ import type { import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' +import type { PreloadApi } from '../api-types' export const ptySessionControlApi = { spawn: (opts: { @@ -204,4 +205,4 @@ export const ptySessionControlApi = { ipcRenderer.invoke('pty:hasChildProcesses', { id }), getForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:getForegroundProcess', { id }) -} +} satisfies Partial diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 8fc9c48bce1..f751491c0e0 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' +import type { PreloadApi } from '../api-types' export const ptyStreamAndSerializationApi = { inspectProcess: ( @@ -138,4 +139,4 @@ export const ptyStreamAndSerializationApi = { restart: () => ipcRenderer.invoke('pty:management:restart'), macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution') } -} +} satisfies Partial diff --git a/src/preload/api/pty-bridge.ts b/src/preload/api/pty-bridge.ts index df080692178..18f867e6426 100644 --- a/src/preload/api/pty-bridge.ts +++ b/src/preload/api/pty-bridge.ts @@ -1,4 +1,8 @@ +import type { PreloadApi } from '../api-types' import { ptySessionControlApi } from './pty-bridge-session-control' import { ptyStreamAndSerializationApi } from './pty-bridge-stream-and-serialization' -export const ptyApi = { ...ptySessionControlApi, ...ptyStreamAndSerializationApi } +export const ptyApi = { + ...ptySessionControlApi, + ...ptyStreamAndSerializationApi +} satisfies PreloadApi['pty'] diff --git a/src/preload/api/pwsh-bridge.ts b/src/preload/api/pwsh-bridge.ts index bd202277ad1..34ed9880ada 100644 --- a/src/preload/api/pwsh-bridge.ts +++ b/src/preload/api/pwsh-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const pwshApi = { isAvailable: (): Promise => ipcRenderer.invoke('pwsh:isAvailable') -} +} satisfies PreloadApi['pwsh'] diff --git a/src/preload/api/rate-limits-bridge.ts b/src/preload/api/rate-limits-bridge.ts index f403d79cdc6..37af13f0006 100644 --- a/src/preload/api/rate-limits-bridge.ts +++ b/src/preload/api/rate-limits-bridge.ts @@ -4,6 +4,7 @@ import type { RateLimitRuntimeTarget, RateLimitState } from '../../shared/rate-limit-types' +import type { PreloadApi } from '../api-types' export const rateLimitsApi = { get: (): Promise => ipcRenderer.invoke('rateLimits:get'), @@ -27,4 +28,4 @@ export const rateLimitsApi = { ipcRenderer.on('rateLimits:update', listener) return () => ipcRenderer.removeListener('rateLimits:update', listener) } -} +} satisfies PreloadApi['rateLimits'] diff --git a/src/preload/api/runtime-bridge.ts b/src/preload/api/runtime-bridge.ts index c58049bcbe3..8b31e6873f5 100644 --- a/src/preload/api/runtime-bridge.ts +++ b/src/preload/api/runtime-bridge.ts @@ -9,6 +9,7 @@ import type { } from '../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RuntimeEnvironmentSubscriptionHandle } from '../runtime-environment-subscriptions' +import type { PreloadApi } from '../api-types' export const runtimeApi = { syncWindowGraph: (graph: RuntimeRendererSyncWindowGraph): Promise => @@ -141,4 +142,4 @@ export const runtimeApi = { ipcRenderer.on('runtime:clientHostedBrowserRowsChanged', listener) return () => ipcRenderer.removeListener('runtime:clientHostedBrowserRowsChanged', listener) } -} +} satisfies PreloadApi['runtime'] diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts index d018c0574c8..ddfa498dc74 100644 --- a/src/preload/api/runtime-environments-bridge.ts +++ b/src/preload/api/runtime-environments-bridge.ts @@ -9,6 +9,7 @@ import { subscribeRuntimeEnvironmentFromPreload, type RuntimeEnvironmentSubscriptionHandle } from '../runtime-environment-subscriptions' +import type { PreloadApi } from '../api-types' export const runtimeEnvironmentsApi = { list: (): Promise => @@ -90,4 +91,4 @@ export const runtimeEnvironmentsApi = { } ): Promise => subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks) -} +} satisfies PreloadApi['runtimeEnvironments'] diff --git a/src/preload/api/settings-bridge.ts b/src/preload/api/settings-bridge.ts index d8aaa23b0b7..4e7105c00cb 100644 --- a/src/preload/api/settings-bridge.ts +++ b/src/preload/api/settings-bridge.ts @@ -4,22 +4,20 @@ import type { WarpThemeImportPreview, WarpThemeImportSource } from '../../shared/terminal-custom-themes' +import type { PreloadApi } from '../api-types' export const settingsApi = { - get: (): Promise => ipcRenderer.invoke('settings:get'), + get: () => ipcRenderer.invoke('settings:get'), // Why: blocking read for the few startup decisions (terminal side-effect authority) that can't wait for async hydration. Call sparingly. - getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'), + getSync: () => ipcRenderer.sendSync('settings:get-sync'), - set: (args: Record): Promise => - ipcRenderer.invoke('settings:set', args), + set: (args: Record) => ipcRenderer.invoke('settings:set', args), - setActiveRuntimeEnvironmentPreference: (args: { - environmentId: string | null - }): Promise => + setActiveRuntimeEnvironmentPreference: (args: { environmentId: string | null }) => ipcRenderer.invoke('settings:set-active-runtime-environment-preference', args), - updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }): Promise => + updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }) => ipcRenderer.invoke('settings:update-pr-bot-author-override', args), listFonts: (): Promise => ipcRenderer.invoke('settings:listFonts'), @@ -36,4 +34,4 @@ export const settingsApi = { ipcRenderer.on('settings:changed', listener) return () => ipcRenderer.removeListener('settings:changed', listener) } -} +} satisfies PreloadApi['settings'] diff --git a/src/preload/api/shell-bridge.ts b/src/preload/api/shell-bridge.ts index be34c7ff70e..21ccda3fd83 100644 --- a/src/preload/api/shell-bridge.ts +++ b/src/preload/api/shell-bridge.ts @@ -4,6 +4,7 @@ import type { ShellOpenExternalEditorResult, ShellOpenLocalPathResult } from '../../shared/shell-open-types' +import type { PreloadApi } from '../api-types' export const shellApi = { openPath: (path: string): Promise => ipcRenderer.invoke('shell:openPath', path), @@ -38,4 +39,4 @@ export const shellApi = { copyFile: (args: { srcPath: string; destPath: string }): Promise => ipcRenderer.invoke('shell:copyFile', args) -} +} satisfies PreloadApi['shell'] diff --git a/src/preload/api/skills-bridge.ts b/src/preload/api/skills-bridge.ts index eafaf2ce543..4bcde9a613c 100644 --- a/src/preload/api/skills-bridge.ts +++ b/src/preload/api/skills-bridge.ts @@ -37,6 +37,7 @@ import type { SkillUpdateRun, SkillUpdateStartResult } from '../../shared/skill-freshness' +import type { PreloadApi } from '../api-types' export const skillsApi = { discover: (target?: SkillDiscoveryTarget): Promise => @@ -126,4 +127,4 @@ export const skillsApi = { ipcRenderer.on('skills:updateRun', listener) return () => ipcRenderer.removeListener('skills:updateRun', listener) } -} +} satisfies PreloadApi['skills'] diff --git a/src/preload/api/speech-bridge.ts b/src/preload/api/speech-bridge.ts index 513b219f498..dbd7e0d26ab 100644 --- a/src/preload/api/speech-bridge.ts +++ b/src/preload/api/speech-bridge.ts @@ -6,6 +6,7 @@ import type { SpeechModelState, SpeechTranscriptEvent } from '../../shared/speech-types' +import type { PreloadApi } from '../api-types' export const speechApi = { getCatalog: (): Promise => ipcRenderer.invoke('speech:getCatalog'), @@ -78,4 +79,4 @@ export const speechApi = { ipcRenderer.on('speech:error', listener) return () => ipcRenderer.removeListener('speech:error', listener) } -} +} satisfies PreloadApi['speech'] diff --git a/src/preload/api/ssh-bridge.ts b/src/preload/api/ssh-bridge.ts index 2884e4ec8c1..b0f7b89ec3d 100644 --- a/src/preload/api/ssh-bridge.ts +++ b/src/preload/api/ssh-bridge.ts @@ -17,6 +17,7 @@ import { admitSshDetectedPorts } from '../../shared/ssh-retained-payload-admission' import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' +import type { PreloadApi } from '../api-types' export const sshApi = { listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), @@ -180,4 +181,4 @@ export const sshApi = { submitCredential: (args: { requestId: string; value: string | null }): Promise => ipcRenderer.invoke('ssh:submitCredential', args) -} +} satisfies PreloadApi['ssh'] diff --git a/src/preload/api/star-nag-bridge.ts b/src/preload/api/star-nag-bridge.ts index b697739a52f..49e56e4aa91 100644 --- a/src/preload/api/star-nag-bridge.ts +++ b/src/preload/api/star-nag-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const starNagApi = { onShow: ( @@ -27,4 +28,4 @@ export const starNagApi = { ipcRenderer.invoke('star-nag:agentValueMoment'), showAgentValueMoment: (): Promise => ipcRenderer.invoke('star-nag:showAgentValueMoment'), onboardingCompleted: (): Promise => ipcRenderer.invoke('star-nag:onboardingCompleted') -} +} satisfies PreloadApi['starNag'] diff --git a/src/preload/api/stats-bridge.ts b/src/preload/api/stats-bridge.ts index 20bc823b86a..f435b9980f5 100644 --- a/src/preload/api/stats-bridge.ts +++ b/src/preload/api/stats-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const statsApi = { getSummary: (): Promise<{ @@ -7,4 +8,4 @@ export const statsApi = { totalAgentTimeMs: number firstEventAt: number | null }> => ipcRenderer.invoke('stats:summary') -} +} satisfies PreloadApi['stats'] diff --git a/src/preload/api/terminal-preview-bridge.ts b/src/preload/api/terminal-preview-bridge.ts index c8bf5b38623..3bd94d4998b 100644 --- a/src/preload/api/terminal-preview-bridge.ts +++ b/src/preload/api/terminal-preview-bridge.ts @@ -3,6 +3,7 @@ import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload } from '../../shared/terminal-preview' +import type { PreloadApi } from '../api-types' export const terminalPreviewApi = { connect: ( @@ -30,4 +31,4 @@ export const terminalPreviewApi = { ipcRenderer.on('terminalPreview:data', listener) return () => ipcRenderer.removeListener('terminalPreview:data', listener) } -} +} satisfies PreloadApi['terminalPreview'] diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 867bd80026d..fdad19c2944 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -12,6 +12,7 @@ import { import type { NativeFileDropPayload } from '../../shared/native-file-drop' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' import { subscribeNativeFileDrop } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' export const uiClipboardAndWindowControlsApi = { onOpenDiffFromMobile: ( @@ -195,4 +196,4 @@ export const uiClipboardAndWindowControlsApi = { notifyWindowRevealed: (): void => { ipcRenderer.send('ui:window-revealed') } -} +} satisfies Partial diff --git a/src/preload/api/ui-bridge-state-and-menu-commands.ts b/src/preload/api/ui-bridge-state-and-menu-commands.ts index 34eb83886c8..246cebb1613 100644 --- a/src/preload/api/ui-bridge-state-and-menu-commands.ts +++ b/src/preload/api/ui-bridge-state-and-menu-commands.ts @@ -2,6 +2,7 @@ import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import { ipcRenderer } from 'electron' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { KeybindingActionId } from '../../shared/keybindings' +import type { PreloadApi } from '../api-types' export const uiStateAndMenuCommandsApi = { get: () => ipcRenderer.invoke('ui:get'), @@ -173,4 +174,4 @@ export const uiStateAndMenuCommandsApi = { replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }): void => { ipcRenderer.send('browser:tabCreateReply', reply) } -} +} satisfies Partial diff --git a/src/preload/api/ui-bridge-tab-and-browser-commands.ts b/src/preload/api/ui-bridge-tab-and-browser-commands.ts index ccca9a24f5b..d275367b398 100644 --- a/src/preload/api/ui-bridge-tab-and-browser-commands.ts +++ b/src/preload/api/ui-bridge-tab-and-browser-commands.ts @@ -6,6 +6,7 @@ import type { WorktreeSetupLaunch } from '../../shared/worktree/launch-types' import { browserFindSubscriptions } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' export const uiTabAndBrowserCommandsApi = { onRequestTabSetProfile: ( @@ -200,4 +201,4 @@ export const uiTabAndBrowserCommandsApi = { ipcRenderer.on('ui:activateWorktree', listener) return () => ipcRenderer.removeListener('ui:activateWorktree', listener) } -} +} satisfies Partial diff --git a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts index 9de47cceb0c..eaff9e8847a 100644 --- a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -11,6 +11,7 @@ import type { RuntimeTerminalCreateRequestPayload, RuntimeTerminalPresentation } from '../../shared/runtime-types' +import type { PreloadApi } from '../api-types' export const uiTerminalAndSessionTabsApi = { onCreateTerminal: ( @@ -211,4 +212,4 @@ export const uiTerminalAndSessionTabsApi = { ipcRenderer.on('ui:openFileFromMobile', listener) return () => ipcRenderer.removeListener('ui:openFileFromMobile', listener) } -} +} satisfies Partial diff --git a/src/preload/api/wsl-bridge.ts b/src/preload/api/wsl-bridge.ts index aeb32cdfa45..bc7869000e4 100644 --- a/src/preload/api/wsl-bridge.ts +++ b/src/preload/api/wsl-bridge.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const wslApi = { isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable'), listDistros: (): Promise => ipcRenderer.invoke('wsl:listDistros') -} +} satisfies PreloadApi['wsl'] diff --git a/src/preload/app-restart-checkpoint-routing.test.ts b/src/preload/app-restart-checkpoint-routing.test.ts index 19eb6948c9a..d794a86b9ab 100644 --- a/src/preload/app-restart-checkpoint-routing.test.ts +++ b/src/preload/app-restart-checkpoint-routing.test.ts @@ -92,6 +92,20 @@ describe('native preload destructive app actions', () => { }) } + it('exposes the durable checkpoint join the lazy-chunk recovery reload depends on', async () => { + const api = await loadApi() + invoke.mockResolvedValue({ ok: true }) + + await expect(api.app.awaitBeforeUnloadCheckpoint()).resolves.toBeUndefined() + expect(invoke).toHaveBeenCalledWith('app:await-before-unload-checkpoint') + + invoke.mockResolvedValue({ ok: false }) + + await expect(api.app.awaitBeforeUnloadCheckpoint()).rejects.toThrow( + 'Failed to persist renderer state before unload.' + ) + }) + it('preserves both macOS keyboard preload adapters', async () => { const api = await loadApi() invoke.mockResolvedValue(undefined) diff --git a/src/preload/gitlab.ts b/src/preload/gitlab.ts index d6f12367db0..154e139e4c4 100644 --- a/src/preload/gitlab.ts +++ b/src/preload/gitlab.ts @@ -12,23 +12,21 @@ type GitLabRepoSelectorArgs = { } export const glApi = { - viewer: (): Promise => ipcRenderer.invoke('gitlab:viewer'), - diagnoseAuth: (): Promise => ipcRenderer.invoke('gitlab:diagnoseAuth'), - rateLimit: (args?: { force?: boolean; host?: string | null }): Promise => + viewer: () => ipcRenderer.invoke('gitlab:viewer'), + diagnoseAuth: () => ipcRenderer.invoke('gitlab:diagnoseAuth'), + rateLimit: (args?: { force?: boolean; host?: string | null }) => ipcRenderer.invoke('gitlab:rateLimit', args), - projectSlug: (args: GitLabRepoSelectorArgs): Promise => - ipcRenderer.invoke('gitlab:projectSlug', args), + projectSlug: (args: GitLabRepoSelectorArgs) => ipcRenderer.invoke('gitlab:projectSlug', args), mrForBranch: ( args: GitLabRepoSelectorArgs & { branch: string linkedMRIid?: number | null } - ): Promise => ipcRenderer.invoke('gitlab:mrForBranch', args), + ) => ipcRenderer.invoke('gitlab:mrForBranch', args), - mr: (args: GitLabRepoSelectorArgs & { iid: number }): Promise => - ipcRenderer.invoke('gitlab:mr', args), + mr: (args: GitLabRepoSelectorArgs & { iid: number }) => ipcRenderer.invoke('gitlab:mr', args), listMRs: ( args: GitLabRepoSelectorArgs & { @@ -37,7 +35,7 @@ export const glApi = { perPage?: number query?: string } - ): Promise => ipcRenderer.invoke('gitlab:listMRs', args), + ) => ipcRenderer.invoke('gitlab:listMRs', args), listWorkItems: ( args: GitLabRepoSelectorArgs & { @@ -46,9 +44,9 @@ export const glApi = { perPage?: number query?: string } - ): Promise => ipcRenderer.invoke('gitlab:listWorkItems', args), + ) => ipcRenderer.invoke('gitlab:listWorkItems', args), - issue: (args: GitLabRepoSelectorArgs & { number: number }): Promise => + issue: (args: GitLabRepoSelectorArgs & { number: number }) => ipcRenderer.invoke('gitlab:issue', args), listIssues: ( @@ -58,8 +56,7 @@ export const glApi = { limit?: number page?: number } - ): Promise<{ items: unknown[]; totalPages?: number; error?: unknown }> => - ipcRenderer.invoke('gitlab:listIssues', args), + ) => ipcRenderer.invoke('gitlab:listIssues', args), createIssue: ( args: GitLabRepoSelectorArgs & { @@ -77,25 +74,23 @@ export const glApi = { ): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gitlab:updateIssue', args), - addIssueComment: ( - args: GitLabRepoSelectorArgs & { number: number; body: string } - ): Promise => ipcRenderer.invoke('gitlab:addIssueComment', args), + addIssueComment: (args: GitLabRepoSelectorArgs & { number: number; body: string }) => + ipcRenderer.invoke('gitlab:addIssueComment', args), listLabels: (args: GitLabRepoSelectorArgs): Promise => ipcRenderer.invoke('gitlab:listLabels', args), - listAssignableUsers: (args: GitLabRepoSelectorArgs): Promise => + listAssignableUsers: (args: GitLabRepoSelectorArgs) => ipcRenderer.invoke('gitlab:listAssignableUsers', args), - todos: (args: GitLabRepoSelectorArgs): Promise => - ipcRenderer.invoke('gitlab:todos', args), + todos: (args: GitLabRepoSelectorArgs) => ipcRenderer.invoke('gitlab:todos', args), workItemDetails: ( args: GitLabRepoSelectorArgs & { iid: number type: 'issue' | 'mr' } - ): Promise => ipcRenderer.invoke('gitlab:workItemDetails', args), + ) => ipcRenderer.invoke('gitlab:workItemDetails', args), closeMR: ( args: GitLabRepoSelectorArgs & { @@ -133,9 +128,9 @@ export const glApi = { reviewerIds: number[] projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:updateMRReviewers', args), + ) => ipcRenderer.invoke('gitlab:updateMRReviewers', args), - addMRComment: (args: GitLabRepoSelectorArgs & { iid: number; body: string }): Promise => + addMRComment: (args: GitLabRepoSelectorArgs & { iid: number; body: string }) => ipcRenderer.invoke('gitlab:addMRComment', args), addMRInlineComment: ( @@ -144,7 +139,7 @@ export const glApi = { input: unknown projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:addMRInlineComment', args), + ) => ipcRenderer.invoke('gitlab:addMRInlineComment', args), resolveMRDiscussion: ( args: GitLabRepoSelectorArgs & { @@ -152,15 +147,14 @@ export const glApi = { discussionId: string resolved: boolean } - ): Promise => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args), + ) => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args), jobTrace: ( args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown; logExcerpt?: boolean } - ): Promise => ipcRenderer.invoke('gitlab:jobTrace', args), + ) => ipcRenderer.invoke('gitlab:jobTrace', args), - retryJob: ( - args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:retryJob', args), + retryJob: (args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown }) => + ipcRenderer.invoke('gitlab:retryJob', args), workItemByPath: ( args: GitLabRepoSelectorArgs & { @@ -169,5 +163,5 @@ export const glApi = { iid: number type: 'issue' | 'mr' } - ): Promise => ipcRenderer.invoke('gitlab:workItemByPath', args) + ) => ipcRenderer.invoke('gitlab:workItemByPath', args) } diff --git a/src/preload/index.ts b/src/preload/index.ts index ad97a911fe5..27d3ca8e062 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -182,7 +182,7 @@ const api = { mobile: mobileApi, agentStatus: agentStatusApi, speech: speechApi -} +} satisfies PreloadApi if (process.contextIsolated) { try { @@ -193,6 +193,5 @@ if (process.contextIsolated) { } } else { window.electron = electronAPI - // @ts-expect-error (define in dts) window.api = api } From fd33f9b0f93ff03055a05c977496e93a1e2f6573 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:52:36 -0400 Subject: [PATCH 32/94] fix(review-notes): classify send failures and mirrored tabs (#18023) * fix(review-notes): classify send failures * fix(review-notes): honor structured runtime error codes * test(review-notes): use full runtime error envelope * chore: remove unrelated merge formatting * refactor(review-notes): share runtime failure codes * fix(review-notes): classify structured runtime timeouts --- .../editor/ReviewNotesSendMenuContent.tsx | 15 +- .../lib/active-agent-note-send-delivery.ts | 152 +++++++++++ .../lib/active-agent-note-send-diagnostics.ts | 82 ++++++ ...ve-agent-note-send-explicit-target.test.ts | 147 ++++++++++- ...ve-agent-note-send-focused-session.test.ts | 14 +- .../src/lib/active-agent-note-send-result.ts | 57 +++- ...gent-note-send-runtime-error-codes.test.ts | 56 ++++ .../src/lib/active-agent-note-send.ts | 245 ++++++------------ .../src/lib/active-agent-note-target.ts | 5 +- .../active-agent-terminal-send-readiness.ts | 49 ++-- .../store/slices/ui/ui-slice-agent-actions.ts | 13 +- 11 files changed, 617 insertions(+), 218 deletions(-) create mode 100644 src/renderer/src/lib/active-agent-note-send-delivery.ts create mode 100644 src/renderer/src/lib/active-agent-note-send-diagnostics.ts create mode 100644 src/renderer/src/lib/active-agent-note-send-runtime-error-codes.test.ts diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 5fbb92c7636..e0b1d70d5a2 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -124,17 +124,18 @@ export function ReviewNotesSendMenuContent({ toast.message( activeAgentNotesSendFailureMessage(result.status, { - explicitTarget: options.explicitTarget + explicitTarget: options.explicitTarget, + code: result.code }) ) }) - .catch((error) => { - console.error('Failed to send notes:', error) + .catch(() => { + console.error('Failed to send notes:', { code: 'runtime-unverifiable' }) toast.error( - translate( - 'auto.components.editor.ReviewNotesSendMenuContent.f5096c6e4e', - 'Could not send notes.' - ) + activeAgentNotesSendFailureMessage('status-unavailable', { + explicitTarget: options.explicitTarget, + code: 'runtime-unverifiable' + }) ) }) .finally(() => { diff --git a/src/renderer/src/lib/active-agent-note-send-delivery.ts b/src/renderer/src/lib/active-agent-note-send-delivery.ts new file mode 100644 index 00000000000..2d9e88ae507 --- /dev/null +++ b/src/renderer/src/lib/active-agent-note-send-delivery.ts @@ -0,0 +1,152 @@ +import type { RuntimeTerminalSend } from '../../../shared/runtime-types' +import { sanitizeTerminalPasteText } from '@/components/terminal-pane/terminal-bracketed-paste' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { + BRACKETED_PASTE_BEGIN, + BRACKETED_PASTE_END, + POST_PASTE_SUBMIT_DELAY_MS +} from './agent-paste-draft' +import type { ActiveAgentNotesSendResult } from './active-agent-note-send-result' +import { + ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS, + getTerminalAgentSendReadiness, + isRuntimeTerminalNotWritable, + isRuntimeTerminalUnavailable +} from './active-agent-terminal-send-readiness' +import { codeForReadinessStatus, runtimeFailureCode } from './active-agent-note-send-diagnostics' + +const ORCA_DESKTOP_TERMINAL_CLIENT = { id: 'orca-desktop', type: 'desktop' as const } + +export async function sendPromptWithLegacyCombinedSend( + runtimeTarget: Parameters[0], + terminalHandle: string, + prompt: string +): Promise { + try { + const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( + runtimeTarget, + 'terminal.send', + { terminal: terminalHandle, text: prompt, enter: true, client: ORCA_DESKTOP_TERMINAL_CLIENT }, + { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } + ) + return send.accepted + ? { status: 'sent' } + : { status: 'not-writable', code: 'terminal-send-refused' } + } catch (error) { + if (isRuntimeTerminalUnavailable(error)) { + return { + status: 'no-active-terminal', + code: runtimeFailureCode(error) ?? 'runtime-unverifiable' + } + } + if (isRuntimeTerminalNotWritable(error)) { + return { status: 'not-writable', code: 'terminal_not_writable' } + } + throw error + } +} + +export async function sendPromptWithGuardedPasteAndEnter( + runtimeTarget: Parameters[0], + terminalHandle: string, + prompt: string, + options: { allowLegacyFallback: boolean } +): Promise { + const initialAgentStatus = await getTerminalAgentSendReadiness( + runtimeTarget, + terminalHandle, + options + ) + if ( + initialAgentStatus.status !== 'sendable' && + !(initialAgentStatus.status === 'no-agent' && initialAgentStatus.supportsGuardedSend) + ) { + return { + status: initialAgentStatus.status, + code: initialAgentStatus.code ?? codeForReadinessStatus(initialAgentStatus.status) + } + } + + const pastePayload = `${BRACKETED_PASTE_BEGIN}${sanitizeTerminalPasteText(prompt)}${BRACKETED_PASTE_END}` + try { + const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( + runtimeTarget, + 'terminal.send', + { + terminal: terminalHandle, + text: pastePayload, + requireAgentStatus: 'sendable', + client: ORCA_DESKTOP_TERMINAL_CLIENT + }, + { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } + ) + if (!send.accepted) { + if (send.refusedReason === 'permission') { + return { status: 'permission', code: 'terminal-send-permission' } + } + if (send.refusedReason === 'no-agent') { + return { status: 'no-agent', code: 'no-agent' } + } + return { status: 'not-writable', code: 'terminal-send-refused' } + } + } catch (error) { + if (isRuntimeTerminalUnavailable(error)) { + return { + status: 'no-active-terminal', + code: runtimeFailureCode(error) ?? 'runtime-unverifiable' + } + } + if (isRuntimeTerminalNotWritable(error)) { + return { status: 'not-writable', code: 'terminal_not_writable' } + } + throw error + } + + await new Promise((resolve) => setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) + try { + const submitAgentStatus = await getTerminalAgentSendReadiness( + runtimeTarget, + terminalHandle, + options + ) + if ( + submitAgentStatus.status !== 'sendable' && + !(submitAgentStatus.status === 'no-agent' && submitAgentStatus.supportsGuardedSend) + ) { + return { + status: 'partial-submit-failed', + code: submitAgentStatus.code ?? 'submit-readiness-lost' + } + } + } catch (error) { + if (isRuntimeTerminalUnavailable(error)) { + return { + status: 'partial-submit-failed', + code: runtimeFailureCode(error) ?? 'submit-terminal-unavailable' + } + } + throw error + } + + try { + const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( + runtimeTarget, + 'terminal.send', + { + terminal: terminalHandle, + enter: true, + requireAgentStatus: 'sendable', + client: ORCA_DESKTOP_TERMINAL_CLIENT + }, + { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } + ) + return send.accepted + ? { status: 'sent' } + : { status: 'partial-submit-failed', code: 'submit-send-refused' } + } catch (error) { + if (isRuntimeTerminalUnavailable(error) || isRuntimeTerminalNotWritable(error)) { + return { status: 'partial-submit-failed', code: 'submit-send-error' } + } + throw error + } +} diff --git a/src/renderer/src/lib/active-agent-note-send-diagnostics.ts b/src/renderer/src/lib/active-agent-note-send-diagnostics.ts new file mode 100644 index 00000000000..155dec17e06 --- /dev/null +++ b/src/renderer/src/lib/active-agent-note-send-diagnostics.ts @@ -0,0 +1,82 @@ +import type { ActiveTerminalNoteTarget } from './active-agent-note-target' +import type { + ActiveAgentNotesSendFailureCode, + ActiveAgentNotesSendResult +} from './active-agent-note-send-result' +import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' + +export const TERMINAL_RUNTIME_FAILURE_CODES = [ + 'terminal_handle_stale', + 'terminal_exited', + 'terminal_gone', + 'no_active_terminal' +] as const + +export function reportNoteSendFailure( + result: ActiveAgentNotesSendResult, + noteTarget: ActiveTerminalNoteTarget | null +): ActiveAgentNotesSendResult { + if (result.status === 'sent' || result.status === 'empty') { + return result + } + const code = result.code ?? codeForStatus(result.status) + console.warn('[review-notes] send failed', { + code, + status: result.status, + tabId: noteTarget?.tabId, + leafId: noteTarget?.leafId + }) + return { ...result, code } +} + +export function codeForReadinessStatus( + status: 'no-active-terminal' | 'no-agent' | 'permission' | 'status-unavailable' +): ActiveAgentNotesSendFailureCode { + switch (status) { + case 'no-active-terminal': + return 'no-inventory-match' + case 'no-agent': + return 'no-agent' + case 'permission': + return 'agent-permission' + case 'status-unavailable': + return 'status-unavailable' + } +} + +export function runtimeFailureCode(error: unknown): ActiveAgentNotesSendFailureCode | null { + return TERMINAL_RUNTIME_FAILURE_CODES.find((code) => hasRuntimeRpcErrorCode(error, code)) ?? null +} + +export function runtimeFailureFallbackCode(error: unknown): ActiveAgentNotesSendFailureCode { + return isTimeoutError(error) ? 'runtime-timeout' : 'runtime-unverifiable' +} + +function isTimeoutError(error: unknown): boolean { + if (hasRuntimeRpcErrorCode(error, 'runtime_timeout')) { + return true + } + const message = error instanceof Error ? error.message : String(error) + return message.includes('timeout') +} + +function codeForStatus( + status: Exclude +): ActiveAgentNotesSendFailureCode { + switch (status) { + case 'no-active-terminal': + return 'no-inventory-match' + case 'no-agent': + return 'no-agent' + case 'permission': + return 'agent-permission' + case 'status-unavailable': + return 'status-unavailable' + case 'not-ready': + return 'terminal_wait_timeout' + case 'not-writable': + return 'terminal-send-refused' + case 'partial-submit-failed': + return 'submit-send-error' + } +} diff --git a/src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts b/src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts index 96eb0306a40..098dd649f29 100644 --- a/src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts +++ b/src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts @@ -172,7 +172,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'status-unavailable' }) + ).resolves.toEqual({ status: 'status-unavailable', code: 'status-unavailable' }) expect(methods).toEqual(['terminal.list', 'terminal.agentStatus']) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( @@ -275,7 +275,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'permission' }) + ).resolves.toEqual({ status: 'permission', code: 'agent-permission' }) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( expect.anything(), @@ -359,7 +359,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'no-agent' }) + ).resolves.toEqual({ status: 'no-agent', code: 'no-agent' }) expect(methods).toEqual(['terminal.list', 'terminal.agentStatus', 'terminal.send']) }) @@ -401,7 +401,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'not-writable' }) + ).resolves.toEqual({ status: 'not-writable', code: 'terminal-send-refused' }) const sendCalls = testState.callRuntimeRpc.mock.calls.filter( (call) => call[1] === 'terminal.send' @@ -454,7 +454,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'permission' }) + ).resolves.toEqual({ status: 'permission', code: 'terminal-send-permission' }) const sendCalls = testState.callRuntimeRpc.mock.calls.filter( (call) => call[1] === 'terminal.send' @@ -508,7 +508,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'partial-submit-failed' }) + ).resolves.toEqual({ status: 'partial-submit-failed', code: 'submit-readiness-lost' }) const sendCalls = testState.callRuntimeRpc.mock.calls.filter( (call) => call[1] === 'terminal.send' @@ -562,7 +562,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'partial-submit-failed' }) + ).resolves.toEqual({ status: 'partial-submit-failed', code: 'submit-send-refused' }) }) it('maps explicit target guarded Enter permission refusal to partial-submit-failed', async () => { @@ -620,7 +620,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'partial-submit-failed' }) + ).resolves.toEqual({ status: 'partial-submit-failed', code: 'submit-send-refused' }) }) it('uses selected-target failure wording for explicit note targets', () => { @@ -647,8 +647,90 @@ describe('active agent note send', () => { expect(activeAgentNotesSendFailureMessage('partial-submit-failed')).toBe( 'The notes may already be pasted in the active terminal, but Orca could not submit them.' ) + expect( + activeAgentNotesSendFailureMessage('no-active-terminal', { + explicitTarget: true, + code: 'no-inventory-match' + }) + ).toBe('The selected terminal is no longer available. (no-inventory-match)') }) + it.each(['terminal_handle_stale', 'terminal_exited', 'terminal_gone'] as const)( + 'returns and logs the runtime terminal failure code %s without note contents', + async (runtimeCode) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + testState.callRuntimeRpc.mockImplementation(async (_target, method) => { + if (method === 'terminal.list') { + return { + terminals: [ + { + handle: 'term-runtime-failure', + worktreeId: 'wt-1', + worktreePath: '/repo', + branch: 'main', + tabId: 'tab-9', + leafId: OTHER_LEAF_ID, + title: 'Codex', + connected: true, + writable: true, + lastOutputAt: 1, + preview: '' + } + ], + totalCount: 1, + truncated: false + } + } + if (method === 'terminal.agentStatus') { + throw new Error(runtimeCode) + } + throw new Error(`unexpected method ${method}`) + }) + + await expect( + sendNotesToActiveAgentSession({ + worktreeId: 'wt-1', + prompt: 'private note contents', + noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } + }) + ).resolves.toEqual({ status: 'no-active-terminal', code: runtimeCode }) + + expect(warn).toHaveBeenCalledWith('[review-notes] send failed', { + code: runtimeCode, + status: 'no-active-terminal', + tabId: 'tab-9', + leafId: OTHER_LEAF_ID + }) + expect(JSON.stringify(warn.mock.calls)).not.toContain('private note contents') + warn.mockRestore() + } + ) + + it.each([ + ['remote connection closed at /private/workspace', 'runtime-unverifiable'], + ['runtime request timeout', 'runtime-timeout'] + ] as const)( + 'classifies terminal inventory failure without logging raw error details: %s', + async (message, code) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + testState.callRuntimeRpc.mockRejectedValue(new Error(message)) + + await expect( + sendNotesToActiveAgentSession({ + worktreeId: 'wt-1', + prompt: 'private note contents', + noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } + }) + ).resolves.toEqual({ status: 'status-unavailable', code }) + + const logged = JSON.stringify(warn.mock.calls) + expect(logged).toContain(code) + expect(logged).not.toContain(message) + expect(logged).not.toContain('private note contents') + warn.mockRestore() + } + ) + it('returns no-active-terminal when the explicit note target is absent from the runtime list', async () => { testState.callRuntimeRpc.mockImplementation(async (_target, method) => { if (method === 'terminal.list') { @@ -681,7 +763,7 @@ describe('active agent note send', () => { prompt: 'notes', noteTarget: { tabId: 'tab-1', leafId: OTHER_LEAF_ID } }) - ).resolves.toEqual({ status: 'no-active-terminal' }) + ).resolves.toEqual({ status: 'no-active-terminal', code: 'no-inventory-match' }) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( expect.anything(), @@ -690,4 +772,51 @@ describe('active agent note send', () => { expect.anything() ) }) + + it('matches mirrored renderer tab IDs to host runtime tab IDs', async () => { + testState.callRuntimeRpc.mockImplementation(async (_target, method, params) => { + if (method === 'terminal.list') { + return { + terminals: [ + { + handle: 'term-mirrored', + worktreeId: 'wt-1', + worktreePath: '/repo', + branch: 'main', + tabId: 'tab-9', + leafId: OTHER_LEAF_ID, + title: 'Codex', + connected: true, + writable: true, + lastOutputAt: 1, + preview: '' + } + ], + totalCount: 1, + truncated: false + } + } + if (method === 'terminal.agentStatus') { + return { agentStatus: { handle: 'term-mirrored', isRunningAgent: true, status: 'working' } } + } + if (method === 'terminal.send') { + return { + send: { + handle: 'term-mirrored', + accepted: true, + bytesWritten: typeof params.text === 'string' ? params.text.length : 1 + } + } + } + throw new Error(`unexpected method ${method}`) + }) + + await expect( + sendNotesToActiveAgentSession({ + worktreeId: 'wt-1', + prompt: 'notes', + noteTarget: { tabId: 'web-terminal-tab-9', leafId: OTHER_LEAF_ID } + }) + ).resolves.toEqual({ status: 'sent' }) + }) }) diff --git a/src/renderer/src/lib/active-agent-note-send-focused-session.test.ts b/src/renderer/src/lib/active-agent-note-send-focused-session.test.ts index 76fcf49c4e6..118bd134df5 100644 --- a/src/renderer/src/lib/active-agent-note-send-focused-session.test.ts +++ b/src/renderer/src/lib/active-agent-note-send-focused-session.test.ts @@ -187,7 +187,7 @@ describe('active agent note send', () => { await expect( sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' }) - ).resolves.toEqual({ status: 'permission' }) + ).resolves.toEqual({ status: 'permission', code: 'terminal-send-permission' }) }) it('keeps active-focused sends compatible when an older runtime lacks agentStatus', async () => { @@ -286,7 +286,7 @@ describe('active agent note send', () => { await expect( sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' }) - ).resolves.toEqual({ status: 'no-agent' }) + ).resolves.toEqual({ status: 'no-agent', code: 'no-agent' }) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( expect.anything(), @@ -330,7 +330,7 @@ describe('active agent note send', () => { await expect( sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' }) - ).resolves.toEqual({ status: 'not-ready' }) + ).resolves.toEqual({ status: 'not-ready', code: 'terminal_wait_timeout' }) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( expect.anything(), @@ -382,7 +382,7 @@ describe('active agent note send', () => { await expect( sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' }) - ).resolves.toEqual({ status: 'no-active-terminal' }) + ).resolves.toEqual({ status: 'no-active-terminal', code: 'terminal_wait_not_running' }) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( expect.anything(), @@ -435,7 +435,7 @@ describe('active agent note send', () => { await expect( sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' }) - ).resolves.toEqual({ status: 'permission' }) + ).resolves.toEqual({ status: 'permission', code: 'terminal_wait_blocked' }) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( expect.anything(), @@ -495,7 +495,7 @@ describe('active agent note send', () => { await expect( sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' }) - ).resolves.toEqual({ status: 'permission' }) + ).resolves.toEqual({ status: 'permission', code: 'agent-permission' }) expect(statusChecks).toBe(2) expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( @@ -512,7 +512,7 @@ describe('active agent note send', () => { await expect( sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' }) - ).resolves.toEqual({ status: 'no-active-terminal' }) + ).resolves.toEqual({ status: 'no-active-terminal', code: 'no-note-target' }) expect(testState.callRuntimeRpc).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/active-agent-note-send-result.ts b/src/renderer/src/lib/active-agent-note-send-result.ts index 3c292d497e4..388037d7226 100644 --- a/src/renderer/src/lib/active-agent-note-send-result.ts +++ b/src/renderer/src/lib/active-agent-note-send-result.ts @@ -9,39 +9,76 @@ export type ActiveAgentNotesSendStatus = | 'not-writable' | 'partial-submit-failed' +export type ActiveAgentNotesSendFailureCode = + | 'empty' + | 'no-note-target' + | 'no-inventory-match' + | 'terminal_handle_stale' + | 'terminal_exited' + | 'terminal_gone' + | 'no_active_terminal' + | 'terminal_wait_not_running' + | 'terminal_wait_blocked' + | 'terminal_wait_unsatisfied' + | 'terminal_wait_timeout' + | 'no-agent' + | 'agent-permission' + | 'status-unavailable' + | 'terminal-send-permission' + | 'terminal-send-refused' + | 'terminal_not_writable' + | 'submit-readiness-lost' + | 'submit-terminal-unavailable' + | 'submit-send-refused' + | 'submit-send-error' + | 'runtime-unverifiable' + | 'runtime-timeout' + export type ActiveAgentNotesSendResult = { status: ActiveAgentNotesSendStatus + code?: ActiveAgentNotesSendFailureCode } export function activeAgentNotesSendFailureMessage( status: ActiveAgentNotesSendStatus, - options: { explicitTarget?: boolean } = {} + options: { explicitTarget?: boolean; code?: ActiveAgentNotesSendFailureCode } = {} ): string { const target = options.explicitTarget ? 'selected' : 'active' + let message: string switch (status) { case 'empty': - return 'No notes to send.' + message = 'No notes to send.' + break case 'no-active-terminal': - return options.explicitTarget + message = options.explicitTarget ? 'The selected terminal is no longer available.' : 'Open the agent terminal in this worktree, then send the notes again.' + break case 'no-agent': - return `The ${target} terminal is not a recognized agent session.` + message = `The ${target} terminal is not a recognized agent session.` + break case 'permission': - return options.explicitTarget + message = options.explicitTarget ? 'The selected agent needs permission.' : 'The active agent needs permission.' + break case 'status-unavailable': - return `The ${target} agent status could not be verified.` + message = `The ${target} agent status could not be verified.` + break case 'not-ready': - return `The ${target} agent was not ready for input yet.` + message = `The ${target} agent was not ready for input yet.` + break case 'not-writable': - return `The ${target} terminal did not accept the notes.` + message = `The ${target} terminal did not accept the notes.` + break case 'partial-submit-failed': - return options.explicitTarget + message = options.explicitTarget ? 'The notes may already be pasted in the selected terminal, but Orca could not submit them.' : 'The notes may already be pasted in the active terminal, but Orca could not submit them.' + break case 'sent': - return '' + message = '' + break } + return options.code ? `${message} (${options.code})` : message } diff --git a/src/renderer/src/lib/active-agent-note-send-runtime-error-codes.test.ts b/src/renderer/src/lib/active-agent-note-send-runtime-error-codes.test.ts new file mode 100644 index 00000000000..baa41a7d139 --- /dev/null +++ b/src/renderer/src/lib/active-agent-note-send-runtime-error-codes.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { hasRuntimeRpcErrorCode, RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' +import { + isRuntimeTerminalNotWritable, + isRuntimeTerminalUnavailable, + isRuntimeTimeout +} from './active-agent-terminal-send-readiness' +import { + runtimeFailureCode, + runtimeFailureFallbackCode +} from './active-agent-note-send-diagnostics' + +function runtimeError(code: string): RuntimeRpcCallError { + return new RuntimeRpcCallError({ + id: 'test-runtime-error', + ok: false, + error: { code, message: 'The terminal is no longer available' } + }) +} + +describe('active agent note runtime error codes', () => { + it.each(['terminal_handle_stale', 'terminal_exited', 'terminal_gone', 'no_active_terminal'])( + 'uses structured %s codes even with human-readable messages', + (code) => { + const error = runtimeError(code) + + expect(isRuntimeTerminalUnavailable(error)).toBe(true) + expect(runtimeFailureCode(error)).toBe(code) + } + ) + + it('uses structured terminal_not_writable with a human-readable message', () => { + const error = runtimeError('terminal_not_writable') + + expect(isRuntimeTerminalNotWritable(error)).toBe(true) + expect(hasRuntimeRpcErrorCode(error, 'terminal_not_writable')).toBe(true) + }) + + it('uses structured runtime_timeout with a human-readable message', () => { + const error = new RuntimeRpcCallError({ + id: 'test-runtime-timeout', + ok: false, + error: { code: 'runtime_timeout', message: 'Timed out waiting for the remote runtime.' } + }) + + expect(isRuntimeTimeout(error)).toBe(true) + expect(runtimeFailureFallbackCode(error)).toBe('runtime-timeout') + }) + + it('retains support for transport-rewrapped error tokens', () => { + const error = new Error("Error invoking remote method 'terminal.send': terminal_gone") + + expect(isRuntimeTerminalUnavailable(error)).toBe(true) + expect(runtimeFailureCode(error)).toBe('terminal_gone') + }) +}) diff --git a/src/renderer/src/lib/active-agent-note-send.ts b/src/renderer/src/lib/active-agent-note-send.ts index 97bb2418132..48cced68feb 100644 --- a/src/renderer/src/lib/active-agent-note-send.ts +++ b/src/renderer/src/lib/active-agent-note-send.ts @@ -1,26 +1,26 @@ -import type { RuntimeTerminalSend, RuntimeTerminalWait } from '../../../shared/runtime-types' -import { sanitizeTerminalPasteText } from '@/components/terminal-pane/terminal-bracketed-paste' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' -import { - findActiveRuntimeTerminal, - getActiveTerminalNoteTarget, - type ActiveTerminalNoteTarget -} from './active-agent-note-target' -import { - BRACKETED_PASTE_BEGIN, - BRACKETED_PASTE_END, - POST_PASTE_SUBMIT_DELAY_MS -} from './agent-paste-draft' +import { findActiveRuntimeTerminal, getActiveTerminalNoteTarget } from './active-agent-note-target' +import type { ActiveTerminalNoteTarget } from './active-agent-note-target' import type { ActiveAgentNotesSendResult } from './active-agent-note-send-result' import { ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS, getTerminalAgentSendReadiness, - isRuntimeTerminalNotWritable, isRuntimeTerminalUnavailable, isRuntimeTimeout } from './active-agent-terminal-send-readiness' +import { + codeForReadinessStatus, + reportNoteSendFailure, + runtimeFailureCode, + runtimeFailureFallbackCode +} from './active-agent-note-send-diagnostics' +import { + sendPromptWithGuardedPasteAndEnter, + sendPromptWithLegacyCombinedSend +} from './active-agent-note-send-delivery' export { getActiveAgentNoteTarget, @@ -34,11 +34,24 @@ export { type ActiveAgentNotesSendResult, type ActiveAgentNotesSendStatus } from './active-agent-note-send-result' - const ACTIVE_AGENT_SEND_TIMEOUT_MS = 8000 -const ORCA_DESKTOP_TERMINAL_CLIENT = { id: 'orca-desktop', type: 'desktop' as const } -export async function sendNotesToActiveAgentSession({ +export async function sendNotesToActiveAgentSession(args: { + worktreeId: string + prompt: string + noteTarget?: ActiveTerminalNoteTarget + timeoutMs?: number +}): Promise { + try { + return await sendNotesToActiveAgentSessionInternal(args) + } catch (error) { + return reportNoteSendFailure( + { status: 'status-unavailable', code: runtimeFailureFallbackCode(error) }, + args.noteTarget ?? null + ) + } +} +async function sendNotesToActiveAgentSessionInternal({ worktreeId, prompt, noteTarget: explicitNoteTarget, @@ -51,20 +64,13 @@ export async function sendNotesToActiveAgentSession({ }): Promise { const trimmedPrompt = prompt.trim() if (!trimmedPrompt) { - return { status: 'empty' } + return { status: 'empty', code: 'empty' } } - const state = useAppStore.getState() - // Why: an explicit target lets the notes dropdown address ANY running agent of - // the worktree, not just the focused pane; omitted, fall back to the focused - // active terminal so existing callers keep their behavior. Routing below still - // resolves the worktree's owner host, so explicit targets stay SSH/remote-correct. const noteTarget = explicitNoteTarget ?? getActiveTerminalNoteTarget(state, worktreeId) if (!noteTarget) { - return { status: 'no-active-terminal' } + return reportNoteSendFailure({ status: 'no-active-terminal', code: 'no-note-target' }, null) } - // Route by the worktree's owner host so the agent terminal is found and driven - // on the host that actually runs it, not on the focused runtime. const runtimeTarget = getActiveRuntimeTarget( getSettingsForWorktreeRuntimeOwner(state, worktreeId) ) @@ -75,21 +81,30 @@ export async function sendNotesToActiveAgentSession({ ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS ) if (!terminal) { - return { status: 'no-active-terminal' } + return reportNoteSendFailure( + { status: 'no-active-terminal', code: 'no-inventory-match' }, + noteTarget + ) } - if (explicitNoteTarget) { - return await sendPromptToExplicitAgentTarget(runtimeTarget, terminal.handle, trimmedPrompt) + return reportNoteSendFailure( + await sendPromptToExplicitAgentTarget(runtimeTarget, terminal.handle, trimmedPrompt), + noteTarget + ) } - const effectiveTimeoutMs = timeoutMs ?? ACTIVE_AGENT_SEND_TIMEOUT_MS const initialAgentStatus = await getTerminalAgentSendReadiness(runtimeTarget, terminal.handle, { allowLegacyFallback: true }) if (initialAgentStatus.status !== 'sendable') { - return { status: initialAgentStatus.status } + return reportNoteSendFailure( + { + status: initialAgentStatus.status, + code: initialAgentStatus.code ?? codeForReadinessStatus(initialAgentStatus.status) + }, + noteTarget + ) } - try { const { wait } = await callRuntimeRpc<{ wait: RuntimeTerminalWait }>( runtimeTarget, @@ -98,160 +113,64 @@ export async function sendNotesToActiveAgentSession({ { timeoutMs: effectiveTimeoutMs + 5000 } ) if (wait.status !== 'running') { - return { status: 'no-active-terminal' } + return reportNoteSendFailure( + { status: 'no-active-terminal', code: 'terminal_wait_not_running' }, + noteTarget + ) } if (wait.blockedReason) { - return { status: 'permission' } + return reportNoteSendFailure( + { status: 'permission', code: 'terminal_wait_blocked' }, + noteTarget + ) } if (!wait.satisfied) { - return { status: 'not-ready' } + return reportNoteSendFailure( + { status: 'not-ready', code: 'terminal_wait_unsatisfied' }, + noteTarget + ) } } catch (error) { if (isRuntimeTerminalUnavailable(error)) { - return { status: 'no-active-terminal' } + return reportNoteSendFailure( + { status: 'no-active-terminal', code: runtimeFailureCode(error) ?? 'runtime-unverifiable' }, + noteTarget + ) } if (isRuntimeTimeout(error)) { - return { status: 'not-ready' } + return reportNoteSendFailure( + { status: 'not-ready', code: 'terminal_wait_timeout' }, + noteTarget + ) } throw error } - const finalAgentStatus = await getTerminalAgentSendReadiness(runtimeTarget, terminal.handle, { allowLegacyFallback: true }) if (finalAgentStatus.status !== 'sendable') { - return { status: finalAgentStatus.status } + return reportNoteSendFailure( + { + status: finalAgentStatus.status, + code: finalAgentStatus.code ?? codeForReadinessStatus(finalAgentStatus.status) + }, + noteTarget + ) } if (finalAgentStatus.supportsGuardedSend) { - return await sendPromptWithGuardedPasteAndEnter(runtimeTarget, terminal.handle, trimmedPrompt, { - allowLegacyFallback: false - }) - } - - // Why: protocol-compatible older SSH runtimes do not know the guarded send - // option. They already passed terminal.wait + legacy isRunningAgent checks, - // so preserve the old active-focused send path for remote compatibility. - return await sendPromptWithLegacyCombinedSend(runtimeTarget, terminal.handle, trimmedPrompt) -} - -async function sendPromptWithLegacyCombinedSend( - runtimeTarget: ReturnType, - terminalHandle: string, - prompt: string -): Promise { - try { - const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( - runtimeTarget, - 'terminal.send', - { - terminal: terminalHandle, - text: prompt, - enter: true, - client: ORCA_DESKTOP_TERMINAL_CLIENT - }, - { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } + return reportNoteSendFailure( + await sendPromptWithGuardedPasteAndEnter(runtimeTarget, terminal.handle, trimmedPrompt, { + allowLegacyFallback: false + }), + noteTarget ) - return send.accepted ? { status: 'sent' } : { status: 'not-writable' } - } catch (error) { - if (isRuntimeTerminalUnavailable(error)) { - return { status: 'no-active-terminal' } - } - if (isRuntimeTerminalNotWritable(error)) { - return { status: 'not-writable' } - } - throw error - } -} - -async function sendPromptWithGuardedPasteAndEnter( - runtimeTarget: ReturnType, - terminalHandle: string, - prompt: string, - options: { allowLegacyFallback: boolean } -): Promise { - const initialAgentStatus = await getTerminalAgentSendReadiness(runtimeTarget, terminalHandle, { - allowLegacyFallback: options.allowLegacyFallback - }) - // Why: the readiness probe and write guard can observe different transient - // title/process snapshots; the guard owns the bounded no-agent recheck. - if ( - initialAgentStatus.status !== 'sendable' && - !(initialAgentStatus.status === 'no-agent' && initialAgentStatus.supportsGuardedSend) - ) { - return { status: initialAgentStatus.status } } - const pastePayload = `${BRACKETED_PASTE_BEGIN}${sanitizeTerminalPasteText(prompt)}${BRACKETED_PASTE_END}` - try { - const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( - runtimeTarget, - 'terminal.send', - { - terminal: terminalHandle, - text: pastePayload, - requireAgentStatus: 'sendable', - client: ORCA_DESKTOP_TERMINAL_CLIENT - }, - { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } - ) - if (!send.accepted) { - if (send.refusedReason === 'permission') { - return { status: 'permission' } - } - if (send.refusedReason === 'no-agent') { - return { status: 'no-agent' } - } - return { status: 'not-writable' } - } - } catch (error) { - if (isRuntimeTerminalUnavailable(error)) { - return { status: 'no-active-terminal' } - } - if (isRuntimeTerminalNotWritable(error)) { - return { status: 'not-writable' } - } - throw error - } - - await new Promise((resolve) => setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) - - try { - const submitAgentStatus = await getTerminalAgentSendReadiness(runtimeTarget, terminalHandle, { - allowLegacyFallback: options.allowLegacyFallback - }) - if ( - submitAgentStatus.status !== 'sendable' && - !(submitAgentStatus.status === 'no-agent' && submitAgentStatus.supportsGuardedSend) - ) { - return { status: 'partial-submit-failed' } - } - } catch (error) { - if (isRuntimeTerminalUnavailable(error)) { - return { status: 'partial-submit-failed' } - } - throw error - } - - try { - const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( - runtimeTarget, - 'terminal.send', - { - terminal: terminalHandle, - enter: true, - requireAgentStatus: 'sendable', - client: ORCA_DESKTOP_TERMINAL_CLIENT - }, - { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } - ) - return send.accepted ? { status: 'sent' } : { status: 'partial-submit-failed' } - } catch (error) { - if (isRuntimeTerminalUnavailable(error) || isRuntimeTerminalNotWritable(error)) { - return { status: 'partial-submit-failed' } - } - throw error - } + return reportNoteSendFailure( + await sendPromptWithLegacyCombinedSend(runtimeTarget, terminal.handle, trimmedPrompt), + noteTarget + ) } async function sendPromptToExplicitAgentTarget( diff --git a/src/renderer/src/lib/active-agent-note-target.ts b/src/renderer/src/lib/active-agent-note-target.ts index dbacc97e4c1..2b114166d1f 100644 --- a/src/renderer/src/lib/active-agent-note-target.ts +++ b/src/renderer/src/lib/active-agent-note-target.ts @@ -1,4 +1,5 @@ import type { RuntimeTerminalListResult } from '../../../shared/runtime-types' +import { toHostSessionTabId } from '../../../shared/terminal-surface-id' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry @@ -174,9 +175,11 @@ export async function findActiveRuntimeTerminal( }, { timeoutMs } ) + // Why: paired renderer tabs wrap the host id with `web-terminal-*`. + const runtimeTabId = toHostSessionTabId(noteTarget.tabId) return ( terminals.find( - (terminal) => terminal.tabId === noteTarget.tabId && terminal.leafId === noteTarget.leafId + (terminal) => terminal.tabId === runtimeTabId && terminal.leafId === noteTarget.leafId ) ?? null ) } diff --git a/src/renderer/src/lib/active-agent-terminal-send-readiness.ts b/src/renderer/src/lib/active-agent-terminal-send-readiness.ts index fa049398e8b..b432366527f 100644 --- a/src/renderer/src/lib/active-agent-terminal-send-readiness.ts +++ b/src/renderer/src/lib/active-agent-terminal-send-readiness.ts @@ -1,6 +1,12 @@ import type { RuntimeTerminalAgentStatus } from '../../../shared/runtime-types' +import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' +import type { ActiveAgentNotesSendFailureCode } from './active-agent-note-send-result' import { callRuntimeRpc, RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' import type { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + runtimeFailureCode, + TERMINAL_RUNTIME_FAILURE_CODES +} from './active-agent-note-send-diagnostics' export const ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS = 15000 @@ -14,6 +20,7 @@ export type TerminalAgentSendReadiness = export type TerminalAgentSendReadinessResult = { status: TerminalAgentSendReadiness supportsGuardedSend: boolean + code?: ActiveAgentNotesSendFailureCode } export async function getTerminalAgentSendReadiness( @@ -44,13 +51,14 @@ export async function getTerminalAgentSendReadiness( } // Why: active-focused sends still wait for tui-idle, preserving old // runtime compatibility without immediate selected-target risk. - return { - status: await getLegacyTerminalAgentSendStatus(runtimeTarget, terminalHandle), - supportsGuardedSend: false - } + return await getLegacyTerminalAgentSendStatus(runtimeTarget, terminalHandle) } if (isRuntimeTerminalUnavailable(error)) { - return { status: 'no-active-terminal', supportsGuardedSend: false } + return { + status: 'no-active-terminal', + supportsGuardedSend: false, + code: runtimeTerminalUnavailableCode(error) + } } throw error } @@ -59,7 +67,7 @@ export async function getTerminalAgentSendReadiness( async function getLegacyTerminalAgentSendStatus( runtimeTarget: ReturnType, terminalHandle: string -): Promise { +): Promise { try { const { isRunningAgent } = await callRuntimeRpc<{ isRunningAgent: boolean }>( runtimeTarget, @@ -67,31 +75,38 @@ async function getLegacyTerminalAgentSendStatus( { terminal: terminalHandle }, { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } ) - return isRunningAgent ? 'sendable' : 'no-agent' + return { + status: isRunningAgent ? 'sendable' : 'no-agent', + supportsGuardedSend: false + } } catch (error) { if (isRuntimeTerminalUnavailable(error)) { - return 'no-active-terminal' + return { + status: 'no-active-terminal', + supportsGuardedSend: false, + code: runtimeTerminalUnavailableCode(error) + } } throw error } } +function runtimeTerminalUnavailableCode(error: unknown): ActiveAgentNotesSendFailureCode { + return runtimeFailureCode(error) ?? 'runtime-unverifiable' +} + export function isRuntimeTimeout(error: unknown): boolean { + if (hasRuntimeRpcErrorCode(error, 'runtime_timeout')) { + return true + } const message = error instanceof Error ? error.message : String(error) return message.includes('timeout') } export function isRuntimeTerminalUnavailable(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return ( - message.includes('terminal_handle_stale') || - message.includes('terminal_exited') || - message.includes('terminal_gone') || - message.includes('no_active_terminal') - ) + return TERMINAL_RUNTIME_FAILURE_CODES.some((code) => hasRuntimeRpcErrorCode(error, code)) } export function isRuntimeTerminalNotWritable(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return message.includes('terminal_not_writable') + return hasRuntimeRpcErrorCode(error, 'terminal_not_writable') } diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts index 9fa15ffb81e..ebd0f016b3f 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts @@ -149,9 +149,11 @@ export function createUiAgentActions( worktreeId: mode.worktreeId, prompt: mode.prompt, noteTarget: { tabId: target.tabId, leafId: target.leafId } - }).catch((error) => { - console.error('Failed to send notes to sidebar agent target:', error) - return { status: 'no-active-terminal' as const } + }).catch(() => { + console.error('Failed to send notes to sidebar agent target:', { + code: 'runtime-unverifiable' + }) + return { status: 'status-unavailable' as const, code: 'runtime-unverifiable' as const } }) const stillCurrent = (): boolean => { @@ -160,7 +162,10 @@ export function createUiAgentActions( } if (result.status !== 'sent') { - const message = activeAgentNotesSendFailureMessage(result.status, { explicitTarget: true }) + const message = activeAgentNotesSendFailureMessage(result.status, { + explicitTarget: true, + code: result.code + }) set((s) => s.agentSendPopoverTargetMode?.id === mode.id && s.agentSendPopoverTargetMode.instanceId === mode.instanceId From 3f5c54332d8832ca2ed54a93d323e7f5a019f909 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:00:52 -0700 Subject: [PATCH 33/94] fix(github-project): sort and group empty field values last in both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compareSort early-returned 1 for a missing value — before the trailing DESC flip — but expressed the same idea as `cmp = 1` for an empty users/labels list, which that line then negated. Descending order therefore scattered empty cells across both ends of the table. getFieldValueForGrouping had the matching defect: an empty list fell through to deriveStringValue and produced a blank-label group that the header renders as the literal "All". Both paths now share one predicate, which also covers `text: ''` and `date: ''` — reachable because the view normalizer maps a null GitHub text/date to the empty string. Co-authored-by: kaluli123123 <295758798+kaluli123123@users.noreply.github.com> --- .../github-project/group-sort.test.ts | 106 ++++++++++++++++++ src/shared/github/project-group-sort.ts | 62 +++++----- 2 files changed, 137 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/components/github-project/group-sort.test.ts b/src/renderer/src/components/github-project/group-sort.test.ts index cd5e77738bc..57b96267bae 100644 --- a/src/renderer/src/components/github-project/group-sort.test.ts +++ b/src/renderer/src/components/github-project/group-sort.test.ts @@ -33,6 +33,27 @@ const iterationField: GitHubProjectField = { ] } +const assigneesField: GitHubProjectField = { + kind: 'field', + id: 'F_assignees', + name: 'Assignees', + dataType: 'ASSIGNEES' +} + +const labelsField: GitHubProjectField = { + kind: 'field', + id: 'F_labels', + name: 'Labels', + dataType: 'LABELS' +} + +const textField: GitHubProjectField = { + kind: 'field', + id: 'F_text', + name: 'Notes', + dataType: 'TEXT' +} + function makeRow( id: string, position: number, @@ -206,6 +227,66 @@ describe('sortRows', () => { expect(sorted.map((r) => r.id)).toEqual(['rHas', 'rEmpty']) }) + it('sorts an empty user list last in both directions, like a missing value', () => { + // Why: the DESC flip negated the empty branch, sending unassigned rows to the top. + const rows = [ + makeRow('empty-list', 0, { + F_assignees: { kind: 'users', fieldId: 'F_assignees', users: [] } + }), + makeRow('alice', 1, { + F_assignees: { + kind: 'users', + fieldId: 'F_assignees', + users: [{ login: 'alice', name: null, avatarUrl: null }] + } + }), + makeRow('no-value', 2, {}) + ] + + for (const direction of ['ASC', 'DESC'] as const) { + const view = makeView(assigneesField, { direction, field: assigneesField }) + const sorted = sortRows(makeTable(view, rows), rows) + expect(sorted.map((r) => r.id)).toEqual(['alice', 'empty-list', 'no-value']) + } + }) + + it('sorts an empty label list last in both directions, like a missing value', () => { + const rows = [ + makeRow('empty-list', 0, { + F_labels: { kind: 'labels', fieldId: 'F_labels', labels: [] } + }), + makeRow('bug', 1, { + F_labels: { + kind: 'labels', + fieldId: 'F_labels', + labels: [{ name: 'bug', color: 'ff0000' }] + } + }), + makeRow('no-value', 2, {}) + ] + + for (const direction of ['ASC', 'DESC'] as const) { + const view = makeView(labelsField, { direction, field: labelsField }) + const sorted = sortRows(makeTable(view, rows), rows) + expect(sorted.map((r) => r.id)).toEqual(['bug', 'empty-list', 'no-value']) + } + }) + + it('sorts a blank text value last in both directions, like a missing value', () => { + // Why reachable: the normalizer turns a null GitHub text/date into ''. + const rows = [ + makeRow('blank', 0, { F_text: { kind: 'text', fieldId: 'F_text', text: '' } }), + makeRow('alpha', 1, { F_text: { kind: 'text', fieldId: 'F_text', text: 'alpha' } }), + makeRow('no-value', 2, {}) + ] + + for (const direction of ['ASC', 'DESC'] as const) { + const view = makeView(textField, { direction, field: textField }) + const sorted = sortRows(makeTable(view, rows), rows) + expect(sorted.map((r) => r.id)).toEqual(['alpha', 'blank', 'no-value']) + } + }) + it('keeps sort fallback finite when row positions are absent', () => { const view = makeView(singleSelectField) const rows = [ @@ -220,6 +301,31 @@ describe('sortRows', () => { }) describe('groupRows', () => { + it('groups a present-but-empty user list with the missing-value rows', () => { + // Why: an empty list fell through to a blank-label group, which renders as "All". + const view = { ...makeView(assigneesField), groupByFields: [assigneesField] } + const rows = [ + makeRow('empty-list', 0, { + F_assignees: { kind: 'users', fieldId: 'F_assignees', users: [] } + }), + makeRow('alice', 1, { + F_assignees: { + kind: 'users', + fieldId: 'F_assignees', + users: [{ login: 'alice', name: null, avatarUrl: null }] + } + }), + makeRow('no-value', 2, {}) + ] + + const groups = groupRows(makeTable(view, rows), rows) + + expect(groups.map((group) => [group.label, group.rows.map((r) => r.id)])).toEqual([ + ['alice', ['alice']], + ['No Assignees', ['empty-list', 'no-value']] + ]) + }) + it('places the empty group last', () => { const view = { ...makeView(singleSelectField), diff --git a/src/shared/github/project-group-sort.ts b/src/shared/github/project-group-sort.ts index 707fbfb35e4..0b91d92b267 100644 --- a/src/shared/github/project-group-sort.ts +++ b/src/shared/github/project-group-sort.ts @@ -24,6 +24,29 @@ export type ProjectGroup = { const EMPTY_GROUP_KEY = '__empty__' +type ProjectFieldValue = GitHubProjectRow['fieldValuesByFieldId'][string] + +/** False for anything that renders as an empty cell — absent, or present with a blank payload. */ +function hasNonEmptyFieldValue(value: ProjectFieldValue | undefined): boolean { + if (!value) { + return false + } + switch (value.kind) { + case 'users': + return Boolean(value.users[0]?.login) + case 'labels': + return Boolean(value.labels[0]?.name) + case 'text': + return value.text.trim().length > 0 + case 'date': + return value.date.trim().length > 0 + case 'iteration': + case 'number': + case 'single-select': + return true + } +} + // Why: use a finite sentinel instead of Infinity so subtractions in the sort // comparator stay finite. `Infinity - Infinity` is NaN, which makes // Array.sort's behavior implementation-defined and skips later tie-breaks. @@ -35,7 +58,7 @@ function getFieldValueForGrouping( field: GitHubProjectField ): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } { const value = row.fieldValuesByFieldId[field.id] - if (!value) { + if (!hasNonEmptyFieldValue(value)) { return { key: EMPTY_GROUP_KEY, label: labelForEmpty(field), @@ -144,14 +167,11 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje const field = sort.field const aValue = a.fieldValuesByFieldId[field.id] const bValue = b.fieldValuesByFieldId[field.id] - if (!aValue && !bValue) { - return 0 - } - if (!aValue) { - return 1 - } - if (!bValue) { - return -1 + // Why: return before the trailing DESC flip so empty sorts last in both directions. + const aFilled = hasNonEmptyFieldValue(aValue) + const bFilled = hasNonEmptyFieldValue(bValue) + if (!aFilled || !bFilled) { + return aFilled === bFilled ? 0 : aFilled ? -1 : 1 } let cmp = 0 @@ -182,29 +202,9 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje } else if (aValue.kind === 'text' && bValue.kind === 'text') { cmp = aValue.text.localeCompare(bValue.text) } else if (aValue.kind === 'users' && bValue.kind === 'users') { - const aLogin = aValue.users[0]?.login ?? '' - const bLogin = bValue.users[0]?.login ?? '' - if (!aLogin && !bLogin) { - cmp = 0 - } else if (!aLogin) { - cmp = 1 - } else if (!bLogin) { - cmp = -1 - } else { - cmp = aLogin.localeCompare(bLogin) - } + cmp = (aValue.users[0]?.login ?? '').localeCompare(bValue.users[0]?.login ?? '') } else if (aValue.kind === 'labels' && bValue.kind === 'labels') { - const aName = aValue.labels[0]?.name ?? '' - const bName = bValue.labels[0]?.name ?? '' - if (!aName && !bName) { - cmp = 0 - } else if (!aName) { - cmp = 1 - } else if (!bName) { - cmp = -1 - } else { - cmp = aName.localeCompare(bName) - } + cmp = (aValue.labels[0]?.name ?? '').localeCompare(bValue.labels[0]?.name ?? '') } else { // Why: unknown sort-field kind — ignore this sort field and fall through // to tie-breaks (and eventually row.position). From e89321192aebbd1ad842d2c5bb5a94a46b6d332b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:01:59 -0700 Subject: [PATCH 34/94] perf(worktree): batch remote conflict probes, re-arm the prepared checkout (#17829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(worktree): batch remote conflict probes, re-arm the prepared checkout A repo with many remotes paid one `git show-ref --verify` subprocess per remote on every branch-conflict check during create. Ask one `git cat-file --batch-check` over stdin instead; it reports a missing ref as data rather than a failed exit, so a batch stays as decidable as the per-ref probe. Hosts that cannot feed stdin, and undecided batches, still fall back to the per-ref path. The prepared checkout was single-use, so the second create in a row paid the full cold `git worktree add`. Re-arm it in the background after one is consumed; the existing TTL and preparation limit still bound it. The create timing recorder existed but its phases were never emitted and did not cover preflight, leaving a multi-second gap in the trace with no attribution. Add `resolve_name`/`prepare_push_target` phases and record the breakdown, plus the unattributed remainder, on the create span. * fix(worktree): format the conflicting review number eagerly for the create error * perf(worktree): re-arm a prepared checkout only for a burst of creates Re-arming after every consumed preparation spends a full checkout and ~200MB of disk on a user who created one worktree and stopped, then pays an unexplained delete when the TTL expires five minutes later. Track when each preparation key was last consumed and only replace it when a second create lands inside the burst window, so the warm second create is still free and an isolated create costs nothing. * fix(worktree): address review findings on the create-path batching Three findings from PR review: The `batched.found` fallback in the remote-conflict probe was unreachable — a present ref is decisive, so `found` never survives with `unknown` set, and the guard above already returns that case. `rearmPreparation` checked for an existing preparation before recording the consume, so a prefetch that re-armed the key while create finalized swallowed the timestamp and made the next create look isolated when it was really mid-burst. Create runs some phases concurrently, so summing phase durations double-counted overlap and understated `unattributed_ms` — the one number that matters when a create is slow for no visible reason. Measure the union of the phase intervals instead. * refactor(worktree): move stale-preparation cleanup into its own module The preparation module crossed the 300-line budget. Crash recovery is a separate concern from the pool itself — it discards preparations another process left registered, single-flighted per repo and runtime so a burst of arming calls shares one worktree listing. * test(worktree): make the re-arm test able to fail The burst test armed a preparation manually after the second consume, so the third checkout appeared whether or not the re-arm produced it — the assertion passed with re-arming disabled. Drop that arming call so the third checkout can only come from the re-arm, and assert the consume results rather than discarding them. --- src/main/git/exact-ref-probe.ts | 49 ++++ .../git/repo-branch-conflict-real-git.test.ts | 49 ++++ src/main/git/repo-branch-conflict.test.ts | 120 +++++++++ src/main/git/repo-branch-conflict.ts | 53 +++- src/main/ipc/worktree-remote.ts | 250 +++++++++--------- .../register-worktree-create-handlers.ts | 10 +- .../observability/instrumentation.test.ts | 48 ++++ src/main/observability/instrumentation.ts | 59 ++++- src/main/worktree-create-preparation-burst.ts | 21 ++ ...rktree-create-preparation-stale-cleanup.ts | 79 ++++++ src/main/worktree-create-preparation.test.ts | 47 ++++ src/main/worktree-create-preparation.ts | 125 ++++----- 12 files changed, 702 insertions(+), 208 deletions(-) create mode 100644 src/main/git/repo-branch-conflict-real-git.test.ts create mode 100644 src/main/worktree-create-preparation-burst.ts create mode 100644 src/main/worktree-create-preparation-stale-cleanup.ts diff --git a/src/main/git/exact-ref-probe.ts b/src/main/git/exact-ref-probe.ts index 6b13cc718c5..96bb421b8e8 100644 --- a/src/main/git/exact-ref-probe.ts +++ b/src/main/git/exact-ref-probe.ts @@ -19,6 +19,8 @@ export type ExactRefProbeSetResult = { type ExactRefPresence = 'present' | 'absent' | 'unknown' const EXACT_REF_PROBE_CONCURRENCY = 8 +// SHA-1 and SHA-256 repositories both report a full object id here. +const OBJECT_ID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/ export function isShowRefNoMatchError(error: unknown): boolean { const record = error && typeof error === 'object' ? (error as Record) : undefined @@ -126,3 +128,50 @@ export async function probeAnyExactRef( await Promise.all(Array.from({ length: workerCount }, () => probeNext())) return { found, unknown } } + +/** Runs Git with a stdin payload. Only hosts that can feed a child's stdin supply one. */ +export type ExactRefProbeStdinExec = ( + argv: string[], + options: ExactRefProbeExecOptions & { stdin: string } +) => Promise<{ stdout: string }> + +/** `cat-file --batch-check` reports every ref from one child, and reports a missing ref as data + * rather than a failed exit — so a batch stays as decidable as a per-ref `show-ref --verify`. + * A repo with many remotes otherwise pays one subprocess per remote on every conflict check. */ +export async function probeAnyExactRefBatched( + runGit: ExactRefProbeStdinExec, + refs: readonly string[], + options: ExactRefProbeExecOptions = {} +): Promise<{ found: boolean; unknown: boolean }> { + const uniqueRefs = [...new Set(refs)] + const safeRefs = uniqueRefs.filter((ref) => isSafeGitRefName(ref)) + if (safeRefs.length === 0) { + return { found: false, unknown: uniqueRefs.length > 0 } + } + let stdout: string + try { + ;({ stdout } = await runGit(['cat-file', '--batch-check'], { + ...options, + stdin: `${safeRefs.join('\n')}\n` + })) + } catch { + return { found: false, unknown: true } + } + const lines = stdout.split('\n').filter((line) => line.trim().length > 0) + // One line per input, in order; a short read means the batch never answered for the rest. + if (lines.length !== safeRefs.length) { + return { found: false, unknown: true } + } + let unknown = safeRefs.length !== uniqueRefs.length + for (const line of lines) { + const [head, type] = line.split(' ') + if (OBJECT_ID_PATTERN.test(head) && type !== undefined && type !== 'missing') { + return { found: true, unknown: false } + } + if (type !== 'missing') { + // `ambiguous`, or a spelling this Git reports differently; neither proves absence. + unknown = true + } + } + return { found: false, unknown } +} diff --git a/src/main/git/repo-branch-conflict-real-git.test.ts b/src/main/git/repo-branch-conflict-real-git.test.ts new file mode 100644 index 00000000000..34092273eda --- /dev/null +++ b/src/main/git/repo-branch-conflict-real-git.test.ts @@ -0,0 +1,49 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getBranchConflictKind } from './repo-branch-conflict' + +describe('branch conflict real Git contract', () => { + const tempPaths: string[] = [] + + afterEach(() => { + for (const path of tempPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + }) + + it('decides remote conflicts from one batched probe across many remotes', async () => { + const repoPath = mkdtempSync(join(tmpdir(), 'orca-branch-conflict-')) + tempPaths.push(repoPath) + const git = (...args: string[]): string => + execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' }) + + git('init', '--quiet') + git('config', 'user.name', 'Orca Test') + git('config', 'user.email', 'orca@example.test') + git('config', 'commit.gpgSign', 'false') + git('config', 'core.hooksPath', '.git/no-hooks') + writeFileSync(join(repoPath, 'fixture.txt'), 'base\n') + git('add', 'fixture.txt') + git('commit', '--quiet', '-m', 'base') + const head = git('rev-parse', 'HEAD').trim() + + // Many remotes is the shape that used to cost one subprocess each. + for (let index = 0; index < 12; index += 1) { + git('remote', 'add', `remote${index}`, 'https://example.test/repo.git') + } + git('update-ref', 'refs/remotes/remote7/taken', head) + + await expect(getBranchConflictKind(repoPath, 'taken')).resolves.toBe('remote') + await expect(getBranchConflictKind(repoPath, 'free')).resolves.toBeNull() + // The allowed base ref is the one remote spelling that is not a conflict. + await expect( + getBranchConflictKind(repoPath, 'taken', 'refs/remotes/remote7/taken') + ).resolves.toBeNull() + + git('branch', 'local-only', head) + await expect(getBranchConflictKind(repoPath, 'local-only')).resolves.toBe('local') + }) +}) diff --git a/src/main/git/repo-branch-conflict.test.ts b/src/main/git/repo-branch-conflict.test.ts index dab8dd396d6..873c8bd3340 100644 --- a/src/main/git/repo-branch-conflict.test.ts +++ b/src/main/git/repo-branch-conflict.test.ts @@ -134,3 +134,123 @@ describe('getBranchConflictKindViaExec', () => { expect(exec).not.toHaveBeenCalled() }) }) + +describe('getBranchConflictKindViaExec batched remote probe', () => { + function remoteNames(count: number): string { + return `${Array.from({ length: count }, (_, index) => `remote${index}`).join('\n')}\n` + } + + function baseExec(calls: string[][]): (argv: string[]) => Promise<{ stdout: string }> { + return async (argv) => { + calls.push(argv) + if (argv[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (argv[0] === 'remote') { + return { stdout: remoteNames(3) } + } + throw new Error(`unexpected git command: ${argv.join(' ')}`) + } + } + + it('asks one batched child instead of one probe per remote', async () => { + const calls: string[][] = [] + const stdinPayloads: (string | undefined)[] = [] + const exec = baseExec(calls) + const batched = async ( + argv: string[], + options: { stdin: string } + ): Promise<{ stdout: string }> => { + calls.push(argv) + stdinPayloads.push(options.stdin) + return { + stdout: [ + 'refs/remotes/remote0/feature missing', + 'refs/remotes/remote1/feature missing', + 'refs/remotes/remote2/feature missing' + ].join('\n') + } + } + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBeNull() + expect(calls).toEqual([ + ['rev-parse', '--verify', 'refs/heads/feature'], + ['remote'], + ['cat-file', '--batch-check'] + ]) + expect(stdinPayloads).toEqual([ + 'refs/remotes/remote0/feature\nrefs/remotes/remote1/feature\nrefs/remotes/remote2/feature\n' + ]) + }) + + it('reports a remote conflict from the batched answer', async () => { + const calls: string[][] = [] + const exec = baseExec(calls) + const batched = async (): Promise<{ stdout: string }> => ({ + stdout: [ + 'refs/remotes/remote0/feature missing', + `${'a'.repeat(40)} commit 214`, + 'refs/remotes/remote2/feature missing' + ].join('\n') + }) + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBe('remote') + }) + + it('falls back to per-ref probes when the batch cannot answer', async () => { + const calls: string[][] = [] + const exec = async (argv: string[]): Promise<{ stdout: string }> => { + calls.push(argv) + if (argv[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (argv[0] === 'remote') { + return { stdout: remoteNames(3) } + } + if (argv[0] === 'show-ref') { + if (argv[4] === 'refs/remotes/remote1/feature') { + return { stdout: 'abc refs/remotes/remote1/feature\n' } + } + throw Object.assign(new Error('missing'), { code: 1, stderr: '' }) + } + throw new Error(`unexpected git command: ${argv.join(' ')}`) + } + const batched = async (): Promise<{ stdout: string }> => { + throw new Error('cat-file is unavailable') + } + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBe('remote') + expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3) + }) + + it('treats a short batch read as undecided rather than as absence', async () => { + const calls: string[][] = [] + const exec = async (argv: string[]): Promise<{ stdout: string }> => { + calls.push(argv) + if (argv[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (argv[0] === 'remote') { + return { stdout: remoteNames(3) } + } + if (argv[0] === 'show-ref') { + throw Object.assign(new Error('missing'), { code: 1, stderr: '' }) + } + throw new Error(`unexpected git command: ${argv.join(' ')}`) + } + const batched = async (): Promise<{ stdout: string }> => ({ + stdout: 'refs/remotes/remote0/feature missing' + }) + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBeNull() + expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3) + }) +}) diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts index 162d5ef53b3..c799d3770be 100644 --- a/src/main/git/repo-branch-conflict.ts +++ b/src/main/git/repo-branch-conflict.ts @@ -4,8 +4,10 @@ import { gitExecFileAsync } from './runner' import { isSafeGitRefName } from '../../shared/git-status-upstream-ref' import { probeAnyExactRef, + probeAnyExactRefBatched, type ExactRefProbeExec, - type ExactRefProbeExecOptions + type ExactRefProbeExecOptions, + type ExactRefProbeStdinExec } from './exact-ref-probe' export type BranchConflictKind = 'local' | 'remote' @@ -79,12 +81,31 @@ function buildRemoteBranchConflictRefs( return [...refs] } +/** One batched child answers for every remote; the per-ref probes only run when the host cannot + * feed stdin, or when the batch came back undecided. */ +async function probeAnyRemoteConflictRef( + exec: ExactRefProbeExec, + batchedExec: ExactRefProbeStdinExec | undefined, + candidateRefs: readonly string[], + probeOptions: ExactRefProbeExecOptions +): Promise<{ found: boolean }> { + if (batchedExec) { + // A present ref is always decisive, so `found` never survives with `unknown` set. + const batched = await probeAnyExactRefBatched(batchedExec, candidateRefs, probeOptions) + if (!batched.unknown) { + return { found: batched.found } + } + } + return probeAnyExactRef(exec, candidateRefs, probeOptions) +} + /** Run branch-conflict policy through the host that owns Git execution. */ export async function getBranchConflictKindViaExec( exec: ExactRefProbeExec, branchName: string, allowedBaseRef?: string, - options: ExactRefProbeExecOptions = {} + options: ExactRefProbeExecOptions = {}, + batchedExec?: ExactRefProbeStdinExec ): Promise { if (!canQueryRemoteBranchName(branchName)) { return null @@ -104,7 +125,12 @@ export async function getBranchConflictKindViaExec( return null } - const { found: hasRemoteConflict } = await probeAnyExactRef(exec, candidateRefs, probeOptions) + const { found: hasRemoteConflict } = await probeAnyRemoteConflictRef( + exec, + batchedExec, + candidateRefs, + probeOptions + ) return hasRemoteConflict ? 'remote' : null } catch { @@ -119,15 +145,22 @@ export function getBranchConflictKind( options: LocalGitExecOptions = {} ): Promise { const execOptions = gitExecOptions(path, options) + const runLocalGit = ( + argv: string[], + commandOptions?: ExactRefProbeExecOptions & { stdin?: string } + ): Promise<{ stdout: string }> => + gitExecFileAsync(argv, { + ...execOptions, + ...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }), + ...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }), + ...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin }) + }) return getBranchConflictKindViaExec( - (argv, commandOptions) => - gitExecFileAsync(argv, { - ...execOptions, - ...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }), - ...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }) - }), + runLocalGit, branchName, - allowedBaseRef + allowedBaseRef, + {}, + (argv, commandOptions) => runLocalGit(argv, commandOptions) ) } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index e53ae264129..7d28f38f2db 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -2189,130 +2189,139 @@ export async function createLocalWorktree( let lastExistingReviewNumber: number | null = null const shouldRetireGeneratedName = args.nameWasGenerated === true && isGeneratedWorktreeCreateName(sanitizedName) - const retiredNameRegistry = shouldRetireGeneratedName - ? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings) - : null - const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null - // Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user. - for (let suffix = 1, attempts = 0; attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) { - effectiveSanitizedName = shouldRetireGeneratedName - ? getGeneratedWorktreeCreateCandidate( - sanitizedName, - suffix, - retiredNameRegistry?.exhaustedTiers - ) - : getWorktreeCreateCandidate(sanitizedName, suffix) - effectiveRequestedName = shouldRetireGeneratedName - ? effectiveSanitizedName - : requestedName.trim() - ? getWorktreeCreateCandidate(requestedName, suffix) - : effectiveSanitizedName - if (isRetiredName?.(effectiveSanitizedName)) { - continue - } - attempts += 1 - lastExistingReviewNumber = null + await timing.time('resolve_name', async () => { + const retiredNameRegistry = shouldRetireGeneratedName + ? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings) + : null + const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null + // Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user. + for ( + let suffix = 1, attempts = 0; + attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; + suffix += 1 + ) { + effectiveSanitizedName = shouldRetireGeneratedName + ? getGeneratedWorktreeCreateCandidate( + sanitizedName, + suffix, + retiredNameRegistry?.exhaustedTiers + ) + : getWorktreeCreateCandidate(sanitizedName, suffix) + effectiveRequestedName = shouldRetireGeneratedName + ? effectiveSanitizedName + : requestedName.trim() + ? getWorktreeCreateCandidate(requestedName, suffix) + : effectiveSanitizedName + if (isRetiredName?.(effectiveSanitizedName)) { + continue + } + attempts += 1 + lastExistingReviewNumber = null - branchName = await resolveCreateBranchName( - repo.path, - selectedExistingLocalBranchName - ? selectedExistingLocalBranchName - : getBranchNameOverrideCandidate(args.branchNameOverride, suffix), - effectiveSanitizedName, - settings, - username, - localWorktreeGitOptions - ) - checkoutExistingBranch = await canCheckoutExistingLocalBranch( - repo.path, - branchName, - baseBranch, - localWorktreeGitOptions - ) - if (checkoutExistingBranch && !selectedExistingLocalBranchName) { - // Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling. - selectedExistingLocalBranchName = branchName - } - lastBranchConflictKind = checkoutExistingBranch - ? null - : await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions) - const allowedPushTargetRemoteConflict = - lastBranchConflictKind && - isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args) - if (lastBranchConflictKind) { - if (allowedPushTargetRemoteConflict) { - lastExistingPR = null - let lookupFailed = false - const selectedReview = getSelectedReviewBranch(args) - if (selectedReview?.provider === 'github') { - try { - lastExistingPR = await getLocalGitHubPrForBranch( - repo.path, - branchName, - localWorktreeGitOptions - ) - } catch { - lookupFailed = true - } - if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { - lastBranchConflictKind = null - } else if (lastExistingPR) { - lastExistingReviewNumber = lastExistingPR.number - } - } else if (selectedReview) { - let hostedReview: Awaited> = null - try { - hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args) - } catch { - lookupFailed = true - } - if (!lookupFailed && hostedReview?.matchesSelected) { - lastBranchConflictKind = null - } else if (hostedReview) { - lastExistingReviewNumber = hostedReview.number + branchName = await resolveCreateBranchName( + repo.path, + selectedExistingLocalBranchName + ? selectedExistingLocalBranchName + : getBranchNameOverrideCandidate(args.branchNameOverride, suffix), + effectiveSanitizedName, + settings, + username, + localWorktreeGitOptions + ) + checkoutExistingBranch = await canCheckoutExistingLocalBranch( + repo.path, + branchName, + baseBranch, + localWorktreeGitOptions + ) + if (checkoutExistingBranch && !selectedExistingLocalBranchName) { + // Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling. + selectedExistingLocalBranchName = branchName + } + lastBranchConflictKind = checkoutExistingBranch + ? null + : await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions) + const allowedPushTargetRemoteConflict = + lastBranchConflictKind && + isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args) + if (lastBranchConflictKind) { + if (allowedPushTargetRemoteConflict) { + lastExistingPR = null + let lookupFailed = false + const selectedReview = getSelectedReviewBranch(args) + if (selectedReview?.provider === 'github') { + try { + lastExistingPR = await getLocalGitHubPrForBranch( + repo.path, + branchName, + localWorktreeGitOptions + ) + } catch { + lookupFailed = true + } + if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { + lastBranchConflictKind = null + } else if (lastExistingPR) { + lastExistingReviewNumber = lastExistingPR.number + } + } else if (selectedReview) { + let hostedReview: Awaited> = null + try { + hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args) + } catch { + lookupFailed = true + } + if (!lookupFailed && hostedReview?.matchesSelected) { + lastBranchConflictKind = null + } else if (hostedReview) { + lastExistingReviewNumber = hostedReview.number + } } } } - } - if (lastBranchConflictKind) { - continue - } - - // Why: gh pr list is a ~1–3s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it. - if (suffix > 1 && !checkoutExistingBranch) { - lastExistingPR = null - try { - lastExistingPR = await getLocalGitHubPrForBranch( - repo.path, - branchName, - localWorktreeGitOptions - ) - } catch { - // GitHub API may be unreachable, rate-limited, or token missing - } - if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { - lastExistingReviewNumber = lastExistingPR.number + if (lastBranchConflictKind) { continue } - } - worktreePath = ensurePathWithinWorkspace( - computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), - workspaceRoot - ) - if (existsSync(worktreePath)) { - continue - } + // Why: gh pr list is a ~1–3s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it. + if (suffix > 1 && !checkoutExistingBranch) { + lastExistingPR = null + try { + lastExistingPR = await getLocalGitHubPrForBranch( + repo.path, + branchName, + localWorktreeGitOptions + ) + } catch { + // GitHub API may be unreachable, rate-limited, or token missing + } + if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { + lastExistingReviewNumber = lastExistingPR.number + continue + } + } - resolved = true - break - } + worktreePath = ensurePathWithinWorkspace( + computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), + workspaceRoot + ) + if (existsSync(worktreePath)) { + continue + } + + resolved = true + break + } + }) if (!resolved) { // Why: every suffix collided; reject with a specific reason so the user sees why create failed instead of a generic error or hung spinner. - if (lastExistingReviewNumber !== null) { + // Read once and format eagerly: the suffix loop assigns this from a callback, so the `let`'s + // narrowing does not reach the message. + const existingReviewNumber = lastExistingReviewNumber + if (existingReviewNumber !== null) { throw new Error( - `Branch "${branchName}" already has PR #${lastExistingReviewNumber}. Pick a different ${branchConflictSubject}.` + `Branch "${branchName}" already has PR #${String(existingReviewNumber)}. Pick a different ${branchConflictSubject}.` ) } if (lastBranchConflictKind) { @@ -2361,14 +2370,17 @@ export async function createLocalWorktree( emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId) let preparedPushTarget: GitPushTarget | undefined - if (args.pushTarget) { + const requestedPushTarget = args.pushTarget + if (requestedPushTarget) { // Why: validate/fetch the contributor remote before create so a failure doesn't leave a half-created worktree with conflicts on retry. - preparedPushTarget = await prepareWorktreePushTarget( - repo.path, - args.pushTarget, - store, - repo.id, - localWorktreeGitOptions + preparedPushTarget = await timing.time('prepare_push_target', () => + prepareWorktreePushTarget( + repo.path, + requestedPushTarget, + store, + repo.id, + localWorktreeGitOptions + ) ) } diff --git a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts index 775a0859790..f371529963c 100644 --- a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts +++ b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts @@ -4,7 +4,10 @@ import type { CreateWorktreeResult, AdoptProvisionedRootArgs } from '../../../../shared/worktree/create-types' -import { withWorktreeSpan } from '../../../observability/instrumentation' +import { + addWorktreeCreatePhaseAttributes, + withWorktreeSpan +} from '../../../observability/instrumentation' import { workspaceSourceSchema } from '../../../../shared/telemetry-events' import type { WorkspaceSource } from '../../../../shared/telemetry-events' import { @@ -36,7 +39,7 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi async (_event, rawArgs: CreateWorktreeArgs): Promise => { const args = normalizeLinkedWorkItemFields(rawArgs) // Why span here: parent the child git spans for the trace tree; don't attach branch name/remote URL (user content) — repo ID is the safer correlator. - return withWorktreeSpan({ stage: 'create' }, async () => { + return withWorktreeSpan({ stage: 'create' }, async (span) => { const repo = store.getRepo(args.repoId) if (!repo) { throw new Error(`Repo not found: ${args.repoId}`) @@ -74,6 +77,9 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi throw error } finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + if (result.timing) { + addWorktreeCreatePhaseAttributes(span, result.timing) + } // Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name. track('workspace_created', { diff --git a/src/main/observability/instrumentation.test.ts b/src/main/observability/instrumentation.test.ts index 2f7d1da05dd..978854897e4 100644 --- a/src/main/observability/instrumentation.test.ts +++ b/src/main/observability/instrumentation.test.ts @@ -3,6 +3,7 @@ import { _resetTracerForTests, setActiveSink, type TracerSink } from './tracer' import { _gitSpanSamplingBucketCountForTests, _resetGitSpanSamplingForTests, + addWorktreeCreatePhaseAttributes, withGitSpan } from './instrumentation' @@ -167,3 +168,50 @@ describe('withGitSpan sampling', () => { expect(_gitSpanSamplingBucketCountForTests()).toBe(1) }) }) + +describe('addWorktreeCreatePhaseAttributes', () => { + function capture(): { + attributes: Record + span: Parameters[0] + } { + const attributes: Record = {} + const span = { + setAttribute: (key: string, value: unknown) => { + attributes[key] = value + } + } as unknown as Parameters[0] + return { attributes, span } + } + + it('counts concurrent phases once when measuring unattributed time', () => { + const { attributes, span } = capture() + // Create resolves shared directories and .worktreeinclude concurrently; summing their + // durations would claim 400ms of coverage for a 200ms window. + addWorktreeCreatePhaseAttributes(span, { + totalDurationMs: 1000, + phases: [ + { phase: 'resolve_shared_directories', startedAtMs: 100, durationMs: 200 }, + { phase: 'resolve_worktreeinclude', startedAtMs: 150, durationMs: 150 } + ] + }) + + expect(attributes['worktree.create.phase.resolve_shared_directories_ms']).toBe(200) + expect(attributes['worktree.create.phase.resolve_worktreeinclude_ms']).toBe(150) + // Covered wall clock is 100..300, so 800ms is genuinely unaccounted for. + expect(attributes['worktree.create.unattributed_ms']).toBe(800) + }) + + it('sums disjoint phases and never reports negative unattributed time', () => { + const { attributes, span } = capture() + addWorktreeCreatePhaseAttributes(span, { + totalDurationMs: 500, + phases: [ + { phase: 'resolve_name', startedAtMs: 0, durationMs: 100 }, + { phase: 'git_worktree_add', startedAtMs: 300, durationMs: 200 } + ] + }) + + expect(attributes['worktree.create.total_ms']).toBe(500) + expect(attributes['worktree.create.unattributed_ms']).toBe(200) + }) +}) diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts index bab57b74f64..fb57aa5a870 100644 --- a/src/main/observability/instrumentation.ts +++ b/src/main/observability/instrumentation.ts @@ -202,10 +202,11 @@ export type WorktreeSpanArgs = { readonly path?: string } -/** Wrap a worktree-setup phase in a `worktree.` span. */ +/** Wrap a worktree-setup phase in a `worktree.` span. The callback receives the span so a + * create can attach its own phase breakdown; the git children alone leave the waits invisible. */ export async function withWorktreeSpan( meta: WorktreeSpanArgs, - fn: () => Promise + fn: (span: ActiveSpan) => Promise ): Promise { return withSpan( `worktree.${meta.stage}`, @@ -214,12 +215,64 @@ export async function withWorktreeSpan( if (meta.path) { span.setAttribute('worktree.path', meta.path) } - return await fn() + return await fn(span) }, { attributes: { kind: 'worktree' } } ) } +type WorktreeCreatePhaseTiming = { + readonly phase: string + readonly startedAtMs: number + readonly durationMs: number +} + +/** Wall-clock span covered by at least one phase. Create runs some phases concurrently, so summing + * durations double-counts and would report overlap as coverage the phases never had. */ +function measuredWallClockMs(phases: readonly WorktreePhaseInterval[]): number { + const intervals = [...phases] + .map((phase) => [phase.startedAtMs, phase.startedAtMs + phase.durationMs] as const) + .sort((left, right) => left[0] - right[0]) + let covered = 0 + let openedAt: number | null = null + let closesAt = 0 + for (const [start, end] of intervals) { + if (openedAt === null) { + openedAt = start + closesAt = end + continue + } + if (start <= closesAt) { + closesAt = Math.max(closesAt, end) + continue + } + covered += closesAt - openedAt + openedAt = start + closesAt = end + } + return openedAt === null ? 0 : covered + (closesAt - openedAt) +} + +type WorktreePhaseInterval = Pick + +/** Records a create's phase breakdown on its span. Phase names are already a closed vocabulary in + * the recorder, so they are safe to key on; nothing here carries a branch name or a path. */ +export function addWorktreeCreatePhaseAttributes( + span: ActiveSpan, + timing: { totalDurationMs: number; phases: readonly WorktreeCreatePhaseTiming[] } +): void { + span.setAttribute('worktree.create.total_ms', Math.round(timing.totalDurationMs)) + for (const phase of timing.phases) { + span.setAttribute(`worktree.create.phase.${phase.phase}_ms`, Math.round(phase.durationMs)) + } + // What the phases do not cover is the number that matters when create feels slow for no visible + // reason, so name it rather than leaving it to subtraction. + span.setAttribute( + 'worktree.create.unattributed_ms', + Math.max(0, Math.round(timing.totalDurationMs - measuredWallClockMs(timing.phases))) + ) +} + /** Closed set so a typo can't silently mint an orphan span name. */ export type WorktreeRemoveStage = | 'archive_hook' diff --git a/src/main/worktree-create-preparation-burst.ts b/src/main/worktree-create-preparation-burst.ts new file mode 100644 index 00000000000..d289927ffaa --- /dev/null +++ b/src/main/worktree-create-preparation-burst.ts @@ -0,0 +1,21 @@ +import { setBoundedMapEntry } from './runtime/runtime-async-boundaries' + +/** Two creates this close together mean more are likely; an isolated create earns no replacement. */ +export const WORKTREE_CREATE_BURST_MS = 5 * 60_000 +const WORKTREE_CREATE_PREPARATION_CONSUME_MAX = 64 + +/** When each preparation key was last consumed, so a burst can be told from an isolated create. */ +const lastConsumedAt = new Map() + +/** Records this consume and reports whether it continues a burst. A replacement checkout costs a + * full tree and holds disk until its TTL, so only a user who is already creating repeatedly earns + * one; the first create of a session pays nothing for a spare nobody claims. */ +export function recordPreparationConsume(key: string, now = Date.now()): boolean { + const previous = lastConsumedAt.get(key) + setBoundedMapEntry(lastConsumedAt, key, now, WORKTREE_CREATE_PREPARATION_CONSUME_MAX) + return previous !== undefined && now - previous <= WORKTREE_CREATE_BURST_MS +} + +export function resetPreparationConsumeHistoryForTests(): void { + lastConsumedAt.clear() +} diff --git a/src/main/worktree-create-preparation-stale-cleanup.ts b/src/main/worktree-create-preparation-stale-cleanup.ts new file mode 100644 index 00000000000..4a422f672ed --- /dev/null +++ b/src/main/worktree-create-preparation-stale-cleanup.ts @@ -0,0 +1,79 @@ +import { + isWorktreeCreatePreparation, + parseWorktreePreparationOwnerPid, + parseWorktreePreparationPathOwnerPid +} from '../shared/worktree/create-preparation' +import type { AddWorktreeOptions } from './git/worktree' +import { listWorktreeGraph } from './git/worktree' +import { discardPreparedWorktree, unlockPreparedWorktree } from './git/worktree-create-preparation' +import { retryPendingPreparationDiscards } from './worktree-preparation-discard-retry' + +const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4 + +const staleCleanupInFlight = new Map>() + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} + +/** Reclaims preparations a crashed process left registered. Single-flighted per host key so a burst + * of arming calls shares one worktree listing. */ +export async function cleanupStalePreparations( + cleanupKey: string, + repoPath: string, + options: AddWorktreeOptions +): Promise { + const existing = staleCleanupInFlight.get(cleanupKey) + if (existing) { + await existing.catch(() => {}) + return + } + const cleanup = (async () => { + // Not awaited: the create path awaits this cleanup, and one stranded discard costs an unlock plus + // a `worktree remove --force` bounded at 30s each. Reclaiming leaked scratch must not delay create. + void retryPendingPreparationDiscards(cleanupKey) + const worktrees = await listWorktreeGraph(repoPath, { + ...options, + includeCreatePreparations: true + }) + const staleWorktrees = worktrees.filter(isWorktreeCreatePreparation) + let nextIndex = 0 + async function discardNextStalePreparation(): Promise { + while (nextIndex < staleWorktrees.length) { + const worktree = staleWorktrees[nextIndex] + nextIndex += 1 + const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason) + const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path) + if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) { + continue + } + // Preserve a branch-attached final path after a crash; only detached or + // still-hidden preparations are safe to discard automatically. + if (worktree.branch && pathOwnerPid === null) { + await unlockPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) + } else if (pathOwnerPid === lockOwnerPid) { + await discardPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) + } + } + } + const workerCount = Math.min(STALE_PREPARATION_CLEANUP_CONCURRENCY, staleWorktrees.length) + await Promise.all(Array.from({ length: workerCount }, () => discardNextStalePreparation())) + })() + staleCleanupInFlight.set(cleanupKey, cleanup) + try { + await cleanup.catch(() => {}) + } finally { + if (staleCleanupInFlight.get(cleanupKey) === cleanup) { + staleCleanupInFlight.delete(cleanupKey) + } + } +} + +export function resetStalePreparationCleanupForTests(): void { + staleCleanupInFlight.clear() +} diff --git a/src/main/worktree-create-preparation.test.ts b/src/main/worktree-create-preparation.test.ts index 3e03643d6a8..dababbef88d 100644 --- a/src/main/worktree-create-preparation.test.ts +++ b/src/main/worktree-create-preparation.test.ts @@ -466,4 +466,51 @@ describe('worktree create preparation registry', () => { expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true }) expect(mocks.discard).toHaveBeenCalledTimes(1) }) + + function consumeOnce(name: string): ReturnType { + return consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: `/workspace/${name}`, + branch: `feature/${name}`, + baseBranch: 'origin/main' + }) + } + + it('does not re-arm after an isolated create', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await expect(consumeOnce('only')).resolves.toEqual({}) + + // Why: a lone create would otherwise leave a full spare checkout on disk for the whole TTL. + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) + }) + + it('re-arms a preparation once creates arrive in a burst', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await expect(consumeOnce('first')).resolves.toEqual({}) + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(2) + + // No arming call follows this consume: the third checkout can only come from the re-arm. + await expect(consumeOnce('second')).resolves.toEqual({}) + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(3) + + // The replacement is claimable, so a third create still skips the cold add. + await expect(consumeOnce('third')).resolves.toEqual({}) + expect(mocks.finalize).toHaveBeenCalledTimes(3) + }) + + it('does not re-arm when finalization failed', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await expect(consumeOnce('first')).resolves.toEqual({}) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + mocks.prepareCheckout.mockClear() + mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move')) + + await expect(consumeOnce('second')).resolves.toBeNull() + + expect(mocks.prepareCheckout).not.toHaveBeenCalled() + }) }) diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts index ff7478cb46e..22bcf5d1e2c 100644 --- a/src/main/worktree-create-preparation.ts +++ b/src/main/worktree-create-preparation.ts @@ -7,17 +7,12 @@ import { isFolderRepo } from '../shared/repo-kind' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' import { WORKTREE_CREATE_PREPARATION_DIRECTORY, - createWorktreePreparationLockReason, - isWorktreeCreatePreparation, - parseWorktreePreparationOwnerPid, - parseWorktreePreparationPathOwnerPid + createWorktreePreparationLockReason } from '../shared/worktree/create-preparation' import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree' -import { listWorktreeGraph } from './git/worktree' import { discardPreparedWorktree, finalizePreparedWorktree, - unlockPreparedWorktree, prepareWorktreeCreateCheckout } from './git/worktree-create-preparation' import { @@ -25,17 +20,23 @@ import { getWorktreeMirrorDistro } from './project-runtime-git-options' import { computeWorkspaceRootAsync, getWorktreePathSettings } from './ipc/worktree-logic' +import { + recordPreparationConsume, + resetPreparationConsumeHistoryForTests +} from './worktree-create-preparation-burst' +import { + cleanupStalePreparations, + resetStalePreparationCleanupForTests +} from './worktree-create-preparation-stale-cleanup' import { toHostFilesystemPath } from './host-tree-removal' import { discardPreparationWithRetry, resetPendingPreparationDiscardsForTests, - retryPendingPreparationDiscards, trackPreparationDiscard } from './worktree-preparation-discard-retry' export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000 export const WORKTREE_CREATE_PREPARATION_LIMIT = 3 -const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4 type PreparationEntry = { key: string @@ -59,7 +60,6 @@ type ConsumePreparedWorktreeArgs = { } const preparations = new Map() -const staleCleanupInFlight = new Map>() function pathOps(path: string): Pick { return isWindowsAbsolutePathLike(path) ? win32 : posix @@ -79,15 +79,6 @@ function preparationKey( return `${pathKey(repoPath)}\0${pathKey(workspaceRoot)}\0${baseBranch}\0${options.wslDistro ?? ''}` } -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (error) { - return (error as NodeJS.ErrnoException).code !== 'ESRCH' - } -} - function preparationHostKey(repoPath: string, options: AddWorktreeOptions): string { return `${pathKey(repoPath)}\0${options.wslDistro ?? ''}` } @@ -131,57 +122,6 @@ function enforcePreparationLimit(): void { } } -async function cleanupStalePreparations( - repoPath: string, - options: AddWorktreeOptions -): Promise { - const cleanupKey = preparationHostKey(repoPath, options) - const existing = staleCleanupInFlight.get(cleanupKey) - if (existing) { - await existing.catch(() => {}) - return - } - const cleanup = (async () => { - // Not awaited: the create path awaits this cleanup, and one stranded discard costs an unlock plus - // a `worktree remove --force` bounded at 30s each. Reclaiming leaked scratch must not delay create. - void retryPendingPreparationDiscards(cleanupKey) - const worktrees = await listWorktreeGraph(repoPath, { - ...options, - includeCreatePreparations: true - }) - const staleWorktrees = worktrees.filter(isWorktreeCreatePreparation) - let nextIndex = 0 - async function discardNextStalePreparation(): Promise { - while (nextIndex < staleWorktrees.length) { - const worktree = staleWorktrees[nextIndex] - nextIndex += 1 - const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason) - const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path) - if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) { - continue - } - // Preserve a branch-attached final path after a crash; only detached or - // still-hidden preparations are safe to discard automatically. - if (worktree.branch && pathOwnerPid === null) { - await unlockPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) - } else if (pathOwnerPid === lockOwnerPid) { - await discardPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) - } - } - } - const workerCount = Math.min(STALE_PREPARATION_CLEANUP_CONCURRENCY, staleWorktrees.length) - await Promise.all(Array.from({ length: workerCount }, () => discardNextStalePreparation())) - })() - staleCleanupInFlight.set(cleanupKey, cleanup) - try { - await cleanup.catch(() => {}) - } finally { - if (staleCleanupInFlight.get(cleanupKey) === cleanup) { - staleCleanupInFlight.delete(cleanupKey) - } - } -} - export async function prepareWorktreeCreateForRepo( store: Store, repo: Repo, @@ -205,6 +145,16 @@ export async function prepareWorktreeCreateForRepo( return existing.ready } + return startPreparation(key, repo.path, workspaceRoot, baseBranch, options) +} + +function startPreparation( + key: string, + repoPath: string, + workspaceRoot: string, + baseBranch: string, + options: AddWorktreeOptions +): Promise { enforcePreparationLimit() const preparationId = `${process.pid}-${randomUUID()}` const lockReason = createWorktreePreparationLockReason(preparationId) @@ -218,21 +168,21 @@ export async function prepareWorktreeCreateForRepo( expiration.unref() Object.assign(entry, { key, - repoPath: repo.path, + repoPath, workspaceRoot, preparedPath, options, createdAt: Date.now(), expiration, ready: (async () => { - await cleanupStalePreparations(repo.path, options) + await cleanupStalePreparations(preparationHostKey(repoPath, options), repoPath, options) await mkdir( toHostFilesystemPath( pathOps(workspaceRoot).join(workspaceRoot, WORKTREE_CREATE_PREPARATION_DIRECTORY) ), { recursive: true } ) - await prepareWorktreeCreateCheckout(repo.path, preparedPath, baseBranch, lockReason, options) + await prepareWorktreeCreateCheckout(repoPath, preparedPath, baseBranch, lockReason, options) })() } satisfies PreparationEntry) preparations.set(key, entry) @@ -266,6 +216,28 @@ async function claimPreparedWorktree( } } +/** Replaces a just-consumed preparation, but only once the user has shown they are creating in a + * burst. A replacement costs a full checkout and ~5 minutes of disk until its TTL, so arming one + * after an isolated create spends that on nobody. Never awaited: create has already returned by + * the time the replacement checkout finishes. */ +function rearmPreparation(entry: PreparationEntry, baseBranch: string): void { + // Record first: a prefetch that re-armed this key while we finalized would otherwise swallow the + // consume, and the next create would look isolated when it is really the middle of a burst. + const continuesBurst = recordPreparationConsume(entry.key) + if (preparations.has(entry.key) || !continuesBurst) { + return + } + void startPreparation( + entry.key, + entry.repoPath, + entry.workspaceRoot, + baseBranch, + entry.options + ).catch(() => { + // Why: a warm-up failure is recovered by the normal add on the next create. + }) +} + export async function consumePreparedWorktreeCreate( args: ConsumePreparedWorktreeArgs ): Promise { @@ -283,7 +255,7 @@ export async function consumePreparedWorktreeCreate( await mkdir(toHostFilesystemPath(pathOps(args.worktreePath).dirname(args.worktreePath)), { recursive: true }) - return await finalizePreparedWorktree( + const result = await finalizePreparedWorktree( args.repoPath, entry.preparedPath, args.worktreePath, @@ -292,6 +264,10 @@ export async function consumePreparedWorktreeCreate( args.refreshLocalBaseRef, options ) + // Consuming the only prepared checkout leaves the next create cold. Re-arm for a user who is + // creating in a burst; the TTL and the preparation limit still bound an unused replacement. + rearmPreparation(entry, args.baseBranch) + return result } catch (error) { await discardPreparedWorktree(args.repoPath, entry.preparedPath, options).catch(() => {}) console.warn( @@ -305,7 +281,8 @@ export async function consumePreparedWorktreeCreate( export async function _resetWorktreeCreatePreparationsForTests(): Promise { const entries = [...preparations.values()] preparations.clear() - staleCleanupInFlight.clear() + resetPreparationConsumeHistoryForTests() + resetStalePreparationCleanupForTests() await Promise.all( entries.map(async (entry) => { clearTimeout(entry.expiration) From 8b7d778a2ef5e3c0a17434b564486e8aa61138de Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:04:03 -0700 Subject: [PATCH 35/94] perf(git-common): bound the fs-stat fan-out in the worktree pollers (#17839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(git-common): bound the fs-stat fan-out in the worktree pollers snapshotGitCommon and snapshotBase issued one fs op per candidate via Promise.all/a serial loop, unbounded by worktree count. At 973 live worktrees this queued ~6,800 concurrent stat calls (measured peak 6000 in a 1000-entry synthetic benchmark) onto libuv's 4-thread default pool, starving every other main-process fs operation for the scan's duration (~1s). Bound both to concurrency 8 via the existing forEachWithConcurrency helper, matching the precedent in exact-ref-probe.ts and worktree-head-identity-reader.ts. Peak concurrent stats dropped 6000 -> 48 in the benchmark; wall time was essentially unchanged (495ms -> 541ms), since the real bottleneck was never total scan time but pool starvation of unrelated work. Also make the no-native-watch and crash-fuse polling fallbacks in worktree-git-common-watch.ts / worktree-git-common-narrow-watch.ts self-calibrate their cadence: on platforms/paths where this poller is the sole change signal, a fixed 2s cadence at hundreds of worktrees approaches a permanent scan loop. Stretch the interval so a scan stays a bounded fraction (10%) of its own cadence, capped at 30s, floored at the configured base interval. Left the reconciliation backstop (fixed 30s cadence, already accepted) and checkPendingMarkers (bounded by concurrent-worktree-creation count, not total count) untouched. Fixes #17828 * perf(git-common): split the tripwire from the per-entry sweep cadence Review on #17839 found a real staleness trade-off: adaptiveCadence gated ALL detection (worktree add/remove, HEAD, dirty refs, AND per-entry commit signals) behind one stretched interval, so on the crash-fuse polling fallback the reviewer measured cadence sitting at 5.4-10s sustained and hitting the 30s cap once a single scan reached 3s at 973 worktrees -- worse than the pre-#17828 fixed ~2s+250ms baseline for signals users notice immediately (sidebar worktree list, branch labels). Split snapshotGitCommon into a cheap structural "tripwire" (readdir, worktreesDir signature, primary-file signatures, newly-appeared entries -- ~5-6 fs ops, O(1) in worktree count) that always runs on the fixed pollIntervalMs, and the O(n) per-entry sweep (commit/dirty detection) that alone is gated by the adaptive cadence via a nextSweepDueAt deadline. Existing, unchanged entries are carried over by reference on a tripwire-only tick (no re-stat), so diffing produces no spurious events; genuinely new entries are still stat'd immediately so worktree add remains real-time. This keeps everything on one ticking-flag-guarded loop (no new concurrency/race surface) -- scheduling stays fixed at pollIntervalMs; only nextSweepDueAt stretches. Also drop the adaptive-cadence seed heuristic entirely: nextSweepDueAt starts at 0, so the first regular tick after bootstrap sweeps unconditionally on its own schedule instead of guessing an initial interval from the bootstrap snapshot's duration (which could stretch the very first tick to 10-30s on a slow disk). Documented that worktree-git-common-watch.ts's adaptiveCadence call site is unreachable in production (Electron only ships darwin/linux/win32, both covered by NARROW_WATCH_PLATFORMS) rather than implying it protects real users. The reachable path is the narrow-watch crash-fuse fallback in worktree-git-common-narrow-watch.ts. Filed #17878 to track the real long-term fix: periodically retrying the upgrade back to the narrow watch after a crash-fuse trip, so the degraded/polling state doesn't need to be tuned at all once the underlying failure clears. * perf(git-common): gate per-entry structural stats on the entry-dir signature Every real git write inside a worktree admin entry (HEAD, index, config.worktree, locked) goes through a lock file + rename, which moves the entry directory's own mtime/ctime/size signature. Only `gitdir` (worktree move/repair) is rewritten in place, and that's already covered by the periodic ungated backstop (INDEX_BACKSTOP_TICKS). The previous comment claiming structural leaves "change in place every tick" was wrong; verified against git 2.55 across checkout, commit, amend, reset, ref updates, stash, worktree lock/unlock, config --worktree, and index writes. Gate all six per-entry stats behind the entry dir's own signature instead of stat-ing every leaf unconditionally every tick: an unchanged entry now costs one stat per tick instead of six, and a changed one still costs six (bounded by change rate, not worktree count). This also fixes the actual in-flight fan-out: forEachWithConcurrency(entries, 8) previously still issued 6 stats per in-flight entry (48 real concurrent ops); with the gate, warm ticks issue ~1 stat per entry, so true in-flight tracks the concurrency limit directly. This makes the follow-up adaptive-cadence machinery from the prior commit unnecessary: the crash-fuse and no-narrow-watch polling fallbacks no longer need to stretch their own cadence, since a warm sweep across hundreds of worktrees is now cheap regardless of interval. Revert both call sites to a fixed pollIntervalMs and delete the adaptive-cadence option, the split tripwire/sweep cadence, and the seed heuristic — none of it earns its complexity once the real per-entry cost is fixed at the source. Per-entry staleness on the crash-fuse path returns to a fixed 2s + 250ms debounce instead of the previous 5.4-30s adaptive stretch. Refs #17828 --- ...ase-directory-poller-marker-fanout.test.ts | 84 +++++++ .../ipc/worktree-base-directory-poller.ts | 37 +-- .../ipc/worktree-git-common-entry-snapshot.ts | 42 ++-- .../ipc/worktree-git-common-narrow-watch.ts | 5 + .../ipc/worktree-git-common-polling.test.ts | 237 ++++++++++++++++++ src/main/ipc/worktree-git-common-polling.ts | 35 +-- src/main/ipc/worktree-git-common-watch.ts | 2 + 7 files changed, 395 insertions(+), 47 deletions(-) create mode 100644 src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts create mode 100644 src/main/ipc/worktree-git-common-polling.test.ts diff --git a/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts b/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts new file mode 100644 index 00000000000..023a8390ccd --- /dev/null +++ b/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' +import type { + WorktreeBaseRepoWatchConfig, + WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' + +// Why: the backstop full scan stats a `.git` marker per candidate dir; an +// unbounded fan-out at hundreds of worktrees would queue thousands of `stat` +// calls on libuv's 4-thread pool (#17828). +const { concurrency } = vi.hoisted(() => ({ concurrency: { current: 0, peak: 0 } })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + stat: async (...args: Parameters) => { + concurrency.current += 1 + concurrency.peak = Math.max(concurrency.peak, concurrency.current) + try { + return await actual.stat(...args) + } finally { + concurrency.current -= 1 + } + } + } +}) + +function makeTarget(path: string): WorktreeBaseWatchTarget { + const repoConfig: WorktreeBaseRepoWatchConfig = { + repoId: 'repo-1', + repoName: 'project', + nestWorkspaces: false + } + return { + key: `base:local:${path}`, + kind: 'base', + path, + repos: new Map([[repoConfig.repoId, repoConfig]]) + } +} + +describe('worktree base directory poller marker fan-out (#17828)', () => { + const cleanups: (() => Promise)[] = [] + + beforeEach(() => { + concurrency.current = 0 + concurrency.peak = 0 + }) + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + }) + + it('bounds concurrent `.git`-marker stats regardless of candidate count', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-base-poller-fanout-'))) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const candidateCount = 200 + for (let i = 0; i < candidateCount; i++) { + const worktree = join(root, `wt-${i}`) + await mkdir(worktree) + await writeFile(join(worktree, '.git'), 'gitdir: elsewhere') + } + + const target = makeTarget(root) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + () => {}, + { pollIntervalMs: 100_000 } + ) + cleanups.push(() => poller.unsubscribe()) + + // 200 candidates stated unbounded would peak near 200 concurrent `stat` + // calls; bounding the marker probe keeps the peak independent of count — + // while still overlapping requests (not serialized one-at-a-time). + expect(concurrency.peak).toBeGreaterThan(1) + expect(concurrency.peak).toBeLessThan(20) + }) +}) diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index 42ee184b811..201d9782fba 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -1,6 +1,8 @@ import { readdir, stat } from 'node:fs/promises' +import type { Dirent } from 'node:fs' import { join } from 'node:path' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { forEachWithConcurrency } from '../../shared/map-with-concurrency' import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { WorktreeBaseRepoWatchConfig, @@ -92,6 +94,11 @@ export const WORKTREE_BASE_BACKSTOP_TICKS = 15 // backstop scan cover the pathological case. const PENDING_MARKER_MAX_TICKS = 300 +// Why: matches the git-common poller's fan-out bound (#17828) — bounded +// concurrency turns hundreds of serial round trips into a handful of batches +// without dumping every candidate onto libuv's 4-thread pool at once. +const MARKER_PROBE_CONCURRENCY = 8 + function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` } @@ -123,6 +130,14 @@ type BaseSnapshot = { gateSignatures: string[] } +async function readdirSafe(path: string): Promise { + try { + return await readdir(path, { withFileTypes: true }) + } catch { + return [] + } +} + // Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested // repo's container, mirroring what worktree-base-directory-event-filter // matches: `/.git` completion markers and `` deletions. @@ -144,14 +159,9 @@ async function snapshotBase( .map((config) => normalizeRuntimePathForComparison(config.repoName)) ) - let rootEntries - try { - rootEntries = await readdir(rootPath, { withFileTypes: true }) - } catch { - // Root vanished: an empty snapshot diffs into delete events for every - // previously-known worktree dir, matching the old watcher's error path. - return { markers, gateDirs, gateSignatures } - } + // Root vanished or unreadable: readdirSafe yields [], producing the same + // empty markers/candidates result as the old watcher's error path. + const rootEntries = await readdirSafe(rootPath) const candidates: string[] = [] for (const entry of rootEntries) { @@ -165,12 +175,7 @@ async function snapshotBase( if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { gateDirs.push(entryPath) gateSignatures.push(await dirSignature(entryPath)) - let subEntries - try { - subEntries = await readdir(entryPath, { withFileTypes: true }) - } catch { - subEntries = [] - } + const subEntries = await readdirSafe(entryPath) for (const sub of subEntries) { if (sub.isDirectory() || sub.isSymbolicLink()) { candidates.push(join(entryPath, sub.name)) @@ -179,9 +184,9 @@ async function snapshotBase( } } - for (const dir of candidates) { + await forEachWithConcurrency(candidates, MARKER_PROBE_CONCURRENCY, async (dir) => { markers.set(dir, await hasGitMarker(dir)) - } + }) return { markers, gateDirs, gateSignatures } } diff --git a/src/main/ipc/worktree-git-common-entry-snapshot.ts b/src/main/ipc/worktree-git-common-entry-snapshot.ts index d14f4ec8a1d..6dad6e0a048 100644 --- a/src/main/ipc/worktree-git-common-entry-snapshot.ts +++ b/src/main/ipc/worktree-git-common-entry-snapshot.ts @@ -39,11 +39,33 @@ export async function snapshotGitCommonEntry( previous: GitCommonEntrySnapshot | undefined, forceFullScan: boolean ): Promise { - // Structural leaves change in place every tick; only index uses the entry-dir gate. + // Git writes HEAD/index/config.worktree/locked via a lock file + rename inside the + // entry dir, so the entry dir's own signature moves on every one of those writes + // (verified against git 2.55: checkout, commit, amend, reset, ref updates, stash, + // worktree lock/unlock, config --worktree, index writes all move it). The one + // in-place exception is `gitdir` (worktree move/repair), which the periodic + // forceFullScan backstop (INDEX_BACKSTOP_TICKS) below re-stats regardless of this + // gate. Gating all of these leaves on the entry-dir signature turns an unchanged + // entry into a single stat per tick instead of stat-ing every leaf every tick. + const nextDirSignature = await gitCommonDirectorySignature(entryPath) + if (nextDirSignature === 'missing') { + return ( + previous ?? { + dirSignature: nextDirSignature, + structuralSignatures: new Map(), + indexSignature: null, + headLogSignature: null + } + ) + } + const shouldRescan = forceFullScan || !previous || previous.dirSignature !== nextDirSignature + if (!shouldRescan) { + return previous + } const structuralSignatures = new Map() - const [nextDirSignature, headLogSignature] = await Promise.all([ - gitCommonDirectorySignature(entryPath), + const [headLogSignature, indexSignature] = await Promise.all([ gitCommonFileSignature(join(entryPath, HEAD_LOG_FILE)), + gitCommonFileSignature(join(entryPath, INDEX_FILE)), Promise.all( STRUCTURAL_METADATA_FILES.map(async (name) => { const signature = await gitCommonFileSignature(join(entryPath, name)) @@ -53,20 +75,6 @@ export async function snapshotGitCommonEntry( }) ) ]) - if (nextDirSignature === 'missing') { - return ( - previous ?? { - dirSignature: nextDirSignature, - structuralSignatures, - indexSignature: null, - headLogSignature - } - ) - } - const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature - const indexSignature = shouldReadIndex - ? await gitCommonFileSignature(join(entryPath, INDEX_FILE)) - : previous.indexSignature return { dirSignature: nextDirSignature, structuralSignatures, diff --git a/src/main/ipc/worktree-git-common-narrow-watch.ts b/src/main/ipc/worktree-git-common-narrow-watch.ts index b60ac9877e8..99e245998a1 100644 --- a/src/main/ipc/worktree-git-common-narrow-watch.ts +++ b/src/main/ipc/worktree-git-common-narrow-watch.ts @@ -73,6 +73,11 @@ export async function startGitCommonNarrowWatch( .unsubscribe() .catch(() => {}) .then(() => + // Crash fuse tripped: this poller is now the sole change signal until a + // future existence-poll upgrade (follow-up: #17878). Its own per-entry + // dir-signature gate (worktree-git-common-entry-snapshot.ts) already keeps + // an unchanged entry to a single stat, so a fixed `pollIntervalMs` cadence + // stays cheap at high worktree counts without needing to stretch itself. startGitCommonPolling( target.path, onEvents, diff --git a/src/main/ipc/worktree-git-common-polling.test.ts b/src/main/ipc/worktree-git-common-polling.test.ts new file mode 100644 index 00000000000..179b73e2c33 --- /dev/null +++ b/src/main/ipc/worktree-git-common-polling.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, sep } from 'node:path' +import { startGitCommonPolling } from './worktree-git-common-polling' +import type { + WorktreeBasePollEvent, + WorktreePollerWindowVisibility +} from './worktree-base-directory-poller' + +// Why: measure the fan-out this poller issues per scan (peak concurrent `stat` +// calls, `readdir` call count as a proxy for "a tick ran") without depending on +// real disk timing (#17828). `entryZeroStatCalls` tracks every stat under a +// specific pre-existing entry (its dir plus every leaf), used to prove the +// entry-dir signature gate keeps an unchanged entry to one stat per tick. +const { statDelayMs, readdirCalls, concurrency, entryZeroStatCalls } = vi.hoisted(() => ({ + statDelayMs: { current: 0 }, + readdirCalls: { count: 0 }, + concurrency: { current: 0, peak: 0 }, + entryZeroStatCalls: { count: 0 } +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readdir: (...args: Parameters) => { + readdirCalls.count += 1 + return actual.readdir(...args) + }, + stat: async (...args: Parameters) => { + concurrency.current += 1 + concurrency.peak = Math.max(concurrency.peak, concurrency.current) + const path = args[0] + const entryZeroSegment = `${sep}wt-0` + if ( + typeof path === 'string' && + (path.endsWith(entryZeroSegment) || path.includes(`${entryZeroSegment}${sep}`)) + ) { + entryZeroStatCalls.count += 1 + } + try { + if (statDelayMs.current > 0) { + await new Promise((resolve) => setTimeout(resolve, statDelayMs.current)) + } + return await actual.stat(...args) + } finally { + concurrency.current -= 1 + } + } + } +}) + +const alwaysVisible: WorktreePollerWindowVisibility = { + isWindowVisible: () => true, + onWindowBecameVisible: () => () => {} +} + +async function makeCommonDir(entryCount: number): Promise { + const root = await mkdtemp(join(tmpdir(), 'git-common-polling-test-')) + for (let i = 0; i < entryCount; i++) { + const entryPath = join(root, 'worktrees', `wt-${i}`) + await mkdir(join(entryPath, 'logs'), { recursive: true }) + await Promise.all([ + writeFile(join(entryPath, 'HEAD'), 'ref: refs/heads/main\n'), + writeFile(join(entryPath, 'gitdir'), `${join(root, `checkout-${i}`, '.git')}\n`), + writeFile(join(entryPath, 'index'), Buffer.from([0])), + writeFile(join(entryPath, 'logs', 'HEAD'), '0000 aaaa\n') + ]) + } + return root +} + +describe('startGitCommonPolling fan-out bounds (#17828)', () => { + const cleanups: (() => Promise)[] = [] + const dirsToRemove: string[] = [] + + beforeEach(() => { + statDelayMs.current = 0 + readdirCalls.count = 0 + concurrency.current = 0 + concurrency.peak = 0 + entryZeroStatCalls.count = 0 + }) + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + await Promise.all( + dirsToRemove.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) + vi.useRealTimers() + }) + + it('bounds concurrent per-entry stat fan-out regardless of entry count', async () => { + const commonDir = await makeCommonDir(200) + dirsToRemove.push(commonDir) + const sub = await startGitCommonPolling(commonDir, () => {}, 100_000, alwaysVisible) + cleanups.push(() => sub.unsubscribe()) + // 200 entries x ~6 concurrent structural stats each would peak near 1,200 + // unbounded; bounding to 8 in-flight entries keeps the peak independent of + // entry count instead of scaling with it. + expect(concurrency.peak).toBeLessThan(80) + }) + + it('never overlaps a scan with itself even when ticks fire faster than a scan completes', async () => { + const commonDir = await makeCommonDir(10) + dirsToRemove.push(commonDir) + statDelayMs.current = 20 + const pollIntervalMs = 5 + const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible) + cleanups.push(() => sub.unsubscribe()) + readdirCalls.count = 0 + // ~60 would-be 5ms ticks elapse in this window while every stat takes 20ms; + // the ticking guard must serialize scans, not launch overlapping ones. + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(readdirCalls.count).toBeLessThan(10) + }) + + it('costs exactly one stat per tick for an unchanged entry', async () => { + const commonDir = await makeCommonDir(1) + dirsToRemove.push(commonDir) + const pollIntervalMs = 20 + const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible) + cleanups.push(() => sub.unsubscribe()) + + // Let the bootstrap snapshot (which always fully reads every entry once) settle. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + readdirCalls.count = 0 + entryZeroStatCalls.count = 0 + await vi.waitFor( + () => { + expect(readdirCalls.count).toBeGreaterThanOrEqual(5) + }, + { timeout: 2_000 } + ) + // Without the entry-dir signature gate, an unchanged entry still costs ~6 + // stats every tick (HEAD/gitdir/locked/config.worktree/logs/HEAD/index). + // With the gate, only the entry dir itself is stat'd once nothing changed — + // one stat per tick, in lockstep with the readdir tripwire. + expect(entryZeroStatCalls.count).toBeLessThanOrEqual(readdirCalls.count + 1) + expect(entryZeroStatCalls.count).toBeGreaterThanOrEqual(readdirCalls.count - 1) + }) + + it('detects a HEAD rewrite via lock+rename on the next tick', async () => { + const commonDir = await makeCommonDir(1) + dirsToRemove.push(commonDir) + const events: WorktreeBasePollEvent[][] = [] + const pollIntervalMs = 20 + const sub = await startGitCommonPolling( + commonDir, + (batch) => events.push(batch), + pollIntervalMs, + alwaysVisible + ) + cleanups.push(() => sub.unsubscribe()) + // Let the bootstrap snapshot settle before mutating. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + + const entryDir = join(commonDir, 'worktrees', 'wt-0') + const headPath = join(entryDir, 'HEAD') + const headLockPath = join(entryDir, 'HEAD.lock') + // Every real git ref write goes through a lock file + rename inside the entry + // dir (never an in-place overwrite), which moves the entry dir's own signature. + await writeFile(headLockPath, 'ref: refs/heads/feature\n') + await rename(headLockPath, headPath) + + await vi.waitFor( + () => { + expect(events.flat()).toContainEqual({ type: 'update', path: headPath }) + }, + { timeout: pollIntervalMs * 10 } + ) + }) + + it('detects an in-place gitdir rewrite only once the periodic backstop rescans it', async () => { + const commonDir = await makeCommonDir(1) + dirsToRemove.push(commonDir) + const events: WorktreeBasePollEvent[][] = [] + const pollIntervalMs = 10 + const sub = await startGitCommonPolling( + commonDir, + (batch) => events.push(batch), + pollIntervalMs, + alwaysVisible + ) + cleanups.push(() => sub.unsubscribe()) + // Let the bootstrap snapshot settle before mutating. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + + const entryDir = join(commonDir, 'worktrees', 'wt-0') + const gitdirPath = join(entryDir, 'gitdir') + // `gitdir` is the one structural leaf git rewrites in place (worktree move/repair), + // so the entry dir's own signature never moves — the periodic ungated backstop + // (INDEX_BACKSTOP_TICKS = 15) is the only thing that catches it. + await writeFile(gitdirPath, `${join(commonDir, 'checkout-moved', '.git')}\n`) + + // Not caught by the next several ticks: the gate stays closed since nothing + // moved the entry dir's own signature. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs * 5)) + expect(events.flat()).not.toContainEqual({ type: 'update', path: gitdirPath }) + + // Eventually caught regardless of the gate, once tick 15 forces the periodic backstop. + await vi.waitFor( + () => { + expect(events.flat()).toContainEqual({ type: 'update', path: gitdirPath }) + }, + { timeout: pollIntervalMs * 40 } + ) + }) + + it('still detects entry add/remove correctly with bounded concurrency', async () => { + const commonDir = await makeCommonDir(5) + dirsToRemove.push(commonDir) + const events: WorktreeBasePollEvent[][] = [] + const sub = await startGitCommonPolling( + commonDir, + (batch) => events.push(batch), + 20, + alwaysVisible + ) + cleanups.push(() => sub.unsubscribe()) + + const newEntry = join(commonDir, 'worktrees', 'wt-new') + await mkdir(join(newEntry, 'logs'), { recursive: true }) + await writeFile(join(newEntry, 'HEAD'), 'ref: refs/heads/main\n') + + await vi.waitFor(() => { + expect(events.flat()).toContainEqual({ type: 'create', path: newEntry }) + }) + + await rm(newEntry, { recursive: true }) + await vi.waitFor(() => { + expect(events.flat()).toContainEqual({ type: 'delete', path: newEntry }) + }) + }) +}) diff --git a/src/main/ipc/worktree-git-common-polling.ts b/src/main/ipc/worktree-git-common-polling.ts index 0418f4a90fd..4b43835a81f 100644 --- a/src/main/ipc/worktree-git-common-polling.ts +++ b/src/main/ipc/worktree-git-common-polling.ts @@ -1,5 +1,6 @@ import { readdir } from 'node:fs/promises' import { join } from 'node:path' +import { forEachWithConcurrency } from '../../shared/map-with-concurrency' import { PRIMARY_CHECKOUT_METADATA_FILES } from './worktree-git-common-metadata-files' import { diffGitCommon, @@ -23,18 +24,26 @@ import { // same way the base poller's backstop rescan does. const INDEX_BACKSTOP_TICKS = 15 +// Why: an unbounded fan-out across every worktree admin entry queues thousands +// of ops on libuv's 4-thread default pool, starving every other main-process +// fs call for the scan's duration (#17828). 8 mirrors the existing +// head-identity/exact-ref-probe pools — enough to saturate typical local +// disks without monopolizing the pool. Since snapshotGitCommonEntry's own +// entry-dir gate (see worktree-git-common-entry-snapshot.ts) keeps most ticks +// down to 1 stat per unchanged entry, real in-flight is now bounded by this +// limit rather than limit × per-entry stat count. +const GIT_COMMON_SNAPSHOT_CONCURRENCY = 8 + async function snapshotStatusRefSignatures( paths: ReadonlySet ): Promise> { const signatures = new Map() - await Promise.all( - [...paths].map(async (path) => { - const signature = await gitCommonFileSignature(path) - if (signature !== null) { - signatures.set(path, signature) - } - }) - ) + await forEachWithConcurrency([...paths], GIT_COMMON_SNAPSHOT_CONCURRENCY, async (path) => { + const signature = await gitCommonFileSignature(path) + if (signature !== null) { + signatures.set(path, signature) + } + }) return signatures } @@ -91,12 +100,10 @@ async function snapshotGitCommon( } const entries = new Map() - await Promise.all( - entryPaths.map(async (entryPath) => { - const previousEntry = previous?.entries.get(entryPath) - entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan)) - }) - ) + await forEachWithConcurrency(entryPaths, GIT_COMMON_SNAPSHOT_CONCURRENCY, async (entryPath) => { + const previousEntry = previous?.entries.get(entryPath) + entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan)) + }) // Why: the expensive per-entry `index` read stays gated on each entry's own dir signature; onFullScan // now reflects an ungated index-metadata backstop fan-out (forceFullScan) — the real periodic cost — // rather than the always-run worktrees-dir readdir. diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 9de2c8c3392..8ed696872f7 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -60,6 +60,8 @@ export async function startGitCommonWatch( } } } + // Why: Electron only ships darwin/linux/win32, all covered by NARROW_WATCH_PLATFORMS + // above, so this branch is defensive dead code in production, not a reachable fallback. return startGitCommonPolling( target.path, onEvents, From fdfe354045680a01b3743600362fd37262031af4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:20:47 -0700 Subject: [PATCH 36/94] test(relay): bind test WebSocket servers to loopback A control-handshake test that expects a timeout was instead getting 'Unexpected server response: 401' about once in fourteen runs. A slow machine cannot turn a timeout into a 401 -- that needs a real HTTP response, so the connection was reaching a different server. new WebSocketServer({ port: 0 }) binds the wildcard address while the client dials 127.0.0.1. On macOS those differ, and with SO_REUSEADDR a foreign process can hold the more specific 127.0.0.1:P and win the connection. Caught live: a wildcard bind took port 52584, which a running Orca app already held on loopback, and Orca answered the probe. A listener that checks a token answers 401. Ten constructions across seven files now pass host: '127.0.0.1', so the reservation covers the address the client dials and a duplicate bind is refused. Adds a ratchet, because this is not authors forgetting a convention: all 30+ .listen(0, ...) sites already pass '127.0.0.1', while 7 of 7 ws constructions did not. ws accepts { port } alone and binds the wildcard silently, so nothing told them. The guard pins the wildcard count, and pins separately at zero the option shapes it cannot read -- spreads and variable option objects fail rather than being exempted, and a recognized-construction floor catches the matcher going blind, which otherwise reads exactly like a clean tree. mobile/scripts/mock-server.ts stays on the wildcard deliberately: a phone reaches it over the LAN. --- ...bsocket-server-wildcard-bind-allowlist.txt | 14 ++ config/scripts/call-site-option-keys.ts | 204 ++++++++++++++++++ config/scripts/websocket-server-bind-scan.ts | 172 +++++++++++++++ .../websocket-server-loopback-bind.test.ts | 106 +++++++++ .../rpc-client-live-recovery.test.ts | 3 +- .../mobile-relay-e2ee.integration.test.ts | 3 +- .../relay/relay-control-client.test.ts | 7 +- src/main/runtime/rpc/relay-transport.test.ts | 3 +- .../src/web/web-runtime-client.test.ts | 3 +- src/shared/remote-runtime-client.test.ts | 13 +- .../remote-runtime-outbound-admission.test.ts | 3 +- .../remote-runtime-request-connection.test.ts | 3 +- ...mote-runtime-shared-control-test-server.ts | 7 +- ...emote-runtime-subscription-request.test.ts | 3 +- 14 files changed, 529 insertions(+), 15 deletions(-) create mode 100644 config/scripts/__fixtures__/websocket-server-wildcard-bind-allowlist.txt create mode 100644 config/scripts/call-site-option-keys.ts create mode 100644 config/scripts/websocket-server-bind-scan.ts create mode 100644 config/scripts/websocket-server-loopback-bind.test.ts diff --git a/config/scripts/__fixtures__/websocket-server-wildcard-bind-allowlist.txt b/config/scripts/__fixtures__/websocket-server-wildcard-bind-allowlist.txt new file mode 100644 index 00000000000..4b76622a9f2 --- /dev/null +++ b/config/scripts/__fixtures__/websocket-server-wildcard-bind-allowlist.txt @@ -0,0 +1,14 @@ +# Files allowed to construct a `ws` server that binds a port without pinning `host`. +# +# `ws` accepts `{ port }` alone and silently binds the wildcard address. A server +# reached over 127.0.0.1 must pin `host: '127.0.0.1'`, or a foreign loopback +# listener can hold the same port and answer in its place -- which is how +# relay-control-client.test.ts came to fail with a real HTTP 401 in a test that +# was simulating silence. +# +# This list only shrinks. Adding a line also requires raising the pin in +# websocket-server-loopback-bind.test.ts, which is deliberate friction. + +# Deliberate, not drift: this mock is dialled by a phone on the LAN, so it has to +# be reachable on a real interface. A loopback bind would make it unreachable. +mobile/scripts/mock-server.ts diff --git a/config/scripts/call-site-option-keys.ts b/config/scripts/call-site-option-keys.ts new file mode 100644 index 00000000000..dff3a971c45 --- /dev/null +++ b/config/scripts/call-site-option-keys.ts @@ -0,0 +1,204 @@ +/** + * Read the top-level option keys of a call's object-literal argument out of raw + * source text. + * + * Text rather than an AST because typescript@7 no longer ships the classic + * compiler API and every installed parser is a transitive dependency. The + * tradeoff is handled by refusing to guess: any shape this cannot read comes + * back as `unreadable` with a reason, and callers must treat that as a failure + * rather than as an absence of keys. + */ + +export type CallOptionKeys = + | { readonly readable: true; readonly keys: readonly string[] } + | { readonly readable: false; readonly reason: string } + +type ScanState = 'code' | 'line' | 'block' | 'single' | 'double' | 'template' + +function closesString(state: ScanState, current: string): boolean { + return ( + (state === 'single' && current === "'") || + (state === 'double' && current === '"') || + (state === 'template' && current === '`') + ) +} + +function opensNonCode(current: string, next: string | undefined): ScanState | null { + if (current === '/' && next === '/') { + return 'line' + } + if (current === '/' && next === '*') { + return 'block' + } + if (current === "'") { + return 'single' + } + if (current === '"') { + return 'double' + } + if (current === '`') { + return 'template' + } + return null +} + +/** + * Text between an open paren and its match, tracking strings and comments so a + * brace inside either cannot unbalance the count. Null when it never closes. + */ +function balancedArguments(text: string, openIndex: number): string | null { + let depth = 0 + let state: ScanState = 'code' + for (let index = openIndex; index < text.length; index++) { + const current = text[index] + const next = text[index + 1] + if (state === 'code') { + const opened = opensNonCode(current, next) + if (opened) { + state = opened + if (opened === 'line' || opened === 'block') { + index++ + } + } else if (current === '(' || current === '{' || current === '[') { + depth++ + } else if (current === ')' || current === '}' || current === ']') { + depth-- + if (depth === 0) { + return text.slice(openIndex + 1, index) + } + if (depth < 0) { + return null + } + } + continue + } + if (state === 'line') { + if (current === '\n') { + state = 'code' + } + continue + } + if (state === 'block') { + if (current === '*' && next === '/') { + state = 'code' + index++ + } + continue + } + if (current === '\\') { + index++ + continue + } + // Brace tracking inside `${}` would need its own depth; templates never + // appear as options, so report one as unreadable instead of guessing. + if (state === 'template' && current === '$' && next === '{') { + return null + } + if (closesString(state, current)) { + state = 'code' + } + } + return null +} + +/** Keys at depth 0 of an object literal body, with anything non-identifier kept verbatim. */ +function objectLiteralKeys(body: string): string[] { + const keys: string[] = [] + let depth = 0 + let state: ScanState = 'code' + let inValue = false + let token = '' + const flush = (): void => { + const name = token.trim() + token = '' + if (name && depth === 0) { + keys.push(name) + } + } + for (let index = 0; index < body.length; index++) { + const current = body[index] + const next = body[index + 1] + if (state === 'code') { + const opened = opensNonCode(current, next) + if (opened) { + state = opened + if (opened === 'line' || opened === 'block') { + index++ + } + } else if (current === '(' || current === '{' || current === '[') { + depth++ + if (!inValue) { + token += current + } + } else if (current === ')' || current === '}' || current === ']') { + depth-- + if (!inValue) { + token += current + } + } else if (current === ':' && depth === 0 && !inValue) { + flush() + inValue = true + } else if (current === ',' && depth === 0) { + // A shorthand or a spread ends here having never seen a colon. + if (inValue) { + inValue = false + token = '' + } else { + flush() + } + } else if (!inValue) { + token += current + } + continue + } + if (state === 'line') { + if (current === '\n') { + state = 'code' + } + continue + } + if (state === 'block') { + if (current === '*' && next === '/') { + state = 'code' + index++ + } + continue + } + if (current === '\\') { + index++ + continue + } + if (closesString(state, current)) { + state = 'code' + } + } + if (!inValue) { + flush() + } + return keys +} + +/** + * Option keys of the call whose argument list opens at `parenIndex`, or the + * reason the shape could not be read. Spreads and computed keys land in the + * latter: either can carry a key this would otherwise report as absent. + */ +export function readCallOptionKeys(text: string, parenIndex: number): CallOptionKeys { + const args = balancedArguments(text, parenIndex) + if (args === null) { + return { readable: false, reason: 'argument list never closes' } + } + if (!args.trim()) { + return { readable: false, reason: 'called with no options argument' } + } + const trimmed = args.trim() + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { + return { readable: false, reason: 'options are not an object literal' } + } + const keys = objectLiteralKeys(trimmed.slice(1, -1)) + const unreadable = keys.find((key) => !/^[A-Za-z_$][\w$]*$/.test(key)) + if (unreadable !== undefined) { + return { readable: false, reason: `unreadable option key \`${unreadable}\`` } + } + return { readable: true, keys } +} diff --git a/config/scripts/websocket-server-bind-scan.ts b/config/scripts/websocket-server-bind-scan.ts new file mode 100644 index 00000000000..9054f8cc38e --- /dev/null +++ b/config/scripts/websocket-server-bind-scan.ts @@ -0,0 +1,172 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join, relative } from 'node:path' +import { readCallOptionKeys } from './call-site-option-keys' + +/** + * Locate every `new WebSocketServer(...)` in the tree and say, for each, whether + * it pins a bind address. + * + * `ws` accepts `{ port }` alone and silently binds the wildcard address, so a + * server the caller then dials on 127.0.0.1 sits at a port a foreign loopback + * listener can also hold -- and the more specific listener wins the connection, + * answering in that server's place. + * + * Anything unreadable is reported as `opaque` rather than skipped. A matcher + * that silently exempts the shapes it fails to parse is worse than no matcher, + * because it reads as coverage. + */ + +export type BindSite = { path: string; line: number } +export type OpaqueSite = BindSite & { reason: string } + +export type WebSocketServerBindScan = { + filesScanned: number + /** Every construction recognized, however it was then classified. */ + constructions: number + /** Binds a port with no `host`: reachable at an address the dialer never named. */ + wildcardBound: BindSite[] + /** Shape that could not be read; never treated as safe. */ + opaque: OpaqueSite[] + /** Binds a port and pins `host`. */ + loopbackBound: BindSite[] + /** No `port`: attaches to a server that owns the bind itself. */ + attached: BindSite[] +} + +const IGNORED_DIRECTORIES = new Set([ + 'node_modules', + 'dist', + 'out', + 'build', + '.git', + '__fixtures__', + 'coverage', + // Full snapshots of older releases; their bind sites are not this tree's to fix. + '.cross-version-checkouts' +]) +const SCANNED_EXTENSIONS = /\.(?:ts|tsx|mts|cts)$/ +const SCANNED_ROOTS = ['src', 'mobile', 'config', 'tests'] +const WS_IMPORT_HINT = /from\s*['"]ws['"]/ + +function collectSourceFiles(root: string, found: string[] = []): string[] { + let entries: ReturnType> + try { + entries = readdirSync(root, { withFileTypes: true }) + } catch { + return found + } + for (const entry of entries) { + if (IGNORED_DIRECTORIES.has(entry.name)) { + continue + } + const full = join(root, entry.name) + if (entry.isDirectory()) { + collectSourceFiles(full, found) + } else if (SCANNED_EXTENSIONS.test(entry.name)) { + found.push(full) + } + } + return found +} + +/** Local names bound to ws's server class, following `as` aliases and namespace imports. */ +function webSocketServerNames(text: string): { direct: Set; namespaces: Set } { + const direct = new Set() + const namespaces = new Set() + // One statement at a time: a pattern reaching for `from 'ws'` would swallow + // every import above it and lose the specifier names in the blob. + for (const match of text.matchAll(/\bimport\b([\s\S]*?)\bfrom\s*(['"])([^'"]+)\2/g)) { + if (match[3] !== 'ws') { + continue + } + const clause = match[1] + if (/^\s*type\b/.test(clause)) { + continue + } + const namespace = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/) + if (namespace) { + namespaces.add(namespace[1]) + } + const named = clause.match(/\{([\s\S]*)\}/) + if (!named) { + continue + } + for (const specifier of named[1].split(',')) { + const trimmed = specifier.trim() + if (!trimmed || /^type\s/.test(trimmed)) { + continue + } + const parts = trimmed.split(/\s+as\s+/) + // `Server` is ws's own alias for WebSocketServer. + if (parts[0].trim() === 'WebSocketServer' || parts[0].trim() === 'Server') { + direct.add((parts[1] ?? parts[0]).trim()) + } + } + } + return { direct, namespaces } +} + +function classify( + scan: WebSocketServerBindScan, + site: BindSite, + text: string, + paren: number +): void { + const options = readCallOptionKeys(text, paren) + if (!options.readable) { + scan.opaque.push({ ...site, reason: options.reason }) + return + } + if (!options.keys.includes('port')) { + scan.attached.push(site) + return + } + if (!options.keys.includes('host')) { + scan.wildcardBound.push(site) + return + } + scan.loopbackBound.push(site) +} + +export function scanWebSocketServerBinds(repoRoot: string): WebSocketServerBindScan { + const files = SCANNED_ROOTS.flatMap((directory) => collectSourceFiles(join(repoRoot, directory))) + const scan: WebSocketServerBindScan = { + filesScanned: files.length, + constructions: 0, + wildcardBound: [], + opaque: [], + loopbackBound: [], + attached: [] + } + for (const file of files) { + const text = readFileSync(file, 'utf8') + // Filter on the import, not on the class name: `Server as Wss` never spells + // WebSocketServer, and keying on that name silently skipped the whole alias. + if (!WS_IMPORT_HINT.test(text)) { + continue + } + const { direct, namespaces } = webSocketServerNames(text) + if (!direct.size && !namespaces.size) { + continue + } + const path = relative(repoRoot, file).split('\\').join('/') + const patterns = [ + ...[...direct].map((name) => new RegExp(`\\bnew\\s+${name}\\s*\\(`, 'g')), + ...[...namespaces].map( + (name) => new RegExp(`\\bnew\\s+${name}\\.(?:WebSocketServer|Server)\\s*\\(`, 'g') + ) + ] + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + scan.constructions++ + const line = text.slice(0, match.index).split('\n').length + classify(scan, { path, line }, text, match.index + match[0].length - 1) + } + } + } + return scan +} + +export function formatSites(sites: readonly BindSite[]): string[] { + return sites.map((site) => `${site.path}:${site.line}`) +} diff --git a/config/scripts/websocket-server-loopback-bind.test.ts b/config/scripts/websocket-server-loopback-bind.test.ts new file mode 100644 index 00000000000..9f32f8eda1a --- /dev/null +++ b/config/scripts/websocket-server-loopback-bind.test.ts @@ -0,0 +1,106 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { formatSites, scanWebSocketServerBinds } from './websocket-server-bind-scan' + +/** + * Hold the bind address at the tree level rather than per call site. + * + * Every one of the ~30 `.listen(0, ...)` calls in this repo already passes + * '127.0.0.1'; 7 of 7 `new WebSocketServer({ port })` calls did not. Authors know + * the convention -- `ws` just never asks, because `{ port }` alone binds the + * wildcard without a word. That silence is what this test replaces. + * + * The allowlist only shrinks. A new wildcard bind fails here even where it looks + * harmless today, because harmless-looking is exactly what the seven were. + */ +/** The ratchet, held as data so it reads as the list it is. */ +const WILDCARD_BIND_ALLOWLIST: readonly string[] = readFileSync( + join(__dirname, '__fixtures__', 'websocket-server-wildcard-bind-allowlist.txt'), + 'utf8' +) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + +/** + * The true count of constructions that bind a port without pinning a host. + * + * May only ever be DECREASED, and only by pinning a host. Raising it is never + * the fix. + */ +const WILDCARD_BIND_PIN = 1 + +/** + * A floor under the constructions the scanner still recognizes. + * + * This is the guard against the scanner going blind: an import pattern it stops + * following reports zero offenders and reads exactly like a clean tree. During + * development a single wrong regex dropped this from 24 to 3. + */ +const RECOGNIZED_CONSTRUCTION_FLOOR = 20 + +describe('WebSocketServer loopback bind boundary', () => { + const repoRoot = resolve(__dirname, '..', '..') + const scan = scanWebSocketServerBinds(repoRoot) + const offenders = scan.wildcardBound.map((site) => site.path) + + it('scans a plausible number of files', () => { + // A broken root or extension list would make the guard silently vacuous. + expect(scan.filesScanned).toBeGreaterThan(5_000) + }) + + it('still recognizes the known construction sites', () => { + expect( + scan.constructions, + `Only ${scan.constructions} WebSocketServer constructions were recognized; the floor is ` + + `${RECOGNIZED_CONSTRUCTION_FLOOR}. The scanner has probably stopped following an import ` + + 'shape rather than the tree having lost that many servers.' + ).toBeGreaterThanOrEqual(RECOGNIZED_CONSTRUCTION_FLOOR) + }) + + it('can read the options of every construction it found', () => { + // An unreadable shape is never assumed safe: it could be hiding a host, or + // hiding the absence of one. Rewrite it as a plain object literal. + expect( + scan.opaque.map((site) => `${site.path}:${site.line} -- ${site.reason}`), + 'WebSocketServer options that this guard cannot read.' + ).toEqual([]) + }) + + it('has no wildcard-bound server outside the allowlist', () => { + const unlisted = scan.wildcardBound.filter( + (site) => !WILDCARD_BIND_ALLOWLIST.includes(site.path) + ) + expect( + formatSites(unlisted), + "New WebSocketServer that binds a port without a host. Pass host: '127.0.0.1' so a foreign " + + 'loopback listener cannot claim the port and answer in its place.' + ).toEqual([]) + }) + + it('has no stale allowlist entry', () => { + // Why this direction matters too: an entry left behind after the file was + // fixed hides the next regression in that same path. + const stale = WILDCARD_BIND_ALLOWLIST.filter((path) => !offenders.includes(path)) + expect(stale, 'Allowlist entry no longer binds the wildcard — delete the line.').toEqual([]) + }) + + it('holds the wildcard-bind count at the pin', () => { + // Bounding by the allowlist's own length would prove nothing: the two move + // together, so appending a line to silence a failure would keep the bound + // satisfied. The pin is a literal so that widening takes a second edit. + expect( + scan.wildcardBound.length, + `${scan.wildcardBound.length} constructions bind the wildcard; the pin is ` + + `${WILDCARD_BIND_PIN}. Never raise the pin -- pass host: '127.0.0.1' instead.` + ).toBeLessThanOrEqual(WILDCARD_BIND_PIN) + // A pin left above reality is how a ratchet rots: it re-opens room for the + // next wildcard bind to land for free. + expect( + scan.wildcardBound.length, + `Only ${scan.wildcardBound.length} constructions bind the wildcard. Lower ` + + `WILDCARD_BIND_PIN to ${scan.wildcardBound.length} to keep the ground you just took.` + ).toBeGreaterThanOrEqual(WILDCARD_BIND_PIN) + }) +}) diff --git a/mobile/src/transport/rpc-client-live-recovery.test.ts b/mobile/src/transport/rpc-client-live-recovery.test.ts index 471a53be740..bef276f918d 100644 --- a/mobile/src/transport/rpc-client-live-recovery.test.ts +++ b/mobile/src/transport/rpc-client-live-recovery.test.ts @@ -59,7 +59,8 @@ function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null { // fail with EADDRINUSE; the full scenario restarts on the captured port // because the client keeps reconnecting to its original URL. function startServer(port = 0): Promise { - const wss = new WebSocketServer({ port }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const wss = new WebSocketServer({ host: '127.0.0.1', port }) wss.on('connection', (ws: ServerSocket) => { let sharedKey: Uint8Array | null = null let authenticated = false diff --git a/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts b/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts index 1f2b50e0648..bf9ec2ce431 100644 --- a/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts +++ b/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts @@ -61,7 +61,8 @@ describe('desktop relay E2EE integration', () => { }) it('splices a simulated phone through CloudRelayTransport with real NaCl E2EE v2', async () => { - const relay = new WebSocketServer({ port: 0, perMessageDeflate: false }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const relay = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(relay) await new Promise((resolve) => relay.once('listening', resolve)) const address = relay.address() diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index a1ad5c03482..2975b67e631 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -99,7 +99,8 @@ describe('RelayControlClient', () => { }) it('rejects a control handshake that never receives a proof response', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() @@ -129,7 +130,7 @@ describe('RelayControlClient', () => { }) it('settles an opening control immediately when ownership closes', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() @@ -165,7 +166,7 @@ describe('RelayControlClient', () => { }) it('proves the host key and drives control/data commands without URL credentials', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() diff --git a/src/main/runtime/rpc/relay-transport.test.ts b/src/main/runtime/rpc/relay-transport.test.ts index 8b7303ed805..21b520c8c9e 100644 --- a/src/main/runtime/rpc/relay-transport.test.ts +++ b/src/main/runtime/rpc/relay-transport.test.ts @@ -28,7 +28,8 @@ describe('CloudRelayTransport', () => { }) it('authenticates one query-free host-data socket and forwards messages verbatim', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 1aa36f06d0f..7373ede6b8c 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -658,7 +658,8 @@ describe('WebRuntimeClient', () => { vi.stubGlobal('WebSocket', WebSocket) const serverKeys = generateKeyPair() const frame = new Uint8Array([9, 8, 7]) - const wss = new WebSocketServer({ port: 0 }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 }) const sockets = new Set() wss.on('connection', (socket) => { sockets.add(socket) diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index 3e3bba921ea..d7a76417e84 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -539,7 +539,12 @@ async function createSubscriptionServer( const nextAuth = new Promise((resolve) => { resolveAuth = resolve }) - const wss = new WebSocketServer({ port: 0, autoPong: options.disableAutoPong !== true }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const wss = new WebSocketServer({ + host: '127.0.0.1', + port: 0, + autoPong: options.disableAutoPong !== true + }) servers.push(wss) wss.on('connection', (ws) => { @@ -625,7 +630,7 @@ async function createClosingServer( reason: string ): Promise<{ pairing: PairingOffer }> { const serverKeyPair = generateKeyPair() - const wss = new WebSocketServer({ port: 0 }) + const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 }) servers.push(wss) wss.on('connection', (ws) => { ws.close(code, reason) @@ -649,7 +654,7 @@ async function createClosingServer( async function createInvalidHandshakeServer(): Promise<{ pairing: PairingOffer }> { const serverKeyPair = generateKeyPair() - const wss = new WebSocketServer({ port: 0 }) + const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 }) servers.push(wss) wss.on('connection', (ws) => { ws.once('message', () => ws.send(JSON.stringify({ type: 'not_orca' }))) @@ -680,7 +685,7 @@ async function createOneShotServer( } = {} ): Promise<{ pairing: PairingOffer }> { const serverKeyPair = generateKeyPair() - const wss = new WebSocketServer({ port: 0 }) + const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 }) servers.push(wss) wss.on('connection', (ws) => { diff --git a/src/shared/remote-runtime-outbound-admission.test.ts b/src/shared/remote-runtime-outbound-admission.test.ts index f536549ca46..eb5f902a16a 100644 --- a/src/shared/remote-runtime-outbound-admission.test.ts +++ b/src/shared/remote-runtime-outbound-admission.test.ts @@ -352,7 +352,8 @@ describe('remote runtime outbound admission', () => { async function createServer(): Promise<{ pairing: PairingOffer; server: WebSocketServer }> { const keyPair = generateKeyPair() - const server = new WebSocketServer({ port: 0 }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const server = new WebSocketServer({ host: '127.0.0.1', port: 0 }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() as AddressInfo diff --git a/src/shared/remote-runtime-request-connection.test.ts b/src/shared/remote-runtime-request-connection.test.ts index 4d812322ba0..eb8f0b06e89 100644 --- a/src/shared/remote-runtime-request-connection.test.ts +++ b/src/shared/remote-runtime-request-connection.test.ts @@ -98,7 +98,8 @@ async function createServer(): Promise { const requests: unknown[] = [] const auths: unknown[] = [] let connectionCount = 0 - const wss = new WebSocketServer({ port: 0 }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 }) servers.push(wss) wss.on('connection', (ws) => { diff --git a/src/shared/remote-runtime-shared-control-test-server.ts b/src/shared/remote-runtime-shared-control-test-server.ts index 33b535c8405..0e4adb1a69c 100644 --- a/src/shared/remote-runtime-shared-control-test-server.ts +++ b/src/shared/remote-runtime-shared-control-test-server.ts @@ -60,7 +60,12 @@ export async function createSharedControlTestServer( const delayedResponses: (() => void)[] = [] let connectionCount = 0 let closedAfterFirstStreamingResponse = false - const wss = new WebSocketServer({ port: 0, autoPong: options.disableAutoPong !== true }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const wss = new WebSocketServer({ + host: '127.0.0.1', + port: 0, + autoPong: options.disableAutoPong !== true + }) servers.push(wss) wss.on('connection', (ws) => { diff --git a/src/shared/remote-runtime-subscription-request.test.ts b/src/shared/remote-runtime-subscription-request.test.ts index dabc51e6a24..14f904308cc 100644 --- a/src/shared/remote-runtime-subscription-request.test.ts +++ b/src/shared/remote-runtime-subscription-request.test.ts @@ -262,7 +262,8 @@ async function createServer(options: ServerOptions = {}): Promise<{ const nextRequest = new Promise((resolve) => { resolveRequest = resolve }) - const wss = new WebSocketServer({ port: 0 }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 }) servers.push(wss) wss.on('connection', (ws) => { let sharedKey: Uint8Array | null = null From d7123591cebd103658c6d5c8f601eebe1dc0cb3e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:06:44 -0700 Subject: [PATCH 37/94] perf(git): pack the loose refs Orca's own fetches leave behind (#17857) * perf(git): pack the loose refs Orca's own fetches leave behind Orca strips git's auto-maintenance off every fetch it issues (GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS) and never compensated, so nothing in an Orca-driven checkout ever packs refs. One real machine reached 36,574 loose refs, where `git show-ref -- main` costs 5.2s and every worktree create pays for it. Add an idle-time, per-repo `git pack-refs --all --prune`, armed by the fetches that create the debt. It runs only after ten minutes of quiet on that repo, only above 1000 loose refs (probed with a walk bounded by that threshold, not by the backlog), one at a time across the whole app, at the background admission tier, and never while an agent is working, a create is prepared or in flight, a worktree removal is deleting refs, the app is quitting, or the machine is on battery. A user who set `maintenance.auto=false` or `gc.auto=0` has opted out. Measured on a 36,001-loose-ref fixture (macOS/APFS, git 2.44): `show-ref` 5.5-12.2s -> 30-49ms, `for-each-ref` 4.0-10.8s -> 43-48ms. Also fixes a pre-existing bug the split exposed: `--path-format=absolute` is ignored before git 2.31, and taking rev-parse's stdout raw collapsed every repo on such a host onto one fetch-serialization key. Refs #17828 * perf(git): make idle ref maintenance preemptible and cheaper to probe The idle veto was one-directional: it stopped a pack from starting during a create, removal, or agent work, but nothing stopped those from starting during a pack. A user-clicked Fetch, a branch delete, or a worktree removal that needed `packed-refs.lock` mid-rewrite could fail with `unable to create packed-refs.lock` -- a git error with no visible cause. Make the pack cancellable end to end. An AbortSignal now reaches the `pack-refs` child and both pre-pack probes, and `pause()` aborts what is running, waits for it to actually stop, and holds a suspension count so nothing new starts until the caller releases. Every entry point that deletes a ref takes that pause: gitFetch, gitPull, gitFastForward, removeWorktree, forceDeleteLocalBranch, prepareWorktreeCreateCheckout, addWorktree. Five more triggers close the rest of the window: battery drop, window focus, quit, the attempt deadline, and any other git command queueing for an admission slot. Judge a pack by re-probing the backlog rather than by the child's exit code. Measured in the field: another Orca session moved a branch mid-pack, git reported `cannot lock ref`, skipped that ref and packed the rest -- 36,688 loose refs down to 3. On a machine running several sessions that is the normal case, and retrying it would be wrong. Probe with one batched `readdir` per directory instead of streaming `opendir`, which issues a thread-pool round trip every 32 entries: 177ms -> 23ms on a real 36,600-ref repository, with half the event-loop lag. The walk stays strictly sequential so it can never occupy more than one of libuv's four filesystem threads. `PackRefsLockOwnership` makes a lock left by SIGKILL attributable, and only reclaims one when a marker exists, the lock is older than any pack-refs could run for, and the recorded process is gone. Refs #17828 * fix(git): wait out the packed-refs lock instead of killing the pack Measured on Git 2.55/APFS with 37k loose refs: a full `pack-refs --all --prune` takes 23-32s but holds `packed-refs.lock` for only 0.03-1.37s of it. The other ~95% is the prune phase, during which a concurrent `fetch --prune`, `branch -D` or `update-ref` succeeds every time -- per-ref locks last microseconds and git retries for `core.filesRefLockTimeout`. So the abort-on-everything design was strictly harmful. SIGTERM into the prune loop strands an empty `refs/**/*.lock` about one time in five (9/30, 5/40, 6/30 kills): `tempfile.c` opens the lock O_EXCL before `activate_tempfile()` links it into the list the signal handler walks, and a pack does ~36k lock cycles. Afterwards `update-ref -d` on that ref fails with `cannot lock ref ... File exists`, permanently. On Windows `taskkill /f` never runs git's handlers at all, so an abort inside the rewrite strands `packed-refs.lock` every time. Never signal the child. `packRefs` no longer takes an abort signal; it polls `packed-refs.lock` and reports the window through a `PackedRefsLockReporter`. `pause()` resolves when the lock is released -- bounded, and free during the prune -- while the suspension counter still blocks new attempts. Battery and window-focus become do-not-start rather than stop-what-is-running, and quit waits for the lock and lets the child finish orphaned. For strands that already exist, `PackRefsLockOwnership` now also reclaims `refs/**/*.lock` under the same three conditions plus a 0-byte check, and a lock carrying our own not-yet-reclaimable marker records `locked` with a 30min retry instead of the 6h failure cooldown -- so a Windows strand self-heals in half an hour rather than six. Reverts the git admission-scheduler event bus, which existed only to drive the abort this removes. Refs #17828 * test(git): make the ref-maintenance waits survive a loaded runner CI shard 4/8 failed on `restarts every armed countdown when the user does ref work themselves`, which passes locally. The `until()` helper spun a fixed 200 event-loop turns and then returned silently, so on a contended runner the filesystem probe had not finished and the assertion that followed failed with an unrelated message. Bound the wait by wall clock instead and throw a named error, which immediately exposed a second latent bug: the single-flight test's second wait could never succeed, because the deferred repo's retry is on a faked `setTimeout` that spinning the real loop never advances. It had been passing only because the old helper gave up quietly. Add a timer-aware variant for those, and have the countdown test await a signal the fake pack resolves rather than polling at all. Verified stable across five sequential runs and once under load average 32 with six concurrent suites. Refs #17828 --- src/main/agent-awake-service.ts | 5 + src/main/cli/cli-command-installation.ts | 8 +- src/main/cli/cli-installer.ts | 8 +- src/main/cli/wsl-cli-installer.ts | 4 +- src/main/git/canonical-repo-key.test.ts | 78 +++ src/main/git/canonical-repo-key.ts | 72 +++ .../git/local-repo-ref-maintenance.test.ts | 182 ++++++ src/main/git/local-repo-ref-maintenance.ts | 272 +++++++++ src/main/git/pack-refs-lock-ownership.test.ts | 192 +++++++ src/main/git/pack-refs-lock-ownership.ts | 202 +++++++ src/main/git/remote.ts | 56 +- .../git/repo-ref-maintenance-real-git.test.ts | 290 ++++++++++ src/main/git/worktree-add.ts | 21 +- src/main/git/worktree-branch-removal.ts | 7 +- src/main/git/worktree-create-preparation.ts | 81 +-- src/main/git/worktree-removal.ts | 10 +- src/main/ipc/repos-create.test.ts | 5 +- src/main/ipc/worktrees.ts | 7 +- .../ipc/worktrees/worktree-ipc-context.ts | 15 + src/main/repo-maintenance-idle-gate.test.ts | 134 +++++ src/main/repo-maintenance-idle-gate.ts | 67 +++ src/main/runtime/fetch-remote-cache.test.ts | 8 +- .../runtime-remote-fetch-controller.ts | 56 +- ...ntime-remote-fetch-ref-maintenance.test.ts | 145 +++++ src/main/startup/main-process-observers.ts | 5 + src/main/startup/main-process-quit.ts | 18 + src/main/startup/main-process-state.ts | 2 + src/main/worktree-create-preparation.ts | 5 + src/shared/git-binary-compatibility.test.ts | 33 ++ src/shared/loose-ref-count.test.ts | 119 ++++ src/shared/loose-ref-count.ts | 80 +++ src/shared/packed-refs-lock-gate.ts | 43 ++ src/shared/repo-ref-maintenance-policy.ts | 166 ++++++ src/shared/repo-ref-maintenance.test.ts | 530 ++++++++++++++++++ src/shared/repo-ref-maintenance.ts | 366 ++++++++++++ 35 files changed, 3197 insertions(+), 95 deletions(-) create mode 100644 src/main/git/canonical-repo-key.test.ts create mode 100644 src/main/git/canonical-repo-key.ts create mode 100644 src/main/git/local-repo-ref-maintenance.test.ts create mode 100644 src/main/git/local-repo-ref-maintenance.ts create mode 100644 src/main/git/pack-refs-lock-ownership.test.ts create mode 100644 src/main/git/pack-refs-lock-ownership.ts create mode 100644 src/main/git/repo-ref-maintenance-real-git.test.ts create mode 100644 src/main/repo-maintenance-idle-gate.test.ts create mode 100644 src/main/repo-maintenance-idle-gate.ts create mode 100644 src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts create mode 100644 src/shared/loose-ref-count.test.ts create mode 100644 src/shared/loose-ref-count.ts create mode 100644 src/shared/packed-refs-lock-gate.ts create mode 100644 src/shared/repo-ref-maintenance-policy.ts create mode 100644 src/shared/repo-ref-maintenance.test.ts create mode 100644 src/shared/repo-ref-maintenance.ts diff --git a/src/main/agent-awake-service.ts b/src/main/agent-awake-service.ts index 29e866d27f2..b79612e2b9c 100644 --- a/src/main/agent-awake-service.ts +++ b/src/main/agent-awake-service.ts @@ -117,6 +117,11 @@ export class AgentAwakeService { } } + /** Agents this runtime has seen working recently, independent of the awake setting. */ + getWorkingAgentCount(): number { + return this.getEligibleRunningStatusCount() + } + subscribe(listener: (status: ComputerAwakeStatus) => void): () => void { this.statusListeners.add(listener) return () => this.statusListeners.delete(listener) diff --git a/src/main/cli/cli-command-installation.ts b/src/main/cli/cli-command-installation.ts index 5b277bd8c62..fbabc3bf6dd 100644 --- a/src/main/cli/cli-command-installation.ts +++ b/src/main/cli/cli-command-installation.ts @@ -35,7 +35,9 @@ export class CliCommandInstallation extends CliCommandInspection { const inspected = await this.inspectStableSymlink(commandPath, launcherPath) if (inspected.status.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.`) + throw new Error( + `Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.` + ) } if (inspected.status.state === 'installed') { return @@ -54,7 +56,9 @@ export class CliCommandInstallation extends CliCommandInspection { if (!(await capturedExpectedEntry(quarantine, inspected))) { await this.restoreQuarantinedCommand(quarantine, commandPath) - throw new Error(`Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.`) + throw new Error( + `Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.` + ) } try { diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 3c832df5078..d95e1649ac0 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -116,7 +116,9 @@ export class CliInstaller extends CliPathRegistration { throw new Error(initialStatus.detail ?? 'CLI registration is unavailable on this build.') } if (initialStatus.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${initialStatus.commandPath}. Remove it and register again if it is no longer needed.`) + throw new Error( + `Refusing to replace non-Orca command at ${initialStatus.commandPath}. Remove it and register again if it is no longer needed.` + ) } const extractedRoot = await this.ensureLinuxAppImagePayload() const status = extractedRoot @@ -126,7 +128,9 @@ export class CliInstaller extends CliPathRegistration { throw new Error(status.detail ?? 'CLI registration is unavailable on this build.') } if (status.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.`) + throw new Error( + `Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.` + ) } // eslint-disable-next-line unicorn/prefer-ternary -- Why: the install path performs async side effects and is easier to audit as an explicit branch than as an awaited ternary. diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index f8362fddc66..484ed4f9bc3 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -207,7 +207,9 @@ export class WslCliInstaller { throw new Error(status.detail ?? 'WSL CLI registration is unavailable.') } if (status.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.`) + throw new Error( + `Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.` + ) } await this.run( diff --git a/src/main/git/canonical-repo-key.test.ts b/src/main/git/canonical-repo-key.test.ts new file mode 100644 index 00000000000..87965bcaed6 --- /dev/null +++ b/src/main/git/canonical-repo-key.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) + +vi.mock('./runner', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { + _resetCanonicalRepoKeyCacheForTests, + getCanonicalRepoKey, + readGitCommonDir +} from './canonical-repo-key' + +beforeEach(() => { + _resetCanonicalRepoKeyCacheForTests() + gitExecFileAsyncMock.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('readGitCommonDir', () => { + it('reads the absolute answer modern Git gives', () => { + expect(readGitCommonDir('/repo/.git\n', '/repo/worktrees/a')).toBe('/repo/.git') + }) + + it('drops the flag Git older than 2.31 echoes back, and resolves the relative answer', () => { + // Without this every repository on such a host would answer `.git` and collide. + expect(readGitCommonDir('--path-format=absolute\n.git\n', '/repo')).toBe('/repo/.git') + }) + + it('resolves a WSL answer in Git execution space, not against the UNC path', () => { + expect(readGitCommonDir('.git\n', '//wsl$/Ubuntu/home/dev/repo')).toBe('/home/dev/repo/.git') + }) + + it('tolerates CRLF and blank lines', () => { + expect(readGitCommonDir('\r\n/repo/.git\r\n', '/repo')).toBe('/repo/.git') + }) + + it('returns undefined when Git printed nothing usable', () => { + expect(readGitCommonDir('\n', '/repo')).toBeUndefined() + }) +}) + +describe('getCanonicalRepoKey', () => { + it('gives every worktree of one repository the same key', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '/repo/.git\n', stderr: '' }) + + await expect(getCanonicalRepoKey('/repo')).resolves.toBe('local::/repo/.git') + await expect(getCanonicalRepoKey('/repo/worktrees/a')).resolves.toBe('local::/repo/.git') + }) + + it('scopes the key to the execution host', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '/home/dev/repo/.git\n', stderr: '' }) + + await expect( + getCanonicalRepoKey('//wsl$/Ubuntu/home/dev/repo', { wslDistro: 'Ubuntu' }) + ).resolves.toBe('wsl:Ubuntu::/home/dev/repo/.git') + }) + + it('caches so repeated arming costs no subprocess', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '/repo/.git\n', stderr: '' }) + + await getCanonicalRepoKey('/repo') + await getCanonicalRepoKey('/repo') + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('falls back to the caller path when Git cannot answer', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('not a git repository')) + + await expect(getCanonicalRepoKey('/not-a-repo')).resolves.toBe('local::/not-a-repo') + }) +}) diff --git a/src/main/git/canonical-repo-key.ts b/src/main/git/canonical-repo-key.ts new file mode 100644 index 00000000000..1b06423bdc9 --- /dev/null +++ b/src/main/git/canonical-repo-key.ts @@ -0,0 +1,72 @@ +import { toWslExecutionSpace } from '../../shared/wsl-paths' +import { gitExecFileAsync } from './runner' +import { resolveRevParsePath } from './worktree-path-comparison' + +/** + * One repository on one execution host, named by its Git common dir. + * + * Shared by the fetch controller (which serializes fetches on it) and idle ref + * maintenance (which scopes all of its state to it), so both agree on what "the + * same repo" means across every worktree that points at it. + */ + +export type CanonicalRepoKeyOptions = { wslDistro?: string } + +const CACHE_MAX = 512 +const cache = new Map() + +/** + * Git < 2.31 ignores `--path-format=absolute`: it echoes the unrecognized flag, + * exits 0, and prints a relative `.git`. Taking the raw stdout there would give + * every repository on the host the same key. + */ +export function readGitCommonDir(stdout: string, repoPath: string): string | undefined { + const commonDir = stdout + .split('\n') + .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) + .findLast((line) => line.length > 0 && !line.startsWith('-')) + return commonDir ? resolveRevParsePath(toWslExecutionSpace(repoPath), commonDir) : undefined +} + +function remember(cacheKey: string, value: string): string { + cache.delete(cacheKey) + cache.set(cacheKey, value) + while (cache.size > CACHE_MAX) { + const oldest = cache.keys().next() + if (oldest.done) { + break + } + cache.delete(oldest.value) + } + return value +} + +/** `${runtimeKey}::${gitCommonDir}`, falling back to the caller's path. */ +export async function getCanonicalRepoKey( + repoPath: string, + options: CanonicalRepoKeyOptions = {} +): Promise { + const runtimeKey = options.wslDistro ? `wsl:${options.wslDistro}` : 'local' + const cacheKey = `${runtimeKey}::${repoPath}` + const cached = cache.get(cacheKey) + if (cached !== undefined) { + return remember(cacheKey, cached) + } + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--path-format=absolute', '--git-common-dir'], + { cwd: repoPath, ...options } + ) + const commonDir = readGitCommonDir(stdout, repoPath) + if (commonDir) { + return remember(cacheKey, `${runtimeKey}::${commonDir}`) + } + } catch { + // The caller path remains a safe serialization key when canonicalization fails. + } + return remember(cacheKey, cacheKey) +} + +export function _resetCanonicalRepoKeyCacheForTests(): void { + cache.clear() +} diff --git a/src/main/git/local-repo-ref-maintenance.test.ts b/src/main/git/local-repo-ref-maintenance.test.ts new file mode 100644 index 00000000000..f6a411733c3 --- /dev/null +++ b/src/main/git/local-repo-ref-maintenance.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) +const readRepoCommonDirFromGitMock = vi.hoisted(() => vi.fn()) + +vi.mock('./runner', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('./worktree-list-reader', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + readRepoCommonDirFromGit: readRepoCommonDirFromGitMock +})) + +import { _resetCanonicalRepoKeyCacheForTests } from './canonical-repo-key' +import { + _resetLocalRepoRefMaintenanceForTests, + armLocalRepoRefMaintenance, + createLocalRepoRefMaintenanceTarget, + getLocalRepoRefMaintenance, + setRepoMaintenanceActivityProbe, + withRepoRefMaintenancePaused +} from './local-repo-ref-maintenance' + +const NO_ABORT = new AbortController().signal + +function target(wslDistro?: string): ReturnType { + return createLocalRepoRefMaintenanceTarget({ + key: 'local::/repo/.git', + repoPath: wslDistro ? '//wsl$/Ubuntu/home/dev/repo' : '/repo', + ...(wslDistro ? { wslDistro } : {}) + }) +} + +beforeEach(() => { + gitExecFileAsyncMock.mockReset() + readRepoCommonDirFromGitMock.mockReset() + delete process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE + _resetCanonicalRepoKeyCacheForTests() + _resetLocalRepoRefMaintenanceForTests() +}) + +afterEach(() => { + delete process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE + _resetLocalRepoRefMaintenanceForTests() + vi.restoreAllMocks() +}) + +describe('local repo ref maintenance target', () => { + it('never hands the pack child an abort signal', async () => { + // Killing a `pack-refs` strands a `refs/**` lock about one time in five, and + // on Windows a force-kill inside the rewrite strands `packed-refs.lock` + // every time. The child must always be allowed to finish. + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await target().packRefs({ setHeld: () => {} }) + + const packCall = gitExecFileAsyncMock.mock.calls.find( + ([argv]) => (argv as string[])[0] === 'pack-refs' + ) + expect(packCall?.[1]).not.toHaveProperty('signal') + }) + + it('runs pack-refs at the background tier with a long deadline', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await target().packRefs({ setHeld: () => {} }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['pack-refs', '--all', '--prune'], + expect.objectContaining({ cwd: '/repo', admissionTier: 'background', timeout: 15 * 60_000 }) + ) + }) + + it('reads either Git auto-maintenance opt-out, and unset keys as consent', async () => { + for (const stdout of [ + 'maintenance.auto false\n', + 'gc.auto 0\n', + 'gc.auto 6700\nmaintenance.auto false\n' + ]) { + gitExecFileAsyncMock.mockResolvedValue({ stdout, stderr: '' }) + await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(true) + } + + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'maintenance.auto true\ngc.auto 6700\n', + stderr: '' + }) + await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false) + + // `git config --get-regexp` exits non-zero when nothing matches. + gitExecFileAsyncMock.mockRejectedValue(new Error('exit 1')) + await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false) + }) + + it('walks the POSIX refs directory for a native repo', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + + await expect(target().resolveRefsDirectory(NO_ABORT)).resolves.toBe('/repo/.git/refs') + }) + + it('translates a WSL repo answer back to the UNC path the main process can open', async () => { + // Git answers in its own execution space, which for WSL is a Linux path. + readRepoCommonDirFromGitMock.mockResolvedValue('/home/dev/repo/.git') + + await expect(target('Ubuntu').resolveRefsDirectory(NO_ABORT)).resolves.toBe( + '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo\\.git\\refs' + ) + }) + + it('reports an unresolvable repository rather than guessing a path', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue(undefined) + + await expect(target().resolveRefsDirectory(NO_ABORT)).resolves.toBeUndefined() + }) +}) + +describe('local repo ref maintenance scheduling', () => { + it('schedules nothing when the kill switch is set', () => { + process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE = '1' + const arm = vi.spyOn(getLocalRepoRefMaintenance(), 'arm') + + armLocalRepoRefMaintenance({ key: 'local::/repo/.git', repoPath: '/repo' }) + + expect(arm).not.toHaveBeenCalled() + }) + + it('arms through the shared single-flight instance otherwise', () => { + const arm = vi.spyOn(getLocalRepoRefMaintenance(), 'arm') + + armLocalRepoRefMaintenance({ key: 'local::/repo/.git', repoPath: '/repo' }) + + expect(arm).toHaveBeenCalledTimes(1) + }) + + it('is free when nothing has ever been armed', async () => { + // The common case by far: no timers, no instance, no reason to pay anything. + await expect(withRepoRefMaintenancePaused('git-fetch', async () => 'done')).resolves.toBe( + 'done' + ) + }) + + it('holds the window shut for the duration of ref-touching work', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + _resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: 1, looseRefThreshold: 0 }) + setRepoMaintenanceActivityProbe(() => false) + const maintenance = getLocalRepoRefMaintenance() + const packRefs = vi.fn(async () => {}) + maintenance.arm({ + key: 'local::/repo/.git', + resolveRefsDirectory: async () => '/repo/.git/refs', + packRefs + }) + + await withRepoRefMaintenancePaused('branch-delete', async () => { + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(packRefs).not.toHaveBeenCalled() + }) + + await vi.waitFor(() => expect(packRefs).toHaveBeenCalledTimes(1)) + }) + + it('routes the app activity probe into the shared instance', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + let busy = true + setRepoMaintenanceActivityProbe(() => busy) + const maintenance = getLocalRepoRefMaintenance() + const packRefs = vi.fn(async () => {}) + + maintenance.arm({ + key: 'local::/repo/.git', + resolveRefsDirectory: async () => '/repo/.git/refs', + packRefs + }) + await maintenance.whenAttemptSettled() + + expect(packRefs).not.toHaveBeenCalled() + busy = false + }) +}) diff --git a/src/main/git/local-repo-ref-maintenance.ts b/src/main/git/local-repo-ref-maintenance.ts new file mode 100644 index 00000000000..e84f7d1ae30 --- /dev/null +++ b/src/main/git/local-repo-ref-maintenance.ts @@ -0,0 +1,272 @@ +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance' +import { + PACK_REFS_ARGS, + PACK_REFS_TIMEOUT_MS, + RefMaintenanceRepoLocked, + type PackedRefsLockReporter, + type RepoRefMaintenanceOptions, + type RepoRefMaintenanceTarget +} from '../../shared/repo-ref-maintenance-policy' +import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' +import { withSpan } from '../observability/tracer' +import { PackRefsLockOwnership } from './pack-refs-lock-ownership' +import { gitExecFileAsync } from './runner' +import { readRepoCommonDirFromGit } from './worktree-list-reader' + +/** + * Main-process wiring for idle loose-ref packing on the local execution host + * (native and WSL). + * + * SSH-hosted repos are deliberately out of scope: the execution host owns + * anything that touches execution, so maintaining them means running host-side + * on the relay, which today has neither admission control nor spans. Keying all + * state by execution host is what keeps this path from reaching across. + */ + +export type RepoMaintenanceActivityProbe = () => boolean + +const REPO_BUSY_PROBE_MAX = 64 + +let activityProbe: RepoMaintenanceActivityProbe | null = null +let shared: RepoRefMaintenance | null = null +// Why keyed here rather than captured in the target: a repo can be armed from +// the fetch controller or from a user-initiated fetch, and every arming must see +// the same "this repo has work in flight" answer, not whichever closure was last. +const repoBusyProbes = new Map boolean>() + +/** Register the owner of "this repo has a fetch in flight" for `key`. */ +export function setRepoRefMaintenanceBusyProbe(key: string, probe: () => boolean): void { + repoBusyProbes.delete(key) + repoBusyProbes.set(key, probe) + while (repoBusyProbes.size > REPO_BUSY_PROBE_MAX) { + const oldest = repoBusyProbes.keys().next() + if (oldest.done) { + break + } + repoBusyProbes.delete(oldest.value) + } +} + +/** + * Register the app-wide "do not start maintenance now" signal. Owned by the + * main entry point because the inputs (live agents, battery, quit) are not + * visible from the git layer. + */ +export function setRepoMaintenanceActivityProbe(probe: RepoMaintenanceActivityProbe | null): void { + activityProbe = probe +} + +/** Support escape hatch: kills the sweep without touching the user's git config. */ +function isDisabled(): boolean { + return process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE === '1' +} + +function localMaintenanceOptions(): RepoRefMaintenanceOptions { + return { + // Fail closed: without the app-level gate installed we cannot see agents, + // creates, or battery, and running blind is worse than not running. + isBusy: () => activityProbe?.() ?? true, + observe: (attempt) => + withSpan('repo.ref_maintenance', (span) => attempt(span), { + attributes: { kind: 'git', 'repo.maintenance_host': 'local' } + }), + onError: (error) => { + console.warn('[repo-ref-maintenance] attempt failed:', error) + } + } +} + +export function getLocalRepoRefMaintenance(): RepoRefMaintenance { + shared ??= new RepoRefMaintenance(localMaintenanceOptions()) + return shared +} + +/** + * Cancels every armed timer and waits out any `packed-refs` rewrite in progress. + * + * Deliberately does not kill the child. A pack orphaned by the app quitting + * finishes on its own; a pack signalled mid-prune strands a ref lock about one + * time in five, and on Windows a force-kill inside the rewrite strands + * `packed-refs.lock` every time -- which blocks every later ref deletion. + */ +export function disposeLocalRepoRefMaintenance(): Promise { + const settling = shared?.awaitPackedRefsLockRelease() ?? Promise.resolve() + shared?.dispose() + shared = null + repoBusyProbes.clear() + return settling +} + +/** + * Hold every repository open while `run` touches refs. + * + * A ref deletion needs `packed-refs.lock`, which a running pack holds only while + * it rewrites the file -- 0.03-1.37s of a 23-32s run. Waiting that out turns the + * collision into a short pause. Cancelling the pack instead would strand a + * `refs/**` lock about one time in five, which Git never clears, so the ref + * stays undeletable indefinitely. + */ +export async function withRepoRefMaintenancePaused( + reason: string, + run: () => Promise +): Promise { + // Taken unconditionally rather than only when something is already armed: a + // fetch inside `run` can arm the sweep, and one counter bump against an idle + // instance costs a microtask. This can rebuild the instance after the + // quit-time dispose; harmless, because a fresh one has no armed timers and its + // activity probe is gone, so it fails closed. + const release = await getLocalRepoRefMaintenance().pause(reason) + try { + return await run() + } finally { + release() + } +} + +/** Wait out a `packed-refs` rewrite without holding the window open. For shutdown. */ +export function awaitPackedRefsLockRelease(): Promise { + return shared ? shared.awaitPackedRefsLockRelease() : Promise.resolve() +} + +/** + * Count user-initiated ref work as activity and restart every armed countdown. + * + * Deliberately not keyed to a repo: resolving one would cost a `rev-parse` on a + * path the user is waiting on, and a manual fetch or pull says the user is at + * the keyboard, which is a reason to defer every repository. + */ +export function postponeRepoRefMaintenance(): void { + shared?.postponeAll() +} + +/** `overrides` preseeds the shared instance so a test can shorten the quiet period. */ +export function _resetLocalRepoRefMaintenanceForTests( + overrides?: Partial +): void { + shared?.dispose() + shared = overrides ? new RepoRefMaintenance({ ...localMaintenanceOptions(), ...overrides }) : null + activityProbe = null + repoBusyProbes.clear() +} + +/** + * Git reports the common dir in its own execution space, so a WSL repo answers + * with a Linux path the Windows main process cannot open. Translate it back to + * the UNC spelling for the dirent walk; the walk reads directories, not files, + * so the handful of round trips stays cheap even over the share. + */ +function refsDirectoryForMainProcess(commonDir: string, wslDistro: string | undefined): string { + if (wslDistro && !isWslUncPath(commonDir) && !isWindowsAbsolutePathLike(commonDir)) { + return win32.join(toWindowsWslPath(commonDir, wslDistro), 'refs') + } + // Decided by path syntax, not by platform: `win32.isAbsolute` accepts POSIX paths too. + return (isWindowsAbsolutePathLike(commonDir) ? win32 : posix).join(commonDir, 'refs') +} + +/** + * `maintenance.auto=false` and `gc.auto=0` are the two knobs a user reaches for + * to tell Git to stop maintaining a repository on its own. Orca sets both on its + * own fetches, but only as per-invocation `-c` flags, so this probe sees the + * user's persisted config and never Orca's own suppression. + */ +export function isGitAutoMaintenanceDisabled(configOutput: string): boolean { + return configOutput + .split('\n') + .map((line) => line.trim()) + .some((line) => line === 'maintenance.auto false' || line === 'gc.auto 0') +} + +/** + * The common dir in the spelling the main process can open. + * + * Derived from the converted refs path, not the raw one: a WSL answer arrives as + * a Linux path but converts to a UNC path with no `/` in it, so choosing the + * path flavour before conversion collapses the whole thing to `.`. + */ +function gitCommonDirForMainProcess(commonDir: string, wslDistro: string | undefined): string { + const refs = refsDirectoryForMainProcess(commonDir, wslDistro) + return (isWindowsAbsolutePathLike(refs) ? win32 : posix).dirname(refs) +} + +export type LocalRepoRefMaintenanceTargetArgs = { + /** `${runtimeKey}::${gitCommonDir}` -- already scoped to the execution host. */ + readonly key: string + readonly repoPath: string + readonly wslDistro?: string +} + +/** + * Record a write to this repo and restart its quiet-period countdown. The only + * entry point callers need: the kill switch is honoured before anything is + * scheduled, so a disabled build arms no timers at all. + */ +export function armLocalRepoRefMaintenance(args: LocalRepoRefMaintenanceTargetArgs): void { + if (isDisabled()) { + return + } + getLocalRepoRefMaintenance().arm(createLocalRepoRefMaintenanceTarget(args)) +} + +export function createLocalRepoRefMaintenanceTarget( + args: LocalRepoRefMaintenanceTargetArgs +): RepoRefMaintenanceTarget { + const gitOptions = args.wslDistro ? { wslDistro: args.wslDistro } : {} + // The engine always probes before it packs, so the pack reuses this answer + // rather than spending a second rev-parse on the same repository. + let commonDir: string | undefined + const resolveCommonDir = async (signal?: AbortSignal): Promise => { + commonDir ??= await readRepoCommonDirFromGit(args.repoPath, { + ...gitOptions, + ...(signal ? { signal } : {}) + }) + return commonDir + } + return { + key: args.key, + isBusy: () => repoBusyProbes.get(args.key)?.() ?? false, + async resolveRefsDirectory(signal: AbortSignal) { + const resolved = await resolveCommonDir(signal) + return resolved ? refsDirectoryForMainProcess(resolved, args.wslDistro) : undefined + }, + async isOptedOut(signal: AbortSignal) { + try { + const { stdout } = await gitExecFileAsync( + ['config', '--get-regexp', '^(maintenance\\.auto|gc\\.auto)$'], + { cwd: args.repoPath, ...gitOptions, admissionTier: 'background', signal } + ) + return isGitAutoMaintenanceDisabled(stdout) + } catch { + // Neither key set is the common case and exits non-zero; that is consent. + return false + } + }, + async packRefs(lock: PackedRefsLockReporter) { + const resolved = await resolveCommonDir() + const owner = resolved + ? new PackRefsLockOwnership(gitCommonDirForMainProcess(resolved, args.wslDistro)) + : null + const claim = owner ? await owner.claim() : { ok: true as const } + if (!claim.ok) { + throw new RefMaintenanceRepoLocked(claim.reason) + } + // Report the rewrite window rather than accepting a signal. A pack that is + // killed mid-prune strands a `refs/**` lock about one time in five, and + // Git never clears those; waiting out the window costs at most ~1.4s. + const watch = owner?.watchLock((held) => lock.setHeld(held)) + try { + await gitExecFileAsync([...PACK_REFS_ARGS], { + cwd: args.repoPath, + ...gitOptions, + admissionTier: 'background', + timeout: PACK_REFS_TIMEOUT_MS + }) + } finally { + watch?.stop() + lock.setHeld(false) + await owner?.release() + } + } + } +} diff --git a/src/main/git/pack-refs-lock-ownership.test.ts b/src/main/git/pack-refs-lock-ownership.test.ts new file mode 100644 index 00000000000..e181feb9976 --- /dev/null +++ b/src/main/git/pack-refs-lock-ownership.test.ts @@ -0,0 +1,192 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { PackRefsLockOwnership } from './pack-refs-lock-ownership' + +const roots: string[] = [] + +async function gitCommonDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-pack-refs-lock-')) + roots.push(root) + return root +} + +function paths(commonDir: string): { lock: string; marker: string } { + return { + lock: join(commonDir, 'packed-refs.lock'), + marker: join(commonDir, 'packed-refs.orca-owner') + } +} + +async function exists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} + +/** A pid that cannot be running: the kernel rejects it outright. */ +const DEAD_PID = 0x7fffffff +const ABANDONED_LOCK_AGE_MS = 15 * 60_000 +const PID_REUSE_HORIZON_MS = 24 * 60 * 60_000 + +/** `claim` takes `now`, so age cases need no sleeping and no mtime forgery. */ +function laterBy(ms: number): number { + return Date.now() + ms +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('packed-refs lock ownership', () => { + it('claims a repository with no lock and records the owner', async () => { + const commonDir = await gitCommonDir() + const { marker } = paths(commonDir) + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toEqual({ ok: true }) + + await expect(readFile(marker, 'utf-8')).resolves.toContain(String(process.pid)) + }) + + it('drops the owner marker on release', async () => { + const commonDir = await gitCommonDir() + const ownership = new PackRefsLockOwnership(commonDir) + await ownership.claim() + + await ownership.release() + + await expect(exists(paths(commonDir).marker)).resolves.toBe(false) + }) + + it('refuses a lock it cannot prove is its own', async () => { + const commonDir = await gitCommonDir() + // A lock with no marker belongs to the user's own git, or to another tool. + await writeFile(paths(commonDir).lock, 'someone else') + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toMatchObject({ ok: false }) + await expect(exists(paths(commonDir).lock)).resolves.toBe(true) + }) + + it('refuses a lock whose recorded owner is still running', async () => { + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'in progress') + await writeFile(marker, JSON.stringify({ pid: process.pid })) + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + ).resolves.toMatchObject({ ok: false }) + await expect(exists(lock)).resolves.toBe(true) + }) + + it('reclaims the lock its own dead process left behind', async () => { + // SIGKILL and power loss bypass git's cleanup, and git never clears this itself. + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'abandoned mid-rewrite') + await writeFile(marker, JSON.stringify({ pid: DEAD_PID })) + + const claimed = await new PackRefsLockOwnership(commonDir).claim( + laterBy(ABANDONED_LOCK_AGE_MS + 1) + ) + + expect(claimed).toEqual({ ok: true }) + await expect(exists(lock)).resolves.toBe(false) + await expect(readFile(marker, 'utf-8')).resolves.toContain(String(process.pid)) + }) + + it('leaves a young lock alone even when the marker names a dead process', async () => { + // A marker outlives its lock, so a foreign lock can appear after our death. + // Age is the only thing separating our wreckage from somebody's live lock. + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(marker, JSON.stringify({ pid: DEAD_PID })) + await writeFile(lock, 'a different git process, started just now') + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toMatchObject({ ok: false }) + await expect(exists(lock)).resolves.toBe(true) + }) + + it('does not wedge a repository forever when the recorded pid was recycled', async () => { + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'abandoned mid-rewrite') + // Our own pid stands in for a recycled one: alive, but not the process that wrote this. + await writeFile(marker, JSON.stringify({ pid: process.pid })) + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + ).resolves.toMatchObject({ ok: false }) + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(PID_REUSE_HORIZON_MS + 1)) + ).resolves.toEqual({ ok: true }) + await expect(exists(lock)).resolves.toBe(false) + }) + + it('refuses a lock whose marker is unreadable rather than guessing', async () => { + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'in progress') + await writeFile(marker, 'not json') + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(PID_REUSE_HORIZON_MS + 1)) + ).resolves.toMatchObject({ ok: false }) + await expect(exists(lock)).resolves.toBe(true) + }) + + it('claims cleanly when a marker outlived its lock', async () => { + const commonDir = await gitCommonDir() + await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID })) + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toEqual({ ok: true }) + }) +}) + +describe('stranded per-ref locks', () => { + it('clears the empty refs/**/*.lock files its own dead process left behind', async () => { + // `tempfile.c` opens the lock O_EXCL before linking it into the list the + // signal handler walks, so a kill in that window leaves a 0-byte file that + // Git never clears -- and `update-ref -d` on that ref then fails forever. + const commonDir = await gitCommonDir() + const namespace = join(commonDir, 'refs', 'remotes', 'origin') + await mkdir(namespace, { recursive: true }) + await writeFile(join(namespace, 'main.lock'), '') + await writeFile(join(namespace, 'main'), 'a'.repeat(40)) + await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID })) + + await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + + await expect(exists(join(namespace, 'main.lock'))).resolves.toBe(false) + // The ref itself is untouched. + await expect(exists(join(namespace, 'main'))).resolves.toBe(true) + }) + + it('leaves a non-empty ref lock alone, because a live writer is mid-write', async () => { + const commonDir = await gitCommonDir() + const namespace = join(commonDir, 'refs', 'heads') + await mkdir(namespace, { recursive: true }) + await writeFile(join(namespace, 'busy.lock'), 'b'.repeat(40)) + await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID })) + + await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + + await expect(exists(join(namespace, 'busy.lock'))).resolves.toBe(true) + }) + + it('leaves ref locks alone when there is no marker naming a dead process', async () => { + const commonDir = await gitCommonDir() + const namespace = join(commonDir, 'refs', 'heads') + await mkdir(namespace, { recursive: true }) + await writeFile(join(namespace, 'other.lock'), '') + + await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + + await expect(exists(join(namespace, 'other.lock'))).resolves.toBe(true) + }) +}) diff --git a/src/main/git/pack-refs-lock-ownership.ts b/src/main/git/pack-refs-lock-ownership.ts new file mode 100644 index 00000000000..9e7273b143b --- /dev/null +++ b/src/main/git/pack-refs-lock-ownership.ts @@ -0,0 +1,202 @@ +import { readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { + PACK_REFS_TIMEOUT_MS, + PACKED_REFS_LOCK_POLL_MS +} from '../../shared/repo-ref-maintenance-policy' + +/** No legitimate `pack-refs` outlives its own deadline, so an older lock is abandoned. */ +const ABANDONED_LOCK_AGE_MS = PACK_REFS_TIMEOUT_MS + +/** Beyond this a recorded pid may have been recycled, so it stops being evidence of life. */ +const PID_REUSE_HORIZON_MS = 24 * 60 * 60_000 + +/** The ref tree is wide but shallow; this only stops a pathological walk. */ +const REF_LOCK_SCAN_CEILING = 4096 + +/** + * Makes a `packed-refs.lock` Orca left behind attributable, and only that one. + * + * Git registers signal handlers that clean the lock up, but SIGKILL and power + * loss bypass them, and Git never removes a stale `packed-refs.lock` on its own + * -- every later ref deletion in that repository fails until someone deletes a + * file they have never heard of. Recording our pid beside the lock lets a later + * run recognise its own wreckage. + * + * Three independent conditions must all hold before anything is unlinked, + * because deleting a lock somebody else is holding is far worse than declining + * to pack: a marker must exist at all, the lock must be older than any + * `pack-refs` could legitimately run for, and the recorded process must be gone. + * A marker can outlive its lock, so age is what separates "our wreckage" from a + * foreign lock that happened to appear afterwards. + */ +export class PackRefsLockOwnership { + private readonly lockPath: string + private readonly markerPath: string + + constructor(gitCommonDir: string) { + const path = isWindowsAbsolutePathLike(gitCommonDir) ? win32 : posix + this.lockPath = path.join(gitCommonDir, 'packed-refs.lock') + this.markerPath = path.join(gitCommonDir, 'packed-refs.orca-owner') + } + + /** Refused when the lock belongs to something we cannot prove is our own wreckage. */ + async claim(now = Date.now()): Promise { + const reclaim = await this.reclaimAbandonedLock(now) + if (!reclaim.ok) { + return reclaim + } + // Per-ref strands outlive their pack and are invisible to Git, which never + // clears a `refs/**\/*.lock` it did not create in this process. + await this.reclaimStrandedRefLocks(now) + try { + await writeFile(this.markerPath, JSON.stringify({ pid: process.pid }), 'utf-8') + } catch { + // Losing the marker only costs attribution on the next run, never correctness. + } + return { ok: true } + } + + /** + * Poll `packed-refs.lock` so the scheduler knows when the exclusive rewrite + * window opens and closes. Cheap: one `stat` on a fixed path. + */ + watchLock(report: (held: boolean) => void): { stop: () => void } { + let stopped = false + let last = false + const tick = async (): Promise => { + if (stopped) { + return + } + const held = (await fileAgeMs(this.lockPath, Date.now())) !== null + if (!stopped && held !== last) { + last = held + report(held) + } + } + const timer = setInterval(() => void tick(), PACKED_REFS_LOCK_POLL_MS) + timer.unref?.() + void tick() + return { + stop: () => { + stopped = true + clearInterval(timer) + } + } + } + + async release(): Promise { + await rm(this.markerPath, { force: true }).catch(() => {}) + } + + private async reclaimAbandonedLock(now: number): Promise { + const lockAgeMs = await fileAgeMs(this.lockPath, now) + if (lockAgeMs === null) { + return { ok: true } + } + // No marker means the lock is not ours to reason about, let alone remove. + const marker = await readOwnerMarker(this.markerPath) + if (marker === null) { + return { ok: false, reason: 'held by another process' } + } + if (lockAgeMs < ABANDONED_LOCK_AGE_MS) { + // Ours, but too young to be certain the writer is gone. Worth retrying soon. + return { ok: false, reason: 'our own lock, not yet old enough to reclaim' } + } + // Past the pid-reuse horizon the pid proves nothing, and a lock this old is + // abandoned whoever wrote it -- otherwise a recycled pid would wedge the + // repository permanently. + if (isProcessAlive(marker.pid) && lockAgeMs < PID_REUSE_HORIZON_MS) { + return { ok: false, reason: 'the recorded owner is still running' } + } + await rm(this.lockPath, { force: true }).catch(() => {}) + await rm(this.markerPath, { force: true }).catch(() => {}) + return { ok: true } + } + + /** + * Clear `refs/**\/*.lock` files a dead pack of ours left behind. + * + * `tempfile.c` opens the lock `O_EXCL` before `activate_tempfile()` links it + * into the list the signal handler walks, so a kill inside that window leaves + * a 0-byte file. Afterwards `update-ref -d` and any fetch touching that ref + * fail with `cannot lock ref ... File exists`, forever. Same three conditions + * as the packed-refs lock, plus a size check: a live writer's lock is not empty. + */ + private async reclaimStrandedRefLocks(now: number): Promise { + const marker = await readOwnerMarker(this.markerPath) + if (marker === null || isProcessAlive(marker.pid)) { + return + } + const markerAgeMs = await fileAgeMs(this.markerPath, now) + if (markerAgeMs === null || markerAgeMs < ABANDONED_LOCK_AGE_MS) { + return + } + const path = isWindowsAbsolutePathLike(this.markerPath) ? win32 : posix + const pending = [path.join(path.dirname(this.markerPath), 'refs')] + let visited = 0 + while (pending.length > 0) { + const directory = pending.pop() + if (directory === undefined || (visited += 1) > REF_LOCK_SCAN_CEILING) { + return + } + let entries: { name: string; isDirectory: () => boolean }[] + try { + entries = await readdir(directory, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + const full = path.join(directory, entry.name) + if (entry.isDirectory()) { + pending.push(full) + } else if (entry.name.endsWith('.lock') && (await isEmptyFile(full))) { + await rm(full, { force: true }).catch(() => {}) + } + } + } + } +} + +export type PackRefsLockClaim = { ok: true } | { ok: false; reason: string } + +/** A strand from the `O_EXCL` window is 0 bytes; a live writer's lock is not. */ +async function isEmptyFile(path: string): Promise { + try { + return (await stat(path)).size === 0 + } catch { + return false + } +} + +async function readOwnerMarker(path: string): Promise<{ pid: number } | null> { + try { + const raw = (await readFile(path, 'utf-8')).slice(0, 256) + const pid = (JSON.parse(raw) as { pid?: unknown }).pid + return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 ? { pid } : null + } catch { + return null + } +} + +/** Null when the file does not exist. Uses stat: the lock holds a whole packed-refs. */ +async function fileAgeMs(path: string, now: number): Promise { + try { + return Math.max(0, now - (await stat(path)).mtimeMs) + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' ? null : 0 + } +} + +function isProcessAlive(pid: number): boolean { + if (pid === process.pid) { + return true + } + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index c1557bdd806..2baf3b77137 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -7,6 +7,10 @@ import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name import type { GitPushTarget } from '../../shared/worktree/types' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' +import { + postponeRepoRefMaintenance, + withRepoRefMaintenancePaused +} from './local-repo-ref-maintenance' import { validateGitPushTarget } from './push-target-validation' import { gitExecFileAsync } from './runner' import { fetchForkRemoteWithStaleRefspecRepair } from './fork-remote-stale-branch-refspec' @@ -260,8 +264,11 @@ export async function gitPull( // Why: plain `git pull` uses the user's configured pull strategy (merge by // default) so diverged branches reconcile instead of erroring out. Conflicts // surface through the existing conflict-resolution flow. - await runWithGitWorktreeOperationLock(worktreePath, options.signal, () => - runWithGitReadCacheInvalidation(() => gitPullWithArgs(worktreePath, [], pushTarget, options)) + postponeRepoRefMaintenance() + await withRepoRefMaintenancePaused('git-pull', () => + runWithGitWorktreeOperationLock(worktreePath, options.signal, () => + runWithGitReadCacheInvalidation(() => gitPullWithArgs(worktreePath, [], pushTarget, options)) + ) ) } @@ -270,9 +277,12 @@ export async function gitFastForward( pushTarget?: GitPushTarget, options: GitRuntimeOptions = {} ): Promise { - await runWithGitWorktreeOperationLock(worktreePath, options.signal, () => - runWithGitReadCacheInvalidation(() => - gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options) + postponeRepoRefMaintenance() + await withRepoRefMaintenancePaused('git-fast-forward', () => + runWithGitWorktreeOperationLock(worktreePath, options.signal, () => + runWithGitReadCacheInvalidation(() => + gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options) + ) ) ) } @@ -282,22 +292,28 @@ export async function gitFetch( pushTarget?: GitPushTarget, options: GitRuntimeOptions = {} ): Promise { + // `--prune` deletes remote-tracking refs, which needs the `packed-refs` lock a + // running idle pack holds while it rewrites -- ~1.4s at most. This is the user + // clicking Fetch, so wait that window out rather than letting it fail on the lock. + postponeRepoRefMaintenance() try { - if (pushTarget) { - const target = await validateGitPushTarget(worktreePath, pushTarget, options) - const runtimeOptions = gitOptionsForWorktree(worktreePath, options) - await fetchForkRemoteWithStaleRefspecRepair( - (args, cwd) => gitExecFileAsync(args, { ...runtimeOptions, cwd }), - worktreePath, - target.remoteName, - () => - gitExecFileAsync(['fetch', '--prune', target.remoteName], runtimeOptions).then( - () => undefined - ) - ) - return - } - await gitExecFileAsync(['fetch', '--prune'], gitOptionsForWorktree(worktreePath, options)) + await withRepoRefMaintenancePaused('git-fetch', async () => { + if (pushTarget) { + const target = await validateGitPushTarget(worktreePath, pushTarget, options) + const runtimeOptions = gitOptionsForWorktree(worktreePath, options) + await fetchForkRemoteWithStaleRefspecRepair( + (args, cwd) => gitExecFileAsync(args, { ...runtimeOptions, cwd }), + worktreePath, + target.remoteName, + () => + gitExecFileAsync(['fetch', '--prune', target.remoteName], runtimeOptions).then( + () => undefined + ) + ) + return + } + await gitExecFileAsync(['fetch', '--prune'], gitOptionsForWorktree(worktreePath, options)) + }) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'fetch')) } diff --git a/src/main/git/repo-ref-maintenance-real-git.test.ts b/src/main/git/repo-ref-maintenance-real-git.test.ts new file mode 100644 index 00000000000..30c67b0365a --- /dev/null +++ b/src/main/git/repo-ref-maintenance-real-git.test.ts @@ -0,0 +1,290 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { countLooseRefs } from '../../shared/loose-ref-count' +import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance' +import { + _resetLocalRepoRefMaintenanceForTests, + createLocalRepoRefMaintenanceTarget, + getLocalRepoRefMaintenance, + setRepoMaintenanceActivityProbe +} from './local-repo-ref-maintenance' +import { forceDeleteLocalBranch } from './worktree-branch-removal' + +const roots: string[] = [] +// Large enough that the deferral ladder (1x, 2x, 4x ... capped at 8x) outlasts +// three real `pack-refs` runs before the deferral budget is spent. +const QUIET_MS = 25 +const THRESHOLD = 20 + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim() +} + +/** A repo whose only loose-ref backlog is the one the test asks for. */ +async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDir: string }> { + const root = await mkdtemp(join(tmpdir(), 'orca-ref-maintenance-git-')) + roots.push(root) + const repoPath = join(root, 'repo') + execFileSync('git', ['init', '--quiet', repoPath]) + git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + git(repoPath, ['config', 'user.email', 'test@example.com']) + git(repoPath, ['config', 'user.name', 'Test User']) + await writeFile(join(repoPath, 'file.txt'), 'one\n') + git(repoPath, ['add', 'file.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'initial']) + const head = git(repoPath, ['rev-parse', 'HEAD']) + // Written directly: `update-ref` for thousands of refs is the slow part of the fixture. + const namespace = join(repoPath, '.git', 'refs', 'remotes', 'origin') + await mkdir(namespace, { recursive: true }) + for (let index = 0; index < looseRefs; index += 1) { + await writeFile(join(namespace, `branch-${index}`), `${head}\n`) + } + return { repoPath, refsDir: join(repoPath, '.git', 'refs') } +} + +function createMaintenance(onPackRefs: () => void = () => {}): { + maintenance: RepoRefMaintenance + arm: (repoPath: string) => void +} { + const maintenance = new RepoRefMaintenance({ + quietPeriodMs: QUIET_MS, + looseRefThreshold: THRESHOLD + }) + return { + maintenance, + arm: (repoPath: string) => { + const target = createLocalRepoRefMaintenanceTarget({ + key: `local::${repoPath}`, + repoPath + }) + maintenance.arm({ + ...target, + packRefs: async (signal) => { + onPackRefs() + await target.packRefs(signal) + } + }) + } + } +} + +async function settle(maintenance: RepoRefMaintenance): Promise { + await new Promise((resolve) => setTimeout(resolve, QUIET_MS * 4)) + await maintenance.whenAttemptSettled() +} + +/** Deferred repos re-arm for another quiet period, so drain rather than count rounds. */ +async function settleUntil( + maintenance: RepoRefMaintenance, + done: () => Promise +): Promise { + for (let round = 0; round < 100; round += 1) { + if (await done()) { + return + } + await settle(maintenance) + } +} + +afterEach(async () => { + _resetLocalRepoRefMaintenanceForTests() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('idle ref maintenance against real Git', () => { + it('packs a backlogged repository down to zero loose refs', async () => { + const { repoPath, refsDir } = await createRepo(THRESHOLD + 30) + const { maintenance, arm } = createMaintenance() + + await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({ + count: THRESHOLD + 31 + }) + + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + + await expect(countLooseRefs(refsDir, 10_000)).resolves.toEqual({ count: 0, saturated: false }) + // The refs survived the move into packed-refs; nothing was lost. + expect(git(repoPath, ['for-each-ref', '--format=%(refname)']).split('\n')).toHaveLength( + THRESHOLD + 31 + ) + expect(git(repoPath, ['rev-parse', '--verify', 'refs/remotes/origin/branch-0'])).toMatch( + /^[0-9a-f]{40}$/ + ) + }, 30_000) + + it('leaves a healthy repository untouched', async () => { + const { repoPath, refsDir } = await createRepo(2) + let packed = 0 + const { maintenance, arm } = createMaintenance(() => { + packed += 1 + }) + + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + + expect(packed).toBe(0) + await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({ count: 3 }) + }, 30_000) + + it('honours maintenance.auto=false in the repository config', async () => { + const { repoPath, refsDir } = await createRepo(THRESHOLD + 30) + git(repoPath, ['config', 'maintenance.auto', 'false']) + let packed = 0 + const { maintenance, arm } = createMaintenance(() => { + packed += 1 + }) + + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + + expect(packed).toBe(0) + await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({ + count: THRESHOLD + 31 + }) + }, 30_000) + + it('runs one repository at a time even when several go quiet together', async () => { + const repos = await Promise.all([ + createRepo(THRESHOLD + 5), + createRepo(THRESHOLD + 5), + createRepo(THRESHOLD + 5) + ]) + let concurrent = 0 + let peak = 0 + const maintenance = new RepoRefMaintenance({ + quietPeriodMs: QUIET_MS, + looseRefThreshold: THRESHOLD + }) + for (const { repoPath } of repos) { + const target = createLocalRepoRefMaintenanceTarget({ + key: `local::${repoPath}`, + repoPath + }) + maintenance.arm({ + ...target, + packRefs: async (signal) => { + concurrent += 1 + peak = Math.max(peak, concurrent) + try { + await target.packRefs(signal) + } finally { + concurrent -= 1 + } + } + }) + } + + const allPacked = async (): Promise => { + const counts = await Promise.all(repos.map(({ refsDir }) => countLooseRefs(refsDir, 10_000))) + return counts.every((scan) => scan.count === 0) + } + await settleUntil(maintenance, allPacked) + maintenance.dispose() + + expect(peak).toBe(1) + for (const { refsDir } of repos) { + await expect(countLooseRefs(refsDir, 10_000)).resolves.toEqual({ + count: 0, + saturated: false + }) + } + }, 60_000) +}) + +describe('yielding the repository to work that deletes refs', () => { + it('waits for the packed-refs lock and succeeds while the prune continues', async () => { + // The pack is never killed. `packed-refs.lock` is held for ~1.4s of a 30s + // run; the rest is the prune, during which a concurrent `update-ref -d` + // succeeds on its own because per-ref locks last microseconds. Signalling + // the child there strands a `refs/**` lock Git never clears. + const { repoPath } = await createRepo(0) + git(repoPath, ['branch', 'doomed']) + const head = git(repoPath, ['rev-parse', 'refs/heads/doomed']) + + let packing = false + let releaseLock: (() => void) | undefined + _resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: QUIET_MS, looseRefThreshold: 1 }) + setRepoMaintenanceActivityProbe(() => false) + getLocalRepoRefMaintenance().arm({ + key: `local::${repoPath}`, + resolveRefsDirectory: async () => join(repoPath, '.git', 'refs'), + packRefs: async (lock) => { + packing = true + lock.setHeld(true) + // Stands in for the rewrite window, then the long prune that follows it. + await new Promise((resolve) => { + releaseLock = () => { + lock.setHeld(false) + resolve() + } + }) + } + }) + for (let attempt = 0; attempt < 200 && !packing; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, QUIET_MS)) + } + expect(packing).toBe(true) + + // The real deletion path, which routes through withRepoRefMaintenancePaused. + let deleted = false + const deletion = forceDeleteLocalBranch(repoPath, 'doomed', head).then(() => { + deleted = true + }) + + // It must still be waiting: the rewrite window is open. + await new Promise((resolve) => setTimeout(resolve, QUIET_MS * 4)) + expect(deleted).toBe(false) + expect(git(repoPath, ['branch', '--list', 'doomed'])).toContain('doomed') + + // Releasing the window is enough -- the pack is never cancelled. + releaseLock?.() + await deletion + expect(deleted).toBe(true) + expect(git(repoPath, ['branch', '--list', 'doomed'])).toBe('') + }, 30_000) + + it('does not block the caller once the rewrite window has closed', async () => { + // The prune phase is concurrency-safe, so a caller arriving during it pays + // nothing at all. + const { repoPath } = await createRepo(0) + git(repoPath, ['branch', 'doomed']) + const head = git(repoPath, ['rev-parse', 'refs/heads/doomed']) + + let pruning = false + let finishPrune: (() => void) | undefined + _resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: QUIET_MS, looseRefThreshold: 1 }) + setRepoMaintenanceActivityProbe(() => false) + getLocalRepoRefMaintenance().arm({ + key: `local::${repoPath}`, + resolveRefsDirectory: async () => join(repoPath, '.git', 'refs'), + packRefs: async (lock) => { + lock.setHeld(true) + lock.setHeld(false) + pruning = true + await new Promise((resolve) => { + finishPrune = resolve + }) + } + }) + for (let attempt = 0; attempt < 200 && !pruning; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, QUIET_MS)) + } + + const startedAt = Date.now() + await expect(forceDeleteLocalBranch(repoPath, 'doomed', head)).resolves.toBeUndefined() + expect(Date.now() - startedAt).toBeLessThan(2_000) + + finishPrune?.() + }, 30_000) +}) diff --git a/src/main/git/worktree-add.ts b/src/main/git/worktree-add.ts index 3cd761f4d13..ea6ec704b46 100644 --- a/src/main/git/worktree-add.ts +++ b/src/main/git/worktree-add.ts @@ -4,6 +4,7 @@ import type { LocalBaseRefUpdateSuggestion } from '../../shared/worktree/base-ref-drift-types' import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' @@ -149,15 +150,17 @@ export async function addWorktree( options: AddWorktreeOptions = {} ): Promise { try { - return await runWithGitReadCacheInvalidation(() => - performAddWorktree( - repoPath, - worktreePath, - branch, - baseBranch, - refreshLocalBaseRef, - noCheckout, - options + return await withRepoRefMaintenancePaused('worktree-add', () => + runWithGitReadCacheInvalidation(() => + performAddWorktree( + repoPath, + worktreePath, + branch, + baseBranch, + refreshLocalBaseRef, + noCheckout, + options + ) ) ) } finally { diff --git a/src/main/git/worktree-branch-removal.ts b/src/main/git/worktree-branch-removal.ts index 5e64b4a582d..08b6b21a1e3 100644 --- a/src/main/git/worktree-branch-removal.ts +++ b/src/main/git/worktree-branch-removal.ts @@ -4,6 +4,7 @@ import { } from '../../shared/git-branch-cleanup' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { parseWorktreeList } from './worktree-list-parser' import type { GitWorktreeExecOptions, RemoveWorktreeOptions } from './worktree-operation-options' @@ -152,7 +153,11 @@ export async function forceDeleteLocalBranch( } // Why: stale toast actions must not delete a branch that moved; `update-ref -d` deletes only if the ref still == expectedHead. try { - await runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath) + // `update-ref -d` needs the packed-refs lock a running idle pack holds while + // it rewrites; waits it out rather than cancelling the pack. + await withRepoRefMaintenancePaused('branch-delete', () => + runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath) + ) } catch { throw new Error( `Local branch "${branchName}" changed after the workspace was deleted. Review it before deleting it.` diff --git a/src/main/git/worktree-create-preparation.ts b/src/main/git/worktree-create-preparation.ts index 3be0fee1648..b60dc01ec33 100644 --- a/src/main/git/worktree-create-preparation.ts +++ b/src/main/git/worktree-create-preparation.ts @@ -10,6 +10,7 @@ import { WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' @@ -69,46 +70,48 @@ export async function prepareWorktreeCreateCheckout( options: GitWorktreeExecOptions = {} ): Promise { try { - await runWithGitReadCacheInvalidation(async () => { - const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => - hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) - ) - try { - await gitExecFileAsync( - [ - ...windowsLongPathGitArgs(repoPath), - 'worktree', - 'add', - '--detach', - '--no-checkout', - worktreePath, - effectiveBase - ], - { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + await withRepoRefMaintenancePaused('worktree-prepare', () => + runWithGitReadCacheInvalidation(async () => { + const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) ) - // The add just wrote the marker; drop any pre-create route before the reset routes Git. - invalidateWslLinkedWorktreeGitRouting(worktreePath) - // Why: reset materializes files without running user post-checkout hooks before submit. - await gitExecFileAsync( - [...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase], - { ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() } - ) - await gitExecFileAsync( - [ - ...windowsLongPathGitArgs(repoPath), - 'worktree', - 'lock', - '--reason', - lockReason, - worktreePath - ], - { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } - ) - } catch (error) { - await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {}) - throw error - } - }) + try { + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'add', + '--detach', + '--no-checkout', + worktreePath, + effectiveBase + ], + { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + // The add just wrote the marker; drop any pre-create route before the reset routes Git. + invalidateWslLinkedWorktreeGitRouting(worktreePath) + // Why: reset materializes files without running user post-checkout hooks before submit. + await gitExecFileAsync( + [...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase], + { ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'lock', + '--reason', + lockReason, + worktreePath + ], + { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + } catch (error) { + await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {}) + throw error + } + }) + ) } finally { notifyPreparedWorktreeMutation(repoPath) } diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index c6743a4a7e2..afe1bf2a9c1 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -22,6 +22,7 @@ import { } from './worktree-operation-options' import { areWorktreePathsEqual } from './worktree-path-comparison' import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { bumpWorktreeScanGeneration, listWorktrees } from './worktree-scan-cache' import { invalidateSparseCheckoutState } from './worktree-sparse-checkout-cache' @@ -36,8 +37,13 @@ export async function removeWorktree( options: RemoveWorktreeOptions = {} ): Promise { try { - return await runWithGitReadCacheInvalidation(() => - performRemoveWorktree(repoPath, worktreePath, force, options) + // Removal deletes branches, and a ref deletion needs the packed-refs lock a + // running idle pack holds while it rewrites. Waits that window out; the + // prune phase that follows it is concurrency-safe and is left to finish. + return await withRepoRefMaintenancePaused('worktree-remove', () => + runWithGitReadCacheInvalidation(() => + performRemoveWorktree(repoPath, worktreePath, force, options) + ) ) } finally { invalidateWslLinkedWorktreeGitRouting(worktreePath) diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index 86e2dc5ee10..44a16f8e10d 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -61,8 +61,11 @@ vi.mock('fs/promises', () => ({ rm: rmMock })) +// `availableParallelism` is read at module load by the git admission scheduler, +// which this module graph reaches; a partial `os` mock breaks that import. vi.mock('os', () => ({ - homedir: homedirMock + homedir: homedirMock, + availableParallelism: () => 8 })) vi.mock('../git/runner', () => ({ diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index ff58a561ee3..3fd2fb41908 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -17,7 +17,10 @@ import { registerSparseCheckoutCacheInvalidation } from './worktrees/listing/reg import { registerWorktreeMetadataHandlers } from './worktrees/metadata/register-worktree-metadata-handlers' import { registerWorktreeForgetHandlers } from './worktrees/removal/register-worktree-forget-handlers' import { registerWorktreeRemovalHandlers } from './worktrees/removal/register-worktree-removal-handlers' -import type { WorktreeIpcContext } from './worktrees/worktree-ipc-context' +import { + createWorktreeRemovalRegistry, + type WorktreeIpcContext +} from './worktrees/worktree-ipc-context' registerDetectedWorktreeScanInvalidation() @@ -66,7 +69,7 @@ export function registerWorktreeHandlers( runtime, ...(options ? { options } : {}), detectedWorktreeCancellations: createSenderScopedRequestCancellations(), - worktreeRemovalsInFlight: new Map() + worktreeRemovalsInFlight: createWorktreeRemovalRegistry() } // Remove all stale registrations before installing any replacement handler. diff --git a/src/main/ipc/worktrees/worktree-ipc-context.ts b/src/main/ipc/worktrees/worktree-ipc-context.ts index 5455a71d06e..153e4b723ec 100644 --- a/src/main/ipc/worktrees/worktree-ipc-context.ts +++ b/src/main/ipc/worktrees/worktree-ipc-context.ts @@ -14,3 +14,18 @@ export type WorktreeIpcContext = { detectedWorktreeCancellations: SenderScopedRequestCancellations worktreeRemovalsInFlight: Map } + +// Why: removal and forget both delete refs, and a ref deletion has to take the +// `packed-refs` lock. Idle ref maintenance needs a process-wide view of that +// registry so it never packs while one is running. +let activeWorktreeRemovals: ReadonlyMap | null = null + +export function createWorktreeRemovalRegistry(): Map { + const registry = new Map() + activeWorktreeRemovals = registry + return registry +} + +export function hasWorktreeRemovalsInFlight(): boolean { + return (activeWorktreeRemovals?.size ?? 0) > 0 +} diff --git a/src/main/repo-maintenance-idle-gate.test.ts b/src/main/repo-maintenance-idle-gate.test.ts new file mode 100644 index 00000000000..78728f3a43a --- /dev/null +++ b/src/main/repo-maintenance-idle-gate.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const isOnBatteryPowerMock = vi.hoisted(() => vi.fn(() => false)) +const hasPendingPreparationsMock = vi.hoisted(() => vi.fn(() => false)) +const hasRemovalsInFlightMock = vi.hoisted(() => vi.fn(() => false)) +const setProbeMock = vi.hoisted(() => vi.fn()) +const disposeMock = vi.hoisted(() => vi.fn(async () => {})) +const postponeMock = vi.hoisted(() => vi.fn()) +const powerListeners = vi.hoisted(() => new Map void>()) +const appListeners = vi.hoisted(() => new Map void>()) + +vi.mock('electron', () => ({ + app: { + on: (event: string, listener: () => void) => appListeners.set(event, listener), + off: (event: string) => appListeners.delete(event) + }, + powerMonitor: { + isOnBatteryPower: isOnBatteryPowerMock, + on: (event: string, listener: () => void) => powerListeners.set(event, listener), + off: (event: string) => powerListeners.delete(event) + } +})) + +vi.mock('./worktree-create-preparation', () => ({ + hasPendingWorktreeCreatePreparations: hasPendingPreparationsMock +})) + +vi.mock('./ipc/worktrees/worktree-ipc-context', () => ({ + hasWorktreeRemovalsInFlight: hasRemovalsInFlightMock +})) + +vi.mock('./git/local-repo-ref-maintenance', () => ({ + setRepoMaintenanceActivityProbe: setProbeMock, + disposeLocalRepoRefMaintenance: disposeMock, + postponeRepoRefMaintenance: postponeMock +})) + +import { installRepoMaintenanceIdleGate } from './repo-maintenance-idle-gate' + +function installProbe( + overrides: Partial<{ isQuitting: () => boolean; getWorkingAgentCount: () => number }> = {} +): { probe: () => boolean; uninstall: () => Promise } { + const uninstall = installRepoMaintenanceIdleGate({ + isQuitting: () => false, + getWorkingAgentCount: () => 0, + ...overrides + }) + return { probe: setProbeMock.mock.calls.at(-1)?.[0] as () => boolean, uninstall } +} + +beforeEach(() => { + isOnBatteryPowerMock.mockReturnValue(false) + hasPendingPreparationsMock.mockReturnValue(false) + hasRemovalsInFlightMock.mockReturnValue(false) + postponeMock.mockClear() + powerListeners.clear() + appListeners.clear() + setProbeMock.mockClear() + disposeMock.mockClear() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('repo maintenance idle gate', () => { + it('reports idle when nothing is happening', () => { + expect(installProbe().probe()).toBe(false) + }) + + it('vetoes while an agent is working', () => { + expect(installProbe({ getWorkingAgentCount: () => 1 }).probe()).toBe(true) + }) + + it('vetoes while a worktree create is prepared or in flight', () => { + hasPendingPreparationsMock.mockReturnValue(true) + + expect(installProbe().probe()).toBe(true) + }) + + it('vetoes while a worktree removal is deleting refs', () => { + // Removal deletes branches, and a ref deletion needs the same packed-refs lock. + hasRemovalsInFlightMock.mockReturnValue(true) + + expect(installProbe().probe()).toBe(true) + }) + + it('vetoes on battery power', () => { + isOnBatteryPowerMock.mockReturnValue(true) + + expect(installProbe().probe()).toBe(true) + }) + + it('vetoes during shutdown', () => { + expect(installProbe({ isQuitting: () => true }).probe()).toBe(true) + }) + + it('treats an unavailable power API as not-on-battery', () => { + isOnBatteryPowerMock.mockImplementation(() => { + throw new Error('unsupported') + }) + + expect(installProbe().probe()).toBe(false) + }) + + it('pushes the next attempt out when the machine drops onto battery', () => { + // Do-not-start, never stop-what-is-running: killing a pack to honour a + // battery change would strand a ref lock to save a little unlinking. + installProbe() + + powerListeners.get('on-battery')?.() + + expect(postponeMock).toHaveBeenCalledTimes(1) + }) + + it('pushes the next attempt out when the user comes back to the window', () => { + // A focus transition, not focus itself: a window left focused while the user + // walks away fires no event and blocks nothing. + installProbe() + + appListeners.get('browser-window-focus')?.() + + expect(postponeMock).toHaveBeenCalledTimes(1) + }) + + it('cancels armed timers, unsubscribes both sources, and clears the probe when uninstalled', async () => { + await installProbe().uninstall() + + expect(disposeMock).toHaveBeenCalledTimes(1) + expect(powerListeners.has('on-battery')).toBe(false) + expect(appListeners.has('browser-window-focus')).toBe(false) + expect(setProbeMock).toHaveBeenLastCalledWith(null) + }) +}) diff --git a/src/main/repo-maintenance-idle-gate.ts b/src/main/repo-maintenance-idle-gate.ts new file mode 100644 index 00000000000..78bb25fe68c --- /dev/null +++ b/src/main/repo-maintenance-idle-gate.ts @@ -0,0 +1,67 @@ +import { app, powerMonitor } from 'electron' +import { + disposeLocalRepoRefMaintenance, + postponeRepoRefMaintenance, + setRepoMaintenanceActivityProbe +} from './git/local-repo-ref-maintenance' +import { hasWorktreeRemovalsInFlight } from './ipc/worktrees/worktree-ipc-context' +import { hasPendingWorktreeCreatePreparations } from './worktree-create-preparation' + +/** + * The app-wide "not now" answer for idle repo maintenance. + * + * `pack-refs` holds a general git admission slot for its whole run, which on a + * large backlog is minutes, and takes the `packed-refs` lock while it writes. + * Any ref deletion needs that same lock and gives up after + * `core.packedRefsTimeout` (1s), so worktree removal in particular has to veto + * this -- as does a create in flight, an agent mid-run, and shutdown. Battery is + * a veto too: this is work the user did not ask for, and a plugged-in quiet + * window always comes along later. + */ +export type RepoMaintenanceIdleInputs = { + isQuitting: () => boolean + getWorkingAgentCount: () => number +} + +export function installRepoMaintenanceIdleGate( + inputs: RepoMaintenanceIdleInputs +): () => Promise { + setRepoMaintenanceActivityProbe( + () => + inputs.isQuitting() || + inputs.getWorkingAgentCount() > 0 || + hasPendingWorktreeCreatePreparations() || + hasWorktreeRemovalsInFlight() || + isOnBatteryPower() + ) + // Do-not-start, never stop-what-is-running. Killing a pack to honour a battery + // or focus change would strand a ref lock roughly one time in five to save at + // most a couple of minutes of background unlinking; pushing the next attempt + // out costs nothing and risks nothing. + const onBattery = (): void => { + postponeRepoRefMaintenance() + } + const onFocus = (): void => { + postponeRepoRefMaintenance() + } + powerMonitor.on('on-battery', onBattery) + app.on('browser-window-focus', onFocus) + return () => { + app.off('browser-window-focus', onFocus) + powerMonitor.off('on-battery', onBattery) + // Order matters: clearing the probe alone would leave armed timers running + // against a gate that can no longer see agents, creates, or shutdown. + const stopped = disposeLocalRepoRefMaintenance() + setRepoMaintenanceActivityProbe(null) + return stopped + } +} + +function isOnBatteryPower(): boolean { + try { + return powerMonitor.isOnBatteryPower() + } catch { + // Absence of the API is not evidence of battery; desktops answer false anyway. + return false + } +} diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts index fc2d761c059..c97966c344e 100644 --- a/src/main/runtime/fetch-remote-cache.test.ts +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -133,9 +133,11 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { const first = runtime.fetchRemoteWithCache('/repo/c', 'origin') const second = runtime.fetchRemoteWithCache('/repo/c', 'origin') - // Allow both callers to register before we resolve. - await Promise.resolve() - await Promise.resolve() + // Allow both callers to register before we resolve. Each canonicalizes the + // repo key first, so the dispatch lands several microtasks in. + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve() + } expect(fetchCallCount()).toBe(1) resolveFetch() diff --git a/src/main/runtime/runtime-remote-fetch-controller.ts b/src/main/runtime/runtime-remote-fetch-controller.ts index c48a8437a3d..b3b9df5dcba 100644 --- a/src/main/runtime/runtime-remote-fetch-controller.ts +++ b/src/main/runtime/runtime-remote-fetch-controller.ts @@ -1,4 +1,9 @@ import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance' +import { getCanonicalRepoKey } from '../git/canonical-repo-key' +import { + armLocalRepoRefMaintenance, + setRepoRefMaintenanceBusyProbe +} from '../git/local-repo-ref-maintenance' import { gitExecFileAsync } from '../git/runner' import { setBoundedMapEntry } from './runtime-async-boundaries' @@ -33,6 +38,11 @@ export class RuntimeRemoteFetchController { return this.fetchLastCompletedAt } + /** `${runtimeKey}::${gitCommonDir}` -- one repo on one execution host. */ + async getCanonicalRepoKey(repoPath: string, gitOptions: GitOptions = {}): Promise { + return getCanonicalRepoKey(repoPath, gitOptions) + } + async getCanonicalFetchKey( repoPath: string, remote: string, @@ -45,23 +55,41 @@ export class RuntimeRemoteFetchController { setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, cached, REMOTE_FETCH_CACHE_MAX) return cached } - let resolved = cacheKey - try { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--path-format=absolute', '--git-common-dir'], - { cwd: repoPath, ...gitOptions } - ) - const commonDir = stdout.trim() - if (commonDir) { - resolved = `${runtimeKey}::${commonDir}::${remote}` - } - } catch { - // The caller path remains a safe serialization key when canonicalization fails. - } + const resolved = `${await this.getCanonicalRepoKey(repoPath, gitOptions)}::${remote}` setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, resolved, REMOTE_FETCH_CACHE_MAX) return resolved } + /** + * Orca strips git's auto-maintenance off these fetches, so every one of them + * adds to a loose-ref backlog nothing else will ever pack. Arm the idle sweep + * that pays it back; each fetch pushes the attempt a further quiet period out. + */ + private armRefMaintenance(repoPath: string, gitOptions: GitOptions): void { + void this.getCanonicalRepoKey(repoPath, gitOptions) + .then((key) => { + setRepoRefMaintenanceBusyProbe(key, () => this.hasInflightFetchForRepo(key)) + armLocalRepoRefMaintenance({ + key, + repoPath, + ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}) + }) + }) + .catch(() => { + // Maintenance is best effort; a repo we cannot name is a repo we skip. + }) + } + + private hasInflightFetchForRepo(repoKey: string): boolean { + const prefix = `${repoKey}::` + for (const key of this.fetchInflight.keys()) { + if (key.startsWith(prefix)) { + return true + } + } + return false + } + private enqueueRemoteFetch( remoteKey: string, runFetch: () => Promise @@ -123,6 +151,7 @@ export class RuntimeRemoteFetchController { }) ).finally(() => { this.fetchInflight.delete(key) + this.armRefMaintenance(repoPath, gitOptions) }) this.fetchInflight.set(key, promise) return promise @@ -178,6 +207,7 @@ export class RuntimeRemoteFetchController { }) }).finally(() => { this.fetchInflight.delete(key) + this.armRefMaintenance(repoPath, gitOptions) }) this.fetchInflight.set(key, promise) return promise diff --git a/src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts b/src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts new file mode 100644 index 00000000000..f577da24854 --- /dev/null +++ b/src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: Orca's fetches are what create the loose-ref backlog (they suppress +// git's auto-maintenance), so the fetch controller is where the idle sweep has +// to be armed. These tests pin that wiring and the per-repo busy signal it +// hands the sweep. + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) +const armMock = vi.hoisted(() => vi.fn()) +const busyProbeMock = vi.hoisted(() => vi.fn()) + +vi.mock('../git/runner', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../git/local-repo-ref-maintenance', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + armLocalRepoRefMaintenance: armMock, + setRepoRefMaintenanceBusyProbe: busyProbeMock +})) + +import { _resetCanonicalRepoKeyCacheForTests } from '../git/canonical-repo-key' +import { RuntimeRemoteFetchController } from './runtime-remote-fetch-controller' + +function armedTargets(): { key: string }[] { + return armMock.mock.calls.map(([args]) => args as { key: string }) +} + +/** The per-repo "a fetch is in flight" answer the controller registers for a key. */ +function busyProbeFor(key: string): (() => boolean) | undefined { + return busyProbeMock.mock.calls.findLast(([registered]) => registered === key)?.[1] as + | (() => boolean) + | undefined +} + +beforeEach(() => { + _resetCanonicalRepoKeyCacheForTests() + gitExecFileAsyncMock.mockReset() + armMock.mockReset() + busyProbeMock.mockReset() + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => + argv[0] === 'rev-parse' ? { stdout: '/repo/.git\n', stderr: '' } : { stdout: '', stderr: '' } + ) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('fetch-armed ref maintenance', () => { + it('arms the sweep for the repo after a remote fetch, keyed by common dir', async () => { + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('/repo/worktrees/a', 'origin') + + expect(armedTargets().map((target) => target.key)).toEqual(['local::/repo/.git']) + }) + + it('gives every worktree of one repo the same maintenance key', async () => { + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('/repo/worktrees/a', 'origin') + await controller.getOrStartRemoteTrackingBaseRefresh('/repo/worktrees/b', { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + }) + + const keys = new Set(armedTargets().map((target) => target.key)) + expect(keys).toEqual(new Set(['local::/repo/.git'])) + }) + + it('scopes the key to the WSL distro that executes the repo', async () => { + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('//wsl$/Ubuntu/repo', 'origin', { + wslDistro: 'Ubuntu' + }) + + expect(armedTargets()[0]?.key).toBe('wsl:Ubuntu::/repo/.git') + }) + + it('does not collapse every repo onto one key on Git older than 2.31', async () => { + // Old Git echoes the unrecognized `--path-format` flag, exits 0, and prints a + // relative `.git`; taking that raw would name every repository identically. + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => + argv[0] === 'rev-parse' + ? { stdout: '--path-format=absolute\n.git\n', stderr: '' } + : { stdout: '', stderr: '' } + ) + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('/repo/one', 'origin') + await controller.getOrStartRemoteFetch('/repo/two', 'origin') + + expect(armedTargets().map((entry) => entry.key)).toEqual([ + 'local::/repo/one/.git', + 'local::/repo/two/.git' + ]) + }) + + it('reports the repo as busy while another fetch on it is in flight', async () => { + const controller = new RuntimeRemoteFetchController() + await controller.getOrStartRemoteFetch('/repo', 'first') + const isBusy = busyProbeFor('local::/repo/.git') + expect(isBusy?.()).toBe(false) + + let releaseFetch: (() => void) | undefined + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + await new Promise((resolve) => { + releaseFetch = resolve + }) + return { stdout: '', stderr: '' } + }) + const second = controller.getOrStartRemoteFetch('/repo', 'second') + await vi.waitFor(() => expect(releaseFetch).toBeDefined()) + expect(isBusy?.()).toBe(true) + + releaseFetch?.() + await second + expect(isBusy?.()).toBe(false) + }) + + it('arms even when the fetch fails, because a partial fetch still writes refs', async () => { + const controller = new RuntimeRemoteFetchController() + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + throw new Error('network is unreachable') + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect(controller.getOrStartRemoteFetch('/repo', 'origin')).resolves.toEqual({ + ok: false, + errorKind: 'git_error' + }) + expect(armedTargets()).toHaveLength(1) + }) +}) diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts index c3d83450b7c..ba37b0a312f 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -18,6 +18,7 @@ import { AgentSessionTransitionRecorder } from '../stats/agent-session-transitio import { ClaudeUsageStore } from '../claude-usage/store' import { CodexUsageStore } from '../codex-usage/store' import { OpenCodeUsageStore } from '../opencode-usage/store' +import { installRepoMaintenanceIdleGate } from '../repo-maintenance-idle-gate' import { mainProcessState as state } from './main-process-state' export function initializeMainProcessObservers(): void { @@ -35,6 +36,10 @@ export function initializeMainProcessObservers(): void { ) // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. state.agentAwakeService.setStatuses([]) + state.uninstallRepoMaintenanceIdleGate = installRepoMaintenanceIdleGate({ + isQuitting: () => state.isQuitting, + getWorkingAgentCount: () => state.agentAwakeService?.getWorkingAgentCount() ?? 0 + }) const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { const ownedIdentities = identities.map((identity) => ({ diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index ec893456c5a..61203bc3164 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -14,6 +14,7 @@ import { clearRuntimeMetadataIfOwned } from '../runtime/runtime-metadata' import { shutdownPairedRuntimeBrowserClientHosts } from '../browser/paired-runtime-browser-client-host-runtime' import { browserManager } from '../browser/browser-manager' import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery' +import { awaitPackedRefsLockRelease } from '../git/local-repo-ref-maintenance' import { settleTeardownWithinDeadline, settleWithinMs } from '../quit-teardown-deadline' import { quitTeardownStartGate } from '../quit-teardown-start-gate' import { setUnreadDockBadgeCount } from '../dock/unread-badge' @@ -33,6 +34,8 @@ let daemonDisconnectDone = false let watcherShutdownPromise: Promise | null = null // Why 2s: a config delete is best-effort, not durable state. const GROK_HOOK_CLEANUP_DEADLINE_MS = 2_000 +// Why 2s: long enough for a `pack-refs` child to take SIGTERM and unlink its lock. +const REF_MAINTENANCE_QUIT_DEADLINE_MS = 2_000 function shutdownWatchersOnce(): Promise { if (state.watcherShutdownDone) { @@ -73,6 +76,10 @@ function installBeforeQuitHandler(): void { state.unsubscribeAgentAwakeStatusChanges = null state.agentAwakeService?.dispose() state.agentAwakeService = null + // Why wait but not uninstall: a renderer beforeunload can still veto this + // quit, and tearing the sweep down here would kill it for the rest of the + // session. `isQuitting` already vetoes new attempts; will-quit does the teardown. + state.repoMaintenanceShutdown = awaitPackedRefsLockRelease() // Why: defer PTY cleanup to will-quit so the renderer captures scrollback before PTY-exit events unmount TerminalPane (dropping its capture callbacks). state.rateLimits?.stop() }) @@ -123,6 +130,16 @@ function installWillQuitHandler(): void { const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime() state.pluginService = null setUnreadDockBadgeCount(0) + // Why wait rather than kill: the child finishes fine orphaned, and signalling + // it mid-prune strands a ref lock Git never clears. The wait is only for the + // short rewrite window, and is bounded so a quit can never hang on it. + const refMaintenanceShutdown = settleWithinMs( + Promise.all([state.repoMaintenanceShutdown, state.uninstallRepoMaintenanceIdleGate?.()]).then( + () => {} + ), + REF_MAINTENANCE_QUIT_DEADLINE_MS + ).then(() => {}) + state.uninstallRepoMaintenanceIdleGate = null agentHookServer.stop() // Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a // bare script path that cannot express the guard and would otherwise keep spawning after quit. @@ -219,6 +236,7 @@ function installWillQuitHandler(): void { { name: 'plugin-hosts', promise: pluginHostShutdown }, { name: 'skill-uploads', promise: skillUploadShutdown }, { name: 'grok-hooks', promise: grokHookCleanup }, + { name: 'ref-maintenance', promise: refMaintenanceShutdown }, { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, { name: 'structured-agent-session', promise: structuredAgentSessionShutdown }, { name: 'usage-cache', promise: usageCacheFlush }, diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index 05c45d5b567..d48219b461e 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -71,6 +71,8 @@ export const mainProcessState = { headlessBrowserDisplayAvailable: false, starNag: null as StarNagService | null, agentAwakeService: null as AgentAwakeService | null, + uninstallRepoMaintenanceIdleGate: null as (() => Promise) | null, + repoMaintenanceShutdown: Promise.resolve() as Promise, crashReports: null as CrashReportStore | null, unsubscribeAgentAwakeStatusChanges: null as (() => void) | null, publishProviderSessionChanges: null as diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts index 22bcf5d1e2c..b4d86923490 100644 --- a/src/main/worktree-create-preparation.ts +++ b/src/main/worktree-create-preparation.ts @@ -61,6 +61,11 @@ type ConsumePreparedWorktreeArgs = { const preparations = new Map() +/** A prepared checkout is a create that is either in flight or imminent. */ +export function hasPendingWorktreeCreatePreparations(): boolean { + return preparations.size > 0 || staleCleanupInFlight.size > 0 +} + function pathOps(path: string): Pick { return isWindowsAbsolutePathLike(path) ? win32 : posix } diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 3a8f562dff1..2861c447788 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -277,6 +277,39 @@ describeBinaryCompatibility('real Git binary compatibility', () => { ).rejects.toMatchObject({ code: 1 }) }) + it('packs loose refs and reads the maintenance opt-out at the baseline', async () => { + // Why: idle ref maintenance runs `pack-refs --all --prune` on every supported + // Git rather than the 2.45+ `--auto` form, and reads `maintenance.auto` to + // honour a user who disabled Git's own auto-maintenance. Both must work at 2.25. + const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() + const packedRef = 'refs/remotes/origin/compat-pack-refs' + await runGit(['update-ref', packedRef, head]) + await expect(readFile(join(repoPath, '.git', packedRef), 'utf-8')).resolves.toContain(head) + + await expect(runGit(['pack-refs', '--all', '--prune'])).resolves.toBeDefined() + + // The loose file is gone and the ref still resolves through packed-refs. + await expect(readFile(join(repoPath, '.git', packedRef), 'utf-8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + await expect(runGit(['rev-parse', '--verify', packedRef])).resolves.toMatchObject({ + stdout: `${head}\n` + }) + await expect(readFile(join(repoPath, '.git', 'packed-refs'), 'utf-8')).resolves.toContain( + packedRef + ) + + // `--get` exits 1 on an unset key; that absence must read as consent, not opt-out. + await expect(runGit(['config', '--bool', '--get', 'maintenance.auto'])).rejects.toMatchObject({ + code: 1 + }) + await runGit(['config', 'maintenance.auto', 'false']) + await expect(runGit(['config', '--bool', '--get', 'maintenance.auto'])).resolves.toMatchObject({ + stdout: 'false\n' + }) + await runGit(['config', '--unset', 'maintenance.auto']) + }) + it('fetches hosted review heads into dedicated refs', async () => { const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() await runGit(['update-ref', 'refs/pull/42/head', head]) diff --git a/src/shared/loose-ref-count.test.ts b/src/shared/loose-ref-count.test.ts new file mode 100644 index 00000000000..c69f655121a --- /dev/null +++ b/src/shared/loose-ref-count.test.ts @@ -0,0 +1,119 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +// Wraps the real `readdir` so the walk's concurrency is observable without +// changing what it reads. +const readdirCalls = vi.hoisted(() => ({ outstanding: 0, peak: 0, count: 0 })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal>() + const realReaddir = actual.readdir as (...args: unknown[]) => Promise + return { + ...actual, + readdir: async (...args: unknown[]) => { + readdirCalls.outstanding += 1 + readdirCalls.count += 1 + readdirCalls.peak = Math.max(readdirCalls.peak, readdirCalls.outstanding) + try { + return await realReaddir(...args) + } finally { + readdirCalls.outstanding -= 1 + } + } + } +}) + +import { countLooseRefs } from './loose-ref-count' + +const roots: string[] = [] + +async function makeRefsTree(counts: Record): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-loose-refs-')) + roots.push(root) + const refs = join(root, 'refs') + for (const [namespace, count] of Object.entries(counts)) { + const directory = join(refs, namespace) + await mkdir(directory, { recursive: true }) + for (let index = 0; index < count; index += 1) { + await writeFile(join(directory, `ref-${index}`), 'a'.repeat(40)) + } + } + await mkdir(refs, { recursive: true }) + return refs +} + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('countLooseRefs', () => { + it('counts files across nested namespaces', async () => { + const refs = await makeRefsTree({ heads: 3, 'remotes/origin': 4, 'remotes/fork/deep': 2 }) + + await expect(countLooseRefs(refs, 100)).resolves.toEqual({ count: 9, saturated: false }) + }) + + it('stops at the budget instead of walking the whole backlog', async () => { + const refs = await makeRefsTree({ 'remotes/origin': 500 }) + + const result = await countLooseRefs(refs, 10) + + expect(result).toEqual({ count: 10, saturated: true }) + }) + + it('reports zero for a repository with no refs directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-loose-refs-missing-')) + roots.push(root) + + await expect(countLooseRefs(join(root, 'refs'), 100)).resolves.toEqual({ + count: 0, + saturated: false + }) + }) + + it('never has more than one directory read outstanding', async () => { + // libuv's filesystem thread pool has four slots shared with the whole main + // process. A probe that fanned out would stall unrelated fs work, so this + // pins the walk as strictly sequential rather than merely bounded. + const refs = await makeRefsTree({ + 'remotes/a': 3, + 'remotes/b': 3, + 'remotes/c': 3, + 'remotes/d': 3, + 'remotes/e/deep': 3 + }) + readdirCalls.peak = 0 + readdirCalls.count = 0 + + await countLooseRefs(refs, 1000) + + expect(readdirCalls.count).toBeGreaterThan(1) + expect(readdirCalls.peak).toBe(1) + }) + + it('reads each directory once rather than streaming it in batches', async () => { + // One thread-pool round trip per directory is what makes the probe ~8x + // cheaper than the streaming form on a real degraded repository. + const refs = await makeRefsTree({ 'remotes/origin': 400 }) + readdirCalls.count = 0 + + await countLooseRefs(refs, 1000) + + // refs/ plus refs/remotes plus refs/remotes/origin. + expect(readdirCalls.count).toBe(3) + }) + + it('does not follow directory symlinks into a loop', async () => { + const refs = await makeRefsTree({ heads: 2 }) + await symlink(refs, join(refs, 'loop'), 'dir') + + const result = await countLooseRefs(refs, 100) + + expect(result.saturated).toBe(false) + // The symlink is one dirent, never a second traversal of the tree. + expect(result.count).toBe(3) + }) +}) diff --git a/src/shared/loose-ref-count.ts b/src/shared/loose-ref-count.ts new file mode 100644 index 00000000000..8f31d8b03a8 --- /dev/null +++ b/src/shared/loose-ref-count.ts @@ -0,0 +1,80 @@ +import { readdir } from 'node:fs/promises' +import { join } from 'node:path' + +export type LooseRefCount = { + /** Loose ref files seen, never above `budget`. */ + count: number + /** The walk stopped early, so `count` is a floor rather than the total. */ + saturated: boolean +} + +// Why: a ref tree is shallow and wide; this bounds both the directories visited +// and the queue holding those still to visit, so neither a symlink loop nor a +// pathological repo turns a gate probe into an unbounded walk. +const DIRECTORY_VISIT_CEILING = 4096 + +/** + * Count loose refs under a repository's `refs/` directory, stopping at `budget`. + * + * Deliberately budgeted: callers use this as an admission gate, so the cost has + * to be bounded by the threshold being tested and not by the size of the + * backlog it is testing for. + * + * One `readdir` per directory, dirents only -- no `stat` per entry, and no + * `opendir` streaming. Measured against a real 36,600-loose-ref repository, the + * batched form is ~8x faster (23ms vs 177ms median to reach a 1000 threshold) + * and holds the event loop for less than half as long, because streaming issues + * a thread-pool round trip every 32 entries where this issues one per + * directory. The cost is holding one directory's dirents at a time, which is + * bounded by the widest ref namespace rather than by the size of the tree. + * + * Strictly sequential on purpose: it awaits one directory before opening the + * next, so it can never occupy more than one of libuv's four thread-pool slots + * and cannot stall unrelated main-process filesystem work. + * + * `signal` stops the walk between directories. A single hung `readdir` is not + * interruptible, but it holds no Git lock, so it delays only maintenance. + */ +export async function countLooseRefs( + refsDirectory: string, + budget: number, + signal?: AbortSignal +): Promise { + const pending = [refsDirectory] + let count = 0 + let visited = 0 + while (pending.length > 0) { + const directory = pending.pop() + if (directory === undefined) { + break + } + visited += 1 + // A cancelled walk reports what it saw as a floor rather than throwing; callers + // already have to treat a saturated result as "not known to be clean". + if ( + signal?.aborted === true || + visited > DIRECTORY_VISIT_CEILING || + pending.length > DIRECTORY_VISIT_CEILING + ) { + return { count, saturated: true } + } + let entries: { name: string; isDirectory: () => boolean }[] + try { + entries = await readdir(directory, { withFileTypes: true }) + } catch { + // A missing or unreadable namespace contributes nothing to the count. + continue + } + for (const entry of entries) { + if (entry.isDirectory()) { + pending.push(join(directory, entry.name)) + continue + } + count += 1 + if (count >= budget) { + return { count, saturated: true } + } + } + } + return { count, saturated: false } +} diff --git a/src/shared/packed-refs-lock-gate.ts b/src/shared/packed-refs-lock-gate.ts new file mode 100644 index 00000000000..f5cff6b8c4a --- /dev/null +++ b/src/shared/packed-refs-lock-gate.ts @@ -0,0 +1,43 @@ +/** + * Tracks the one window in a pack that actually excludes anybody: the + * `packed-refs` rewrite. Callers about to touch refs wait this out rather than + * killing the child, because a signal delivered into the prune phase strands a + * `refs/**\/*.lock` roughly one time in five and Git never clears those. + */ +export class PackedRefsLockGate { + private held = false + private waiters: (() => void)[] = [] + + setHeld(held: boolean): void { + this.held = held + if (held) { + return + } + const waiting = this.waiters + this.waiters = [] + for (const resolve of waiting) { + resolve() + } + } + + /** Resolves on release, or on `timeoutMs` -- past which Git's own retry is the better bet. */ + whenReleased(timeoutMs: number): Promise { + if (!this.held) { + return Promise.resolve() + } + return new Promise((resolve) => { + let settled = false + const finish = (): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + resolve() + } + const timer = setTimeout(finish, timeoutMs) + timer.unref?.() + this.waiters.push(finish) + }) + } +} diff --git a/src/shared/repo-ref-maintenance-policy.ts b/src/shared/repo-ref-maintenance-policy.ts new file mode 100644 index 00000000000..7dc8edd1de3 --- /dev/null +++ b/src/shared/repo-ref-maintenance-policy.ts @@ -0,0 +1,166 @@ +/** + * Idle-time loose-ref packing for repositories Orca itself degrades. + * + * Orca strips git's auto-maintenance off its own frequent fetches + * (`GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS`) and never compensated, so an + * Orca-driven checkout accumulates loose refs forever and every ref + * enumeration -- `show-ref`, `for-each-ref`, worktree create -- pays for them. + * This is the compensation: after a repo goes quiet, probe it, and pack only + * when the backlog is real. + * + * The engine is host-agnostic on purpose. The execution host owns everything + * that touches execution, so each host supplies its own target (which git to + * run, which filesystem to walk) and all state here is keyed per host. + */ + +/** + * Below this, ref enumeration is already fast and `pack-refs` would cost more + * than it saves. + * + * Git's own files-backend auto heuristic (2.47+) packs at + * `max(16, log2(packed_refs_bytes / 100) * 5)` loose refs -- about 76 for the + * 4.1 MB `packed-refs` that motivated this work. A flat 1000 is roughly an + * order of magnitude more conservative on purpose: this runs unasked against a + * real checkout, and being late is cheap where being wrong is not. + */ +export const LOOSE_REF_PACK_THRESHOLD = 1000 + +/** No fetch, create, or other tracked write on the repo for this long. */ +export const REF_MAINTENANCE_QUIET_PERIOD_MS = 10 * 60_000 + +/** Packing empties the backlog; there is nothing to do again for a long while. */ +export const REF_MAINTENANCE_PACKED_COOLDOWN_MS = 12 * 60 * 60_000 + +/** A healthy or unresolvable repo should not be re-probed on every quiet window. */ +export const REF_MAINTENANCE_CLEAN_COOLDOWN_MS = 6 * 60 * 60_000 + +/** A failing repo (permissions, stale lock) must not be retried in a loop. */ +export const REF_MAINTENANCE_FAILURE_COOLDOWN_MS = 6 * 60 * 60_000 + +/** + * A repository whose `packed-refs.lock` is a strand from our own dead process + * becomes reclaimable at `PACK_REFS_TIMEOUT_MS`, so retry near that rather than + * serving the full failure cooldown -- otherwise a Windows force-kill leaves + * every ref deletion in that repo failing for six hours instead of thirty + * minutes. + */ +export const REF_MAINTENANCE_LOCKED_COOLDOWN_MS = 30 * 60_000 + +/** + * `pack-refs` holds `packed-refs.lock` only while it rewrites the file -- + * measured at 0.03-1.37s of a 23-32s run, the other ~95% being the prune phase + * unlinking loose refs. A caller about to touch refs waits out that window + * instead of killing the pack. + */ +export const PACKED_REFS_LOCK_POLL_MS = 50 + +/** + * Ceiling on that wait. Past this we stop blocking the user and let Git's own + * retry (`core.filesRefLockTimeout`, `core.packedRefsTimeout`) handle it, which + * is what happens today without any of this. + */ +export const PACKED_REFS_LOCK_WAIT_MS = 5_000 + +/** + * `pack-refs --prune` unlinks one file per loose ref. Paying off a 36k-ref + * backlog measured at ~83s on APFS, so the deadline has to clear a cold repo on + * a slow disk by a wide margin. A kill mid-run is safe -- git renames + * `packed-refs` into place atomically and the surviving loose refs stay + * authoritative -- but it wastes the work. + */ +export const PACK_REFS_TIMEOUT_MS = 15 * 60_000 + +/** + * Ancient, safe on the Git 2.25 baseline, and does exactly one thing. + * + * Not `pack-refs --auto`: that arrived in 2.45 and unconditionally rewrote + * `packed-refs` on the files backend until 2.47, so it is both unavailable at + * our baseline and wrong on two shipped releases. Not `git maintenance run` + * either -- newer, and it pulls in commit-graph and repack work we did not ask + * for. `--all` is required because the backlog is `refs/heads` and + * `refs/remotes`, which a bare `pack-refs` leaves alone. + */ +export const PACK_REFS_ARGS = ['pack-refs', '--all', '--prune'] as const + +/** + * Backstop on a whole attempt: aborts it, rather than abandoning it. Every Git + * child is already deadlined, but an admission wait is not, and the whole app + * shares one maintenance slot. Abandoning would release that slot while a pack + * that may still hold `packed-refs.lock` runs on, so the deadline cancels the + * work instead and the slot is held until it really stops. + */ +export const REF_MAINTENANCE_ATTEMPT_DEADLINE_MS = PACK_REFS_TIMEOUT_MS + 5 * 60_000 + +export type RefMaintenanceOutcome = + | 'packed' + | 'below_threshold' + | 'unresolved' + | 'opted_out' + | 'deferred' + | 'interrupted' + | 'locked' + | 'timed_out' + | 'failed' + +/** Structurally satisfied by the tracer's `ActiveSpan`. */ +export type RefMaintenanceSpan = { + setAttribute(key: string, value: unknown): void +} + +export type RepoRefMaintenanceTarget = { + /** Repo identity scoped to its execution host; all state here is keyed by it. */ + readonly key: string + /** Absolute `refs/` path *on the host that runs the walk*, or undefined if unresolvable. */ + resolveRefsDirectory(signal: AbortSignal): Promise + /** A user who told Git not to auto-maintain this repo has told Orca too. */ + isOptedOut?(signal: AbortSignal): Promise + /** True while work on *this repo* is in flight -- a fetch, a create, a removal. */ + isBusy?(): boolean + /** + * Runs `pack-refs` to completion. Deliberately takes no abort signal: killing + * a pack is measurably worse than waiting for it (see `PACKED_REFS_LOCK_*`). + * It must report `packed-refs.lock` transitions through `lock` so callers can + * wait for the short window that actually blocks them. + */ + packRefs(lock: PackedRefsLockReporter): Promise +} + +/** How `packRefs` tells the scheduler whether the exclusive write window is open. */ +export type PackedRefsLockReporter = { + setHeld(held: boolean): void +} + +export type RepoRefMaintenanceOptions = { + now?: () => number + /** True while app-wide work this must not race is in flight (create, live agent, battery, quit). */ + isBusy?: () => boolean + /** Wraps one attempt so a host can trace it; must invoke and await `attempt`. */ + observe?: (attempt: (span: RefMaintenanceSpan) => Promise) => Promise + quietPeriodMs?: number + looseRefThreshold?: number + onError?: (error: unknown) => void +} + +/** Marks an abort Orca asked for, so the attempt is retried rather than blamed on the repo. */ +export class RefMaintenanceInterrupted extends Error { + constructor( + reason: string, + /** True when the attempt ran out of time rather than yielding to real work. */ + readonly deadline = false + ) { + super(`Ref maintenance interrupted: ${reason}`) + this.name = 'RefMaintenanceInterrupted' + } +} + +/** + * The repository's `packed-refs.lock` is held by something we must not touch. + * Distinct from a failure so a strand our own dead process left can be retried + * once it ages into reclaimability, rather than parked for six hours. + */ +export class RefMaintenanceRepoLocked extends Error { + constructor(detail: string) { + super(`packed-refs.lock is held: ${detail}`) + this.name = 'RefMaintenanceRepoLocked' + } +} diff --git a/src/shared/repo-ref-maintenance.test.ts b/src/shared/repo-ref-maintenance.test.ts new file mode 100644 index 00000000000..f59b894748f --- /dev/null +++ b/src/shared/repo-ref-maintenance.test.ts @@ -0,0 +1,530 @@ +import { mkdir, mkdtemp, 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 { RepoRefMaintenance } from './repo-ref-maintenance' +import { + RefMaintenanceRepoLocked, + REF_MAINTENANCE_PACKED_COOLDOWN_MS, + PACKED_REFS_LOCK_WAIT_MS, + type PackedRefsLockReporter, + type RefMaintenanceSpan, + type RepoRefMaintenanceOptions, + type RepoRefMaintenanceTarget +} from './repo-ref-maintenance-policy' + +const QUIET_MS = 1000 +const THRESHOLD = 5 +const roots: string[] = [] + +async function refsDirectoryWith(looseRefs: number): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-ref-maintenance-')) + roots.push(root) + const refs = join(root, 'refs', 'remotes', 'origin') + await mkdir(refs, { recursive: true }) + for (let index = 0; index < looseRefs; index += 1) { + await writeFile(join(refs, `ref-${index}`), 'a') + } + return join(root, 'refs') +} + +/** More directories than `countLooseRefs` will visit, but very few files. */ +async function saturatingRefsDirectory(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-ref-maintenance-wide-')) + roots.push(root) + const refs = join(root, 'refs') + for (let index = 0; index < 4200; index += 1) { + await mkdir(join(refs, `ns-${index}`), { recursive: true }) + } + return refs +} + +/** Stands in for a pack that moved the refs into packed-refs before erroring. */ +async function emptyRefsDirectory(refs: string): Promise { + await rm(refs, { recursive: true, force: true }) +} + +function attributesOf(span: RefMaintenanceSpan): Record { + return (span as unknown as { recorded: Record }).recorded +} + +function recordingSpan(): RefMaintenanceSpan { + const recorded: Record = {} + return { + recorded, + setAttribute(key: string, value: unknown) { + recorded[key] = value + } + } as unknown as RefMaintenanceSpan +} + +type Harness = { + maintenance: RepoRefMaintenance + spans: RefMaintenanceSpan[] + packRefs: ((lock: PackedRefsLockReporter) => Promise) & { mock: { calls: unknown[] } } +} + +function createHarness( + overrides: Partial & { + packRefs?: (lock: PackedRefsLockReporter) => Promise + } = {} +): Harness { + const spans: RefMaintenanceSpan[] = [] + const packRefs = vi.fn<(lock: PackedRefsLockReporter) => Promise>( + overrides.packRefs ?? (async () => {}) + ) + const maintenance = new RepoRefMaintenance({ + quietPeriodMs: QUIET_MS, + looseRefThreshold: THRESHOLD, + now: () => Date.now(), + observe: (attempt) => { + const span = recordingSpan() + spans.push(span) + return attempt(span) + }, + ...overrides + }) + return { maintenance, spans, packRefs } +} + +function target( + key: string, + refsDirectory: string, + packRefs: (lock: PackedRefsLockReporter) => Promise, + extra: Partial = {} +): RepoRefMaintenanceTarget { + return { + key, + resolveRefsDirectory: async () => refsDirectory, + packRefs, + ...extra + } +} + +/** Resolves the first time the pack starts, so tests never race real filesystem I/O. */ +function packStartSignal(): { + started: Promise + onStart: (lock: PackedRefsLockReporter) => void +} { + let onStart: (lock: PackedRefsLockReporter) => void = () => {} + const started = new Promise((resolve) => { + onStart = resolve + }) + return { started, onStart } +} + +function yieldToIo(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} + +/** + * Spins the real event loop until `predicate` holds, so filesystem completions + * can land while `setTimeout` is faked. Bounded by wall clock rather than by a + * turn count: a loaded CI runner exhausts a fixed number of turns long before + * the I/O finishes, which fails as a confusing assertion somewhere else. + */ +async function until(predicate: () => boolean, what: string): Promise { + const deadline = Date.now() + 10_000 + while (!predicate() && Date.now() < deadline) { + await yieldToIo() + } + if (!predicate()) { + throw new Error(`timed out after 10s waiting for ${what}`) + } +} + +/** + * Like `until`, but for conditions that also need a scheduled retry to fire: + * spinning the real loop alone can never satisfy them, because `setTimeout` is + * faked. Alternates advancing the fake clock with yielding to real I/O. + */ +async function untilWithTimers(predicate: () => boolean, what: string): Promise { + const deadline = Date.now() + 10_000 + while (!predicate() && Date.now() < deadline) { + await vi.advanceTimersByTimeAsync(QUIET_MS) + await yieldToIo() + } + if (!predicate()) { + throw new Error(`timed out after 10s waiting for ${what}`) + } +} + +/** Fires the quiet-period timer and waits for the attempt it starts. */ +async function elapseQuietPeriod(maintenance: RepoRefMaintenance, periods = 1): Promise { + await vi.advanceTimersByTimeAsync(QUIET_MS * periods) + await maintenance.whenAttemptSettled() +} + +/** Only the quiet-period timer is faked; real filesystem I/O still has to complete. */ +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) +}) + +afterEach(async () => { + vi.useRealTimers() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('RepoRefMaintenance gating', () => { + it('packs only after the repo has been quiet for the full period', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + const { maintenance, spans, packRefs } = createHarness() + const repo = target('local::/repo/.git', refs, packRefs) + + maintenance.arm(repo) + await vi.advanceTimersByTimeAsync(QUIET_MS - 1) + expect(packRefs).not.toHaveBeenCalled() + + // A second write restarts the countdown rather than shortening it. + maintenance.arm(repo) + await vi.advanceTimersByTimeAsync(QUIET_MS - 1) + expect(packRefs).not.toHaveBeenCalled() + + await elapseQuietPeriod(maintenance) + expect(packRefs).toHaveBeenCalledTimes(1) + expect(attributesOf(spans[0])).toMatchObject({ + 'repo.maintenance_outcome': 'packed', + 'repo.maintenance_key': 'local::/repo/.git', + 'git.loose_ref_count': THRESHOLD + 1 + }) + }) + + it('leaves a healthy repository alone', async () => { + const refs = await refsDirectoryWith(THRESHOLD - 1) + const { maintenance, spans, packRefs } = createHarness() + + maintenance.arm(target('local::/healthy/.git', refs, packRefs)) + await elapseQuietPeriod(maintenance) + + expect(packRefs).not.toHaveBeenCalled() + expect(attributesOf(spans[0])).toMatchObject({ + 'repo.maintenance_outcome': 'below_threshold', + 'git.loose_ref_count': THRESHOLD - 1 + }) + }) + + it('does not run while the app is busy', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + let busy = true + const { maintenance, packRefs } = createHarness({ isBusy: () => busy }) + + maintenance.arm(target('local::/busy/.git', refs, packRefs)) + await elapseQuietPeriod(maintenance) + expect(packRefs).not.toHaveBeenCalled() + + // The deferral re-arms on a backed-off delay, so the next window picks it up. + busy = false + await elapseQuietPeriod(maintenance, 2) + expect(packRefs).toHaveBeenCalledTimes(1) + }) + + it('does not run while the repo itself has work in flight', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + const { maintenance, packRefs } = createHarness() + + maintenance.arm(target('local::/fetching/.git', refs, packRefs, { isBusy: () => true })) + await elapseQuietPeriod(maintenance) + + expect(packRefs).not.toHaveBeenCalled() + }) + + it('honours a user who disabled Git auto-maintenance for the repo', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + const { maintenance, spans, packRefs } = createHarness() + + maintenance.arm( + target('local::/opted-out/.git', refs, packRefs, { isOptedOut: async () => true }) + ) + await elapseQuietPeriod(maintenance) + + expect(packRefs).not.toHaveBeenCalled() + expect(attributesOf(spans[0])['repo.maintenance_outcome']).toBe('opted_out') + }) + + it('never reads a truncated walk as a clean repository', async () => { + const { maintenance, spans, packRefs } = createHarness() + + // A walk that stopped early reports a floor, so a low count is not evidence of health. + maintenance.arm({ + key: 'local::/saturated/.git', + resolveRefsDirectory: async () => saturatingRefsDirectory(), + packRefs + }) + await elapseQuietPeriod(maintenance) + + expect(packRefs).toHaveBeenCalledTimes(1) + expect(attributesOf(spans[0])['repo.maintenance_outcome']).toBe('packed') + }) + + it('records a repo whose packed-refs lock is held, and retries sooner than a failure', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + const { maintenance, spans } = createHarness() + + maintenance.arm( + target('local::/locked/.git', refs, async () => { + throw new RefMaintenanceRepoLocked('our own lock, not yet old enough to reclaim') + }) + ) + await elapseQuietPeriod(maintenance) + + expect(attributesOf(spans[0])['repo.maintenance_outcome']).toBe('locked') + }) + + it('skips a repository whose common dir cannot be resolved', async () => { + const { maintenance, spans, packRefs } = createHarness() + + maintenance.arm({ + key: 'local::/gone/.git', + resolveRefsDirectory: async () => undefined, + packRefs + }) + await elapseQuietPeriod(maintenance) + + expect(packRefs).not.toHaveBeenCalled() + expect(attributesOf(spans[0])['repo.maintenance_outcome']).toBe('unresolved') + }) +}) + +describe('RepoRefMaintenance single-flight and backoff', () => { + it('runs one repository at a time', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + let concurrent = 0 + let peak = 0 + const releases: (() => void)[] = [] + const { maintenance } = createHarness() + const slowPack = async (): Promise => { + concurrent += 1 + peak = Math.max(peak, concurrent) + await new Promise((resolve) => releases.push(resolve)) + concurrent -= 1 + } + + maintenance.arm(target('local::/a/.git', refs, slowPack)) + maintenance.arm(target('local::/b/.git', refs, slowPack)) + await vi.advanceTimersByTimeAsync(QUIET_MS) + await until(() => concurrent === 1, 'a pack to start') + expect(concurrent).toBe(1) + + releases.shift()?.() + await maintenance.whenAttemptSettled() + // The second repo was deferred behind the first, so its retry is on a timer. + await untilWithTimers(() => concurrent === 1, 'the second repo to start') + releases.shift()?.() + await maintenance.whenAttemptSettled() + + expect(peak).toBe(1) + expect(concurrent).toBe(0) + }) + + it('waits out the rewrite window instead of killing the pack', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + let finished = false + let release: (() => void) | undefined + const { maintenance } = createHarness() + const started = packStartSignal() + + maintenance.arm( + target('local::/yield/.git', refs, async (lock) => { + lock.setHeld(true) + started.onStart(lock) + await new Promise((resolve) => { + release = () => { + lock.setHeld(false) + resolve() + } + }) + finished = true + }) + ) + await vi.advanceTimersByTimeAsync(QUIET_MS) + await started.started + + let paused = false + void maintenance.pause('worktree-remove').then(() => { + paused = true + }) + await vi.advanceTimersByTimeAsync(1) + // Blocked while the rewrite window is open... + expect(paused).toBe(false) + expect(finished).toBe(false) + + release?.() + await until(() => paused, 'pause() to resolve') + // ...and released without the pack ever being cancelled. + expect(paused).toBe(true) + expect(finished).toBe(true) + }) + + it('gives up waiting on the lock rather than blocking the user indefinitely', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + const { maintenance } = createHarness() + const started = packStartSignal() + + maintenance.arm( + target('local::/stuck-lock/.git', refs, async (lock) => { + lock.setHeld(true) + started.onStart(lock) + await new Promise(() => {}) + }) + ) + await vi.advanceTimersByTimeAsync(QUIET_MS) + await started.started + + let paused = false + void maintenance.pause('git-fetch').then(() => { + paused = true + }) + await vi.advanceTimersByTimeAsync(PACKED_REFS_LOCK_WAIT_MS) + await until(() => paused, 'pause() to resolve') + + expect(paused).toBe(true) + }) + + it('reopens the window only when the last overlapping caller releases', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + const { maintenance, packRefs } = createHarness() + + const outer = await maintenance.pause('worktree-add') + const inner = await maintenance.pause('git-fetch') + maintenance.arm(target('local::/nested/.git', refs, packRefs)) + + await elapseQuietPeriod(maintenance, 8) + expect(packRefs).not.toHaveBeenCalled() + + inner() + await elapseQuietPeriod(maintenance, 8) + expect(packRefs).not.toHaveBeenCalled() + + outer() + await elapseQuietPeriod(maintenance, 8) + expect(packRefs).toHaveBeenCalledTimes(1) + }) + + it('restarts every armed countdown when the user does ref work themselves', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + const { maintenance, packRefs } = createHarness() + + const firstPack = packStartSignal() + const observed = target('local::/a/.git', refs, async (lock) => { + firstPack.onStart(lock) + await packRefs(lock) + }) + maintenance.arm(observed) + maintenance.arm(target('local::/b/.git', refs, packRefs)) + await vi.advanceTimersByTimeAsync(QUIET_MS - 1) + + // A manual fetch says the user is at the keyboard, so nothing may fire yet. + maintenance.postponeAll() + await vi.advanceTimersByTimeAsync(QUIET_MS - 1) + expect(packRefs).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(QUIET_MS) + await firstPack.started + expect(packRefs).toHaveBeenCalled() + }) + + it('costs nothing when no pack is running', async () => { + const { maintenance } = createHarness() + + const release = await maintenance.pause('git-fetch') + release() + // Releasing twice must not leave the window wedged shut. + release() + const refs = await refsDirectoryWith(THRESHOLD + 2) + const { packRefs } = createHarness() + maintenance.arm(target('local::/free/.git', refs, packRefs)) + await elapseQuietPeriod(maintenance) + + expect(packRefs).toHaveBeenCalledTimes(1) + }) + + it('does not re-pack a repository inside its cooldown', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + let clock = 0 + const { maintenance, packRefs } = createHarness({ now: () => clock }) + const repo = target('local::/cooldown/.git', refs, packRefs) + + maintenance.arm(repo) + await elapseQuietPeriod(maintenance) + expect(packRefs).toHaveBeenCalledTimes(1) + + clock = REF_MAINTENANCE_PACKED_COOLDOWN_MS - 1 + maintenance.arm(repo) + await elapseQuietPeriod(maintenance) + expect(packRefs).toHaveBeenCalledTimes(1) + + clock = REF_MAINTENANCE_PACKED_COOLDOWN_MS + 1 + maintenance.arm(repo) + await elapseQuietPeriod(maintenance) + expect(packRefs).toHaveBeenCalledTimes(2) + }) + + it('counts a pack that could not lock every ref as a success', async () => { + // Field-observed on a machine running several Orca sessions: a branch moved + // mid-pack, Git reported an error, and 36,688 loose refs still became 3. + // Retrying that aggressively would be wrong -- the backlog is gone. + const refs = await refsDirectoryWith(THRESHOLD + 2) + const { maintenance, spans } = createHarness() + const repo = target('local::/raced/.git', refs, async () => { + await emptyRefsDirectory(refs) + throw new Error("error: cannot lock ref 'refs/heads/moved'") + }) + + maintenance.arm(repo) + await elapseQuietPeriod(maintenance) + + expect(attributesOf(spans[0])).toMatchObject({ + 'repo.maintenance_outcome': 'packed', + 'git.pack_refs_partial': true, + 'git.loose_ref_count_after': 0 + }) + + // And it serves the full post-pack cooldown rather than retrying. + maintenance.arm(repo) + await elapseQuietPeriod(maintenance) + expect(spans).toHaveLength(1) + }) + + it('records a failure when the pack left the backlog in place', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + const { maintenance, spans } = createHarness({ + packRefs: async () => { + throw new Error('permission denied') + } + }) + + maintenance.arm(target('local::/denied/.git', refs, () => Promise.reject(new Error('denied')))) + await elapseQuietPeriod(maintenance) + + expect(attributesOf(spans[0])['repo.maintenance_outcome']).toBe('failed') + }) + + it('records a failure instead of throwing, and backs off', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + const { maintenance, spans, packRefs } = createHarness({ + packRefs: async () => { + throw new Error('packed-refs.lock exists') + } + }) + const repo = target('local::/failing/.git', refs, packRefs) + + maintenance.arm(repo) + await elapseQuietPeriod(maintenance) + expect(attributesOf(spans[0])['repo.maintenance_outcome']).toBe('failed') + + maintenance.arm(repo) + await elapseQuietPeriod(maintenance) + expect(packRefs).toHaveBeenCalledTimes(1) + }) + + it('stops scheduling once disposed', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + const { maintenance, packRefs } = createHarness() + + maintenance.arm(target('local::/disposed/.git', refs, packRefs)) + maintenance.dispose() + await elapseQuietPeriod(maintenance) + + expect(packRefs).not.toHaveBeenCalled() + }) +}) diff --git a/src/shared/repo-ref-maintenance.ts b/src/shared/repo-ref-maintenance.ts new file mode 100644 index 00000000000..97e228fab52 --- /dev/null +++ b/src/shared/repo-ref-maintenance.ts @@ -0,0 +1,366 @@ +import { countLooseRefs } from './loose-ref-count' +import { PackedRefsLockGate } from './packed-refs-lock-gate' +import { + LOOSE_REF_PACK_THRESHOLD, + REF_MAINTENANCE_ATTEMPT_DEADLINE_MS, + REF_MAINTENANCE_CLEAN_COOLDOWN_MS, + REF_MAINTENANCE_FAILURE_COOLDOWN_MS, + REF_MAINTENANCE_PACKED_COOLDOWN_MS, + REF_MAINTENANCE_QUIET_PERIOD_MS, + PACKED_REFS_LOCK_WAIT_MS, + REF_MAINTENANCE_LOCKED_COOLDOWN_MS, + RefMaintenanceInterrupted, + RefMaintenanceRepoLocked, + type RefMaintenanceOutcome, + type RefMaintenanceSpan, + type RepoRefMaintenanceOptions, + type RepoRefMaintenanceTarget +} from './repo-ref-maintenance-policy' + +/** + * The scheduler half of idle loose-ref packing: when to probe, when to pack, + * when to stand down. The thresholds and the host contract it works against + * live in `./repo-ref-maintenance-policy`. + */ + +/** Give up until the next real activity rather than re-arming forever. */ +const MAX_DEFERRALS = 6 +/** Each deferral doubles the wait, so a busy app is retried rarely, not hammered. */ +const MAX_DEFERRAL_BACKOFF_MULTIPLIER = 8 +/** Armed repos are evicted oldest-first past this; the next write on one re-arms it. */ +const MAX_TRACKED_REPOS = 64 + +type TrackedRepo = { + target: RepoRefMaintenanceTarget + timer: ReturnType | null + deferrals: number +} + +const noopSpan: RefMaintenanceSpan = { setAttribute: () => {} } + +/** A deadline means something is stuck: back off instead of retrying straight away. */ +function hitDeadline(signal: AbortSignal): boolean { + return signal.reason instanceof RefMaintenanceInterrupted && signal.reason.deadline +} + +export class RepoRefMaintenance { + private readonly tracked = new Map() + private readonly cooldownUntil = new Map() + private readonly now: () => number + private readonly isAppBusy: () => boolean + private readonly observe: NonNullable + private readonly quietPeriodMs: number + private readonly looseRefThreshold: number + private readonly onError: (error: unknown) => void + // Why: at most one pack-refs anywhere. It holds a general git admission slot + // for its whole run, and two at once would halve git throughput on a small host. + // The slot is never released while a pack that could hold `packed-refs.lock` + // is still running -- an interrupt cancels the work and waits for it to stop. + private inFlight: Promise | null = null + private inFlightAbort: AbortController | null = null + private readonly lockGate = new PackedRefsLockGate() + // Why a count, not a flag: several ref-touching operations overlap routinely + // (a create's fetch inside a create), and the last one out reopens the window. + private suspensions = 0 + private lastAttempt: Promise = Promise.resolve() + private disposed = false + + constructor(options: RepoRefMaintenanceOptions = {}) { + this.now = options.now ?? Date.now + this.isAppBusy = options.isBusy ?? (() => false) + this.observe = options.observe ?? ((attempt) => attempt(noopSpan)) + this.quietPeriodMs = options.quietPeriodMs ?? REF_MAINTENANCE_QUIET_PERIOD_MS + this.looseRefThreshold = options.looseRefThreshold ?? LOOSE_REF_PACK_THRESHOLD + this.onError = options.onError ?? (() => {}) + } + + /** + * Record a write to `target`'s repo and (re)start its quiet-period countdown. + * Every call pushes the attempt further out, so a burst of fetches or a + * worktree create can never be interrupted by maintenance it triggered. + */ + arm(target: RepoRefMaintenanceTarget): void { + if (this.disposed) { + return + } + const existing = this.tracked.get(target.key) + if (existing?.timer) { + clearTimeout(existing.timer) + } + const tracked: TrackedRepo = { target, timer: null, deferrals: existing?.deferrals ?? 0 } + this.tracked.delete(target.key) + this.evictOldestBeyondCap() + this.tracked.set(target.key, tracked) + this.schedule(target.key, tracked) + } + + /** Resolves once the attempt started by the most recent timer has settled. */ + whenAttemptSettled(): Promise { + return this.lastAttempt + } + + /** + * Wait out the `packed-refs` rewrite, if one is in progress. + * + * Deliberately not a kill. The lock is held for 0.03-1.37s of a 23-32s pack; + * the rest is the prune phase, during which a concurrent `fetch --prune`, + * `branch -D` or `update-ref` measurably succeeds because per-ref locks last + * microseconds and Git retries for `core.filesRefLockTimeout`. Signalling the + * child there buys nothing and strands a lock file about one time in five. + * + * Free when no pack is running, which is almost always. + */ + awaitPackedRefsLockRelease(): Promise { + return this.lockGate.whenReleased(PACKED_REFS_LOCK_WAIT_MS) + } + + /** + * Push every armed repository's attempt out by a full quiet period. + * + * User-initiated ref work is evidence the user is active in the app, not just + * in one repo, and it is free -- no key to resolve, no subprocess, nothing at + * all when nothing is armed. + */ + postponeAll(): void { + if (this.disposed) { + return + } + for (const [key, tracked] of this.tracked) { + if (tracked.timer) { + clearTimeout(tracked.timer) + } + tracked.deferrals = 0 + this.schedule(key, tracked) + } + } + + /** + * Hold the repository open for work that is about to touch refs. + * + * Two things at once: no *new* attempt can start for any repository until the + * returned release is called, and the caller waits out any `packed-refs` + * rewrite already in progress. A prune already running is left alone to + * finish -- it does not block the caller. + */ + async pause(_reason: string): Promise<() => void> { + this.suspensions += 1 + let released = false + try { + await this.awaitPackedRefsLockRelease() + } catch { + // The wait cannot reject, but a release must exist even if it did. + } + return () => { + if (!released) { + released = true + this.suspensions -= 1 + } + } + } + + dispose(): void { + this.disposed = true + this.inFlightAbort?.abort(new RefMaintenanceInterrupted('disposed')) + for (const tracked of this.tracked.values()) { + if (tracked.timer) { + clearTimeout(tracked.timer) + } + } + this.tracked.clear() + this.cooldownUntil.clear() + } + + private isBusy(tracked: TrackedRepo): boolean { + return this.isAppBusy() || (tracked.target.isBusy?.() ?? false) + } + + private schedule(key: string, tracked: TrackedRepo, delayMs = this.quietPeriodMs): void { + const timer = setTimeout(() => { + tracked.timer = null + this.lastAttempt = this.attempt(key).catch((error) => this.onError(error)) + }, delayMs) + // Never hold the process open for maintenance. + timer.unref?.() + tracked.timer = timer + } + + private evictOldestBeyondCap(): void { + while (this.tracked.size >= MAX_TRACKED_REPOS) { + const oldest = this.tracked.keys().next() + if (oldest.done) { + return + } + const evicted = this.tracked.get(oldest.value) + if (evicted?.timer) { + clearTimeout(evicted.timer) + } + this.tracked.delete(oldest.value) + } + } + + /** + * `counted` spends the give-up budget. Waiting behind another repository's + * pack, or yielding to work Orca asked us to yield to, does not: both end on + * their own, so charging for them would let a busy machine starve a repo + * until its next fetch. Only "the app is busy" is charged. + */ + private defer(key: string, tracked: TrackedRepo, counted: boolean): void { + // A fetch that landed while this attempt was probing already re-armed the + // repo; that entry is fresher, so the deferral must not overwrite it. + if (this.disposed || this.tracked.has(key)) { + return + } + if (counted) { + if (tracked.deferrals >= MAX_DEFERRALS) { + return + } + tracked.deferrals += 1 + } + this.tracked.set(key, tracked) + const multiplier = Math.min(2 ** tracked.deferrals, MAX_DEFERRAL_BACKOFF_MULTIPLIER) + this.schedule(key, tracked, this.quietPeriodMs * multiplier) + } + + private async attempt(key: string): Promise { + const tracked = this.tracked.get(key) + if (!tracked || this.disposed) { + return + } + this.tracked.delete(key) + const cooldownUntil = this.cooldownUntil.get(key) + if (cooldownUntil !== undefined && this.now() < cooldownUntil) { + return + } + if (this.inFlight !== null) { + this.defer(key, tracked, false) + return + } + if (this.suspensions > 0 || this.isBusy(tracked)) { + this.defer(key, tracked, true) + return + } + const abort = new AbortController() + const deadline = setTimeout( + () => abort.abort(new RefMaintenanceInterrupted('attempt deadline', true)), + REF_MAINTENANCE_ATTEMPT_DEADLINE_MS + ) + deadline.unref?.() + const run = this.observe((span) => this.packIfNeeded(key, tracked, span, abort.signal)) + this.inFlight = run + this.inFlightAbort = abort + try { + await run + } finally { + clearTimeout(deadline) + if (this.inFlight === run) { + this.inFlight = null + this.inFlightAbort = null + } + } + } + + private async packIfNeeded( + key: string, + tracked: TrackedRepo, + span: RefMaintenanceSpan, + signal: AbortSignal + ): Promise { + span.setAttribute('repo.maintenance_key', key) + // Every await below carries the signal, so a caller waiting in `pause()` is + // never stuck behind a probe that has already been told to stop. + if (await tracked.target.isOptedOut?.(signal)) { + this.settle(key, span, 'opted_out', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + return + } + if (signal.aborted) { + this.yieldTo(key, tracked, span, signal) + return + } + const refsDirectory = await tracked.target.resolveRefsDirectory(signal) + if (!refsDirectory) { + this.settle(key, span, 'unresolved', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + return + } + const budget = this.looseRefThreshold + 1 + const before = await countLooseRefs(refsDirectory, budget, signal) + if (signal.aborted) { + this.yieldTo(key, tracked, span, signal) + return + } + span.setAttribute('git.loose_ref_count', before.count) + span.setAttribute('git.loose_ref_threshold', this.looseRefThreshold) + // A saturated walk stopped early, so `count` is a floor -- never read it as "clean". + if (!before.saturated && before.count < this.looseRefThreshold) { + this.settle(key, span, 'below_threshold', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + return + } + // The quiet window can close while the probe walks; re-check before spending a git slot. + if (this.suspensions > 0 || this.isBusy(tracked)) { + span.setAttribute('repo.maintenance_outcome', 'deferred' satisfies RefMaintenanceOutcome) + this.defer(key, tracked, true) + return + } + const startedAt = this.now() + let partial = false + try { + // No signal: the pack runs to completion. Callers that need the refs wait + // out the rewrite window through `pause()` instead of killing it. + await tracked.target.packRefs(this.lockGate) + } catch (error) { + span.setAttribute('repo.maintenance_error', String(error)) + if (error instanceof RefMaintenanceRepoLocked) { + this.settle(key, span, 'locked', REF_MAINTENANCE_LOCKED_COOLDOWN_MS) + return + } + partial = true + } finally { + this.lockGate.setHeld(false) + } + span.setAttribute('git.pack_refs_ms', this.now() - startedAt) + // Judge by the backlog, not by the exit code. On a machine running several + // Orca sessions a branch moving mid-pack is the normal case, and Git's + // response -- leave that one ref loose, pack the rest -- is the correct one. + // Measured in the field: 36,688 loose refs down to 3, reported as an error. + const after = await countLooseRefs(refsDirectory, budget, signal) + span.setAttribute('git.loose_ref_count_after', after.count) + if (partial && (after.saturated || after.count >= this.looseRefThreshold)) { + this.settle(key, span, 'failed', REF_MAINTENANCE_FAILURE_COOLDOWN_MS) + return + } + span.setAttribute('git.pack_refs_partial', partial) + this.settle(key, span, 'packed', REF_MAINTENANCE_PACKED_COOLDOWN_MS) + } + + /** Record an aborted attempt: retry soon if Orca yielded, back off if it stalled. */ + private yieldTo( + key: string, + tracked: TrackedRepo, + span: RefMaintenanceSpan, + signal: AbortSignal + ): void { + if (hitDeadline(signal)) { + this.settle(key, span, 'timed_out', REF_MAINTENANCE_FAILURE_COOLDOWN_MS) + return + } + span.setAttribute('repo.maintenance_outcome', 'interrupted' satisfies RefMaintenanceOutcome) + this.defer(key, tracked, false) + } + + private settle( + key: string, + span: RefMaintenanceSpan, + outcome: RefMaintenanceOutcome, + cooldownMs: number + ): void { + span.setAttribute('repo.maintenance_outcome', outcome) + // Re-insert so Map order stays newest-last and the eviction below drops the oldest. + this.cooldownUntil.delete(key) + this.cooldownUntil.set(key, this.now() + cooldownMs) + if (this.cooldownUntil.size > MAX_TRACKED_REPOS * 4) { + const oldest = this.cooldownUntil.keys().next() + if (!oldest.done) { + this.cooldownUntil.delete(oldest.value) + } + } + } +} From 0352c239c249a355a26c132b9aeb071b55bc66fb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:20:48 -0700 Subject: [PATCH 38/94] Add Copy Session ID menu item to terminal tabs (#18039) * Add Copy Session ID menu item to terminal tabs Adds a menu item to copy the active pane's agent session ID when available. The item only appears when the session is still live and has reported an ID. * Add Copy Session ID i18n strings and e2e test - Add localized strings for Session ID context menu item - Add e2e test coverage for copying session ID from terminal tabs - Fix dev build permissions when copying private Electron app bundles * Drop the Electron dev-bundle fix from this branch It landed on main as 519af49a58, which restores write permission inside copyPrivateTree itself rather than at the dev runner's call site, so every caller of the private-copy contract is covered and not just this one. That commit also fixes the test that should have caught the crash: the wrapper ran with stdio: 'ignore', so a hard failure presented as a bare timeout. This branch predated that commit and carried a narrower duplicate, mixed into an i18n/e2e commit where it did not belong. * refactor: use dedicated i18n keys for copy session ID toasts Replace auto-generated translation keys with specific, dedicated keys for copy session ID success and error messages. This improves maintainability and makes the strings easier to translate across all supported languages. --- .../tab-bar/SortableTabContextMenu.test.tsx | 59 +++++++ .../tab-bar/SortableTabContextMenu.tsx | 7 + .../TabAgentSessionIdMenuItem.test.tsx | 79 +++++++++ .../tab-bar/TabAgentSessionIdMenuItem.tsx | 48 ++++++ .../tab-bar/tab-agent-session-id.test.ts | 159 ++++++++++++++++++ .../tab-bar/tab-agent-session-id.ts | 32 ++++ .../tab-context-menu-consistency.test.tsx | 1 + src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- tests/e2e/tab-context-menu-session-id.spec.ts | 76 +++++++++ 13 files changed, 481 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx create mode 100644 src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-agent-session-id.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-agent-session-id.ts create mode 100644 tests/e2e/tab-context-menu-session-id.spec.ts diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 76d5d8d145a..e51c599f669 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -55,6 +55,7 @@ vi.mock('lucide-react', () => ({ ArrowRight: () => null, ArrowUp: () => null, Columns2: () => null, + Copy: () => null, ListX: () => null, MessageSquare: () => null, PanelBottomClose: () => null, @@ -71,6 +72,8 @@ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + vi.mock('../../store', () => ({ useAppStore: Object.assign( (selector: (state: Record) => unknown) => selector(storeMock.state), @@ -289,4 +292,60 @@ describe('SortableTabContextMenu', () => { expect(container.textContent).not.toContain('Move Tab to Split') expect(container.textContent).toContain('Split terminal right') }) + + describe('copy session id', () => { + const LEAF = '11111111-1111-4111-8111-111111111111' + + function withLiveAgent(sessionId: string | null): void { + storeMock.state = { + ...storeMock.state, + terminalLayoutsByTabId: { + 'term-1': { root: { type: 'leaf', leafId: LEAF }, activeLeafId: LEAF } + }, + agentStatusByPaneKey: { + [`term-1:${LEAF}`]: { + state: 'done', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + paneKey: `term-1:${LEAF}`, + agentType: 'claude', + stateHistory: [], + ...(sessionId ? { providerSession: { key: 'session_id', id: sessionId } } : {}) + } + }, + paneForegroundAgentByPaneKey: {} + } + } + + it('omits the item for a tab with no agent', () => { + const { container } = renderMenu() + + expect(container.textContent).not.toContain('Copy Session ID') + }) + + it('omits the item until the active agent reports a session id', () => { + withLiveAgent(null) + const { container } = renderMenu() + + expect(container.textContent).not.toContain('Copy Session ID') + }) + + it('copies the active pane session id', async () => { + const writeClipboardText = vi.fn().mockResolvedValue(undefined) + Object.assign(window, { api: { ui: { writeClipboardText } } }) + withLiveAgent('session-abc') + const { container } = renderMenu() + + act(() => getButton(container, 'Copy Session ID').click()) + await vi.waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith('session-abc')) + }) + + it('does not resolve a session id while the menu is closed', () => { + withLiveAgent('session-abc') + const { container } = renderMenu({ open: false }) + + expect(container.textContent).not.toContain('Copy Session ID') + }) + }) }) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 53b31012d39..b298072f9f6 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -11,6 +11,8 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { useAppStore } from '../../store' import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' +import { TabAgentSessionIdMenuItem } from './TabAgentSessionIdMenuItem' +import { resolveTabAgentSessionId } from './tab-agent-session-id' import { TerminalTabSplitMenuSection } from './TerminalTabSplitMenuSection' import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing' @@ -121,6 +123,10 @@ export function SortableTabContextMenu({ onTogglePin }: SortableTabContextMenuProps): React.JSX.Element { const keybindings = useAppStore((state) => state.keybindings) + // The id is a primitive, so unchanged sessions stay referentially stable without a cache. + const agentSessionId = useAppStore((state) => + open ? resolveTabAgentSessionId(state, tab.id) : null + ) const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings) const splitDownShortcut = formatShortcutLabel('terminal.splitDown', keybindings) @@ -188,6 +194,7 @@ export function SortableTabContextMenu({ {translate('auto.components.tab.bar.SortableTabContextMenu.2f697b3c31', 'Change Title')} {renameShortcut ? {renameShortcut} : null} +
{translate('auto.components.tab.bar.SortableTabContextMenu.35e8892fd0', 'Tab Color')} diff --git a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx b/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx new file mode 100644 index 00000000000..da857ec57bf --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx @@ -0,0 +1,79 @@ +/** + * @vitest-environment happy-dom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TabAgentSessionIdMenuItem } from './TabAgentSessionIdMenuItem' + +const toastMock = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenuItem: ({ + children, + disabled, + onSelect, + 'aria-label': ariaLabel + }: { + children?: ReactNode + disabled?: boolean + onSelect?: () => void + 'aria-label'?: string + }) => ( + + ) +})) + +vi.mock('lucide-react', () => ({ Copy: () => null })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', () => ({ toast: toastMock })) + +const mounted: { container: HTMLDivElement; root: Root }[] = [] + +function render(sessionId: string | null): HTMLDivElement { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => root.render()) + mounted.push({ container, root }) + return container +} + +afterEach(() => { + for (const { container, root } of mounted.splice(0)) { + act(() => root.unmount()) + container.remove() + } + toastMock.success.mockReset() + toastMock.error.mockReset() +}) + +describe('TabAgentSessionIdMenuItem', () => { + it('renders nothing when no session id is available', () => { + expect(render(null).textContent).toBe('') + }) + + it('copies on select when an id is known', async () => { + const writeClipboardText = vi.fn().mockResolvedValue(undefined) + Object.assign(window, { api: { ui: { writeClipboardText } } }) + const container = render('abc-123') + + const button = container.querySelector('button') + expect(button?.disabled).toBe(false) + act(() => button?.click()) + await vi.waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith('abc-123')) + }) + + it('reports clipboard failures', async () => { + const writeClipboardText = vi.fn().mockRejectedValue(new Error('clipboard unavailable')) + Object.assign(window, { api: { ui: { writeClipboardText } } }) + const button = render('abc-123').querySelector('button') + + act(() => button?.click()) + await vi.waitFor(() => + expect(toastMock.error).toHaveBeenCalledWith('Failed to copy Session ID') + ) + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx b/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx new file mode 100644 index 00000000000..73f3f128e5d --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx @@ -0,0 +1,48 @@ +import { Copy } from 'lucide-react' +import { toast } from 'sonner' +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { translate } from '@/i18n/i18n' + +async function copySessionId(sessionId: string): Promise { + try { + await window.api.ui.writeClipboardText(sessionId) + toast.success( + translate( + 'components.tab.bar.SortableTabContextMenu.copySessionIdSuccess', + 'Session ID copied' + ) + ) + } catch { + toast.error( + translate( + 'components.tab.bar.SortableTabContextMenu.copySessionIdError', + 'Failed to copy Session ID' + ) + ) + } +} + +/** Copies the active pane's provider session id when one is available. */ +export function TabAgentSessionIdMenuItem({ + sessionId +}: { + sessionId: string | null +}): React.JSX.Element | null { + if (sessionId === null) { + return null + } + const label = translate( + 'components.tab.bar.SortableTabContextMenu.copySessionId', + 'Copy Session ID' + ) + return ( + { + void copySessionId(sessionId) + }} + > + + {label} + + ) +} diff --git a/src/renderer/src/components/tab-bar/tab-agent-session-id.test.ts b/src/renderer/src/components/tab-bar/tab-agent-session-id.test.ts new file mode 100644 index 00000000000..2f06f0a8db7 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-agent-session-id.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { resolveTabAgentSessionId, type TabAgentSessionIdState } from './tab-agent-session-id' + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' + +function entry(overrides: Partial = {}): AgentStatusEntry { + return { + state: 'done', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + paneKey: `tab-1:${LEAF_A}`, + agentType: 'claude', + stateHistory: [], + ...overrides + } +} + +function state(overrides: Partial = {}): TabAgentSessionIdState { + return { + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_A }, + activeLeafId: LEAF_A, + expandedLeafId: null + } + }, + agentStatusByPaneKey: {}, + paneForegroundAgentByPaneKey: {}, + ...overrides + } +} + +describe('resolveTabAgentSessionId', () => { + it('is absent when the pane has no agent row', () => { + expect(resolveTabAgentSessionId(state(), 'tab-1')).toBeNull() + }) + + it('is absent for a tab with no layout', () => { + expect(resolveTabAgentSessionId(state(), 'tab-missing')).toBeNull() + }) + + it('reads the id reported by the active pane', () => { + const resolved = resolveTabAgentSessionId( + state({ + agentStatusByPaneKey: { + [`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'abc-123' } }) + } + }), + 'tab-1' + ) + expect(resolved).toBe('abc-123') + }) + + it('is absent until the agent reports an id', () => { + const resolved = resolveTabAgentSessionId( + state({ agentStatusByPaneKey: { [`tab-1:${LEAF_A}`]: entry() } }), + 'tab-1' + ) + expect(resolved).toBeNull() + }) + + describe('liveness', () => { + it('is absent for a hydrated row with no live hook since restore', () => { + const resolved = resolveTabAgentSessionId( + state({ + agentStatusByPaneKey: { + [`tab-1:${LEAF_A}`]: entry({ + restoredUnconfirmed: true, + providerSession: { key: 'session_id', id: 'abc-123' } + }) + } + }), + 'tab-1' + ) + expect(resolved).toBeNull() + }) + + it('is absent once the pane is proven back at the shell', () => { + const resolved = resolveTabAgentSessionId( + state({ + agentStatusByPaneKey: { + [`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'abc-123' } }) + }, + paneForegroundAgentByPaneKey: { + [`tab-1:${LEAF_A}`]: { agent: null, shellForeground: true } + } + }), + 'tab-1' + ) + expect(resolved).toBeNull() + }) + + it('keeps a session whose foreground evidence is only that an agent runs', () => { + const resolved = resolveTabAgentSessionId( + state({ + agentStatusByPaneKey: { + [`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'abc-123' } }) + }, + paneForegroundAgentByPaneKey: { + [`tab-1:${LEAF_A}`]: { agent: 'claude', shellForeground: false } + } + }), + 'tab-1' + ) + expect(resolved).toBe('abc-123') + }) + + it('keeps a working session that reported a session boundary', () => { + // Why: sessionBoundary marks a resume/clear landing idle — a session start, + // not a session end, and exactly when the first id arrives. + const resolved = resolveTabAgentSessionId( + state({ + agentStatusByPaneKey: { + [`tab-1:${LEAF_A}`]: entry({ + sessionBoundary: true, + providerSession: { key: 'session_id', id: 'fresh-1' } + }) + } + }), + 'tab-1' + ) + expect(resolved).toBe('fresh-1') + }) + }) + + describe('split tabs', () => { + const splitState = (activeLeafId: string): TabAgentSessionIdState => + state({ + terminalLayoutsByTabId: { + 'tab-1': { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B } + }, + activeLeafId, + expandedLeafId: null + } + }, + agentStatusByPaneKey: { + [`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'left' } }), + [`tab-1:${LEAF_B}`]: entry({ providerSession: { key: 'session_id', id: 'right' } }) + } + }) + + it('reads the active pane, not a sibling', () => { + expect(resolveTabAgentSessionId(splitState(LEAF_B), 'tab-1')).toBe('right') + }) + + it('is absent when the active leaf id no longer exists in the layout', () => { + const stale = '33333333-3333-4333-8333-333333333333' + expect(resolveTabAgentSessionId(splitState(stale), 'tab-1')).toBeNull() + }) + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-agent-session-id.ts b/src/renderer/src/components/tab-bar/tab-agent-session-id.ts new file mode 100644 index 00000000000..e0d804bc4c5 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-agent-session-id.ts @@ -0,0 +1,32 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' +import { resolveNativeChatActiveLayoutLeafId } from '../native-chat/native-chat-leaf-routing' + +export type TabAgentSessionIdState = { + agentStatusByPaneKey?: Record + terminalLayoutsByTabId?: Record + paneForegroundAgentByPaneKey?: Record +} + +/** Returns the active pane's provider session id when its agent is still live. */ +export function resolveTabAgentSessionId( + state: TabAgentSessionIdState, + tabId: string +): string | null { + const leafId = resolveNativeChatActiveLayoutLeafId(state.terminalLayoutsByTabId?.[tabId]) + if (!leafId) { + return null + } + const paneKey = `${tabId}:${leafId}` + const entry = state.agentStatusByPaneKey?.[paneKey] + // Hydrated rows may describe a session that ended while no receiver was up. + if (!entry?.agentType || entry.restoredUnconfirmed === true) { + return null + } + // OSC 133;D proves the pane is back at the shell, regardless of the last hook state. + if (state.paneForegroundAgentByPaneKey?.[paneKey]?.shellForeground === true) { + return null + } + return entry.providerSession?.id ?? null +} diff --git a/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx b/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx index c81eae348dd..54f762d4611 100644 --- a/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx @@ -13,6 +13,7 @@ import { const TAB_MENU_SOURCES = [ 'EditorFileTabContextMenu.tsx', 'SortableTabContextMenu.tsx', + 'TabAgentSessionIdMenuItem.tsx', 'BrowserTab.tsx', 'TabWorkspaceLayoutMenuSection.tsx', 'TerminalTabSplitMenuSection.tsx' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7fc9c837ebc..22b1695cc3d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16724,7 +16724,10 @@ "SortableTabContextMenu": { "switchToTerminalView": "Switch to terminal view", "switchToChatView": "Switch to chat view", - "closeTabsToLeft": "Close Tabs To The Left" + "closeTabsToLeft": "Close Tabs To The Left", + "copySessionId": "Copy Session ID", + "copySessionIdSuccess": "Session ID copied", + "copySessionIdError": "Failed to copy Session ID" }, "BrowserTab": { "closeOthers": "Close Others", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 1c8a5c7325b..be6e087060d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14646,7 +14646,10 @@ "SortableTabContextMenu": { "switchToTerminalView": "Cambiar a la vista de terminal", "switchToChatView": "Cambiar a vista de chat", - "closeTabsToLeft": "Cerrar pestañas a la izquierda" + "closeTabsToLeft": "Cerrar pestañas a la izquierda", + "copySessionId": "Copiar ID de sesión", + "copySessionIdSuccess": "ID de sesión copiado", + "copySessionIdError": "No se pudo copiar el ID de sesión" }, "BrowserTab": { "closeOthers": "Cerrar otras", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 334e9e32d27..20e2fca6553 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14646,7 +14646,10 @@ "SortableTabContextMenu": { "switchToTerminalView": "ターミナルビューに切り替える", "switchToChatView": "チャットビューに切り替える", - "closeTabsToLeft": "左側のタブを閉じる" + "closeTabsToLeft": "左側のタブを閉じる", + "copySessionId": "セッション ID をコピー", + "copySessionIdSuccess": "セッション ID をコピーしました", + "copySessionIdError": "セッション ID のコピーに失敗しました" }, "BrowserTab": { "closeOthers": "その他を閉じる", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7ef6c53ca86..e9b9c48fcdf 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14689,7 +14689,10 @@ "SortableTabContextMenu": { "switchToTerminalView": "terminal 보기로 전환", "switchToChatView": "채팅 보기로 전환", - "closeTabsToLeft": "왼쪽으로 탭 닫기" + "closeTabsToLeft": "왼쪽으로 탭 닫기", + "copySessionId": "세션 ID 복사", + "copySessionIdSuccess": "세션 ID를 복사했습니다", + "copySessionIdError": "세션 ID를 복사하지 못했습니다" }, "BrowserTab": { "closeOthers": "다른 탭 닫기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 06fae6cca89..49560989924 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14689,7 +14689,10 @@ "SortableTabContextMenu": { "switchToTerminalView": "切换到终端视图", "switchToChatView": "切换到聊天视图", - "closeTabsToLeft": "关闭左侧的选项卡" + "closeTabsToLeft": "关闭左侧的选项卡", + "copySessionId": "复制会话 ID", + "copySessionIdSuccess": "已复制会话 ID", + "copySessionIdError": "复制会话 ID 失败" }, "BrowserTab": { "closeOthers": "关闭其他", diff --git a/tests/e2e/tab-context-menu-session-id.spec.ts b/tests/e2e/tab-context-menu-session-id.spec.ts new file mode 100644 index 00000000000..cab18bace32 --- /dev/null +++ b/tests/e2e/tab-context-menu-session-id.spec.ts @@ -0,0 +1,76 @@ +/** + * E2E coverage for copying an agent provider session ID from a terminal tab's + * context menu. + */ + +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { waitForPaneIdentitySnapshot } from './helpers/terminal' + +const SESSION_ID = 'e2e-terminal-tab-session' + +test('terminal tab context menu copies the active agent session ID', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const worktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + + const tabId = await getActiveTabId(orcaPage) + if (!tabId) { + throw new Error('No active terminal tab') + } + const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + const leafId = snapshot.panes[0]?.leafId + if (!leafId) { + throw new Error('No active terminal pane') + } + const paneKey = `${tabId}:${leafId}` + + // Seed the same renderer state a live agent hook produces while keeping the + // test independent of an installed provider CLI. + await orcaPage.evaluate( + ({ paneKey, tabId, worktreeId, sessionId }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Store unavailable') + } + state.setAgentStatus( + paneKey, + { state: 'working', prompt: 'copy session id', agentType: 'claude' }, + 'Claude', + undefined, + { tabId, worktreeId }, + { providerSession: { key: 'session_id', id: sessionId } } + ) + }, + { paneKey, tabId, worktreeId, sessionId: SESSION_ID } + ) + + await expect + .poll( + () => + orcaPage.evaluate( + ({ paneKey }) => + window.__store?.getState().agentStatusByPaneKey[paneKey]?.providerSession?.id, + { paneKey } + ), + { timeout: 3_000 } + ) + .toBe(SESSION_ID) + + const tab = orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`) + await expect(tab).toBeVisible() + await tab.click({ button: 'right' }) + + const copyItem = orcaPage.getByRole('menuitem', { name: 'Copy Session ID', exact: true }) + await expect(copyItem).toBeVisible() + await copyItem.click() + + await expect + .poll(() => orcaPage.evaluate(() => window.api.ui.readClipboardText()), { timeout: 3_000 }) + .toBe(SESSION_ID) +}) From a7fda48fe3faa55b6248cd570165095be042e769 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:33:39 -0400 Subject: [PATCH 39/94] feat(telemetry): measure macOS stale-daemon adoption and cwd denials (#18043) * feat(telemetry): measure macOS stale-daemon adoption and cwd denials Adds two enum-only PostHog events so #17696 can be sized instead of guessed at: - daemon_adopted: once per macOS launch that keeps a daemon an earlier app launch forked (invisible to daemon_lifecycle, which only sees replacements). Carries app-version match, spawner-path class (installed app / Squirrel ShipIt cache / other / missing), the existing TCC attribution verdict, and the bucketed live-session count. - daemon_pty_cwd_denied: the symptom itself. The daemon probes the requested cwd in its own process (only its TCC context counts) and returns an additive cwdReadableByDaemon field; the app emits only when the daemon was denied AND the app can read the same path, so a missing or genuinely unreadable cwd never counts. Non-permission errors read as readable on purpose. Both emitters swallow every failure; nothing here can delay or fail daemon startup or a PTY spawn. Off macOS neither event fires. The new wire field is optional, so older daemons and clients are unaffected. * fix(telemetry): keep cwd-denial classification inside the swallow guard Read the pid record at emit time (inside the try) rather than passing the adapter's startup snapshot: a throwing app-environment read can no longer escape spawn(), and a denial after a respawn is billed to the daemon that actually spawned the PTY. --- .../daemon-adoption-telemetry-event.test.ts | 168 ++++++++++++++++++ .../daemon/daemon-adoption-telemetry-event.ts | 81 +++++++++ .../daemon/daemon-create-or-attach-result.ts | 6 + .../daemon/daemon-init-dependency-mocks.ts | 9 +- src/main/daemon/daemon-init-fresh-import.ts | 4 +- src/main/daemon/daemon-init-mock-types.ts | 3 + .../daemon-init-provider-installation.test.ts | 45 +++++ src/main/daemon/daemon-init-test-harness.ts | 4 +- .../daemon/daemon-out-of-process-launcher.ts | 1 + src/main/daemon/daemon-provider-init.ts | 31 ++++ src/main/daemon/daemon-pty-session-spawn.ts | 4 + src/main/daemon/daemon-spawner.ts | 2 + src/main/daemon/daemon-terminal-admission.ts | 5 +- .../daemon/terminal-host-create-contract.ts | 2 + .../terminal-host-cwd-readability.test.ts | 75 ++++++++ .../daemon/terminal-host-session-create.ts | 17 ++ src/main/ipc/telemetry.ts | 2 + src/shared/daemon-adoption-telemetry.test.ts | 89 ++++++++++ src/shared/daemon-adoption-telemetry.ts | 67 +++++++ src/shared/telemetry-daemon-event-schemas.ts | 27 +++ src/shared/telemetry-event-registry.ts | 4 + 21 files changed, 642 insertions(+), 4 deletions(-) create mode 100644 src/main/daemon/daemon-adoption-telemetry-event.test.ts create mode 100644 src/main/daemon/daemon-adoption-telemetry-event.ts create mode 100644 src/main/daemon/terminal-host-cwd-readability.test.ts create mode 100644 src/shared/daemon-adoption-telemetry.test.ts create mode 100644 src/shared/daemon-adoption-telemetry.ts diff --git a/src/main/daemon/daemon-adoption-telemetry-event.test.ts b/src/main/daemon/daemon-adoption-telemetry-event.test.ts new file mode 100644 index 00000000000..5a5cd7406c4 --- /dev/null +++ b/src/main/daemon/daemon-adoption-telemetry-event.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedDaemonPid } from './daemon-pid-file-parse' +import { validate } from '../telemetry/validator' + +const { trackMock, accessSyncMock, existsSyncMock, readFileSyncMock, getVersionMock } = vi.hoisted( + () => ({ + trackMock: vi.fn(), + accessSyncMock: vi.fn(), + existsSyncMock: vi.fn(() => true), + readFileSyncMock: vi.fn(), + getVersionMock: vi.fn(() => '1.4.191') + }) +) +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal>()), + accessSync: accessSyncMock, + existsSync: existsSyncMock, + readFileSync: readFileSyncMock +})) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal>()), + homedir: () => '/Users/alice' +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getVersion: getVersionMock }) +})) + +import { + classifyDaemonAdoptionOrigin, + trackDaemonAdopted, + trackDaemonPtyCwdDeniedIfDiverged +} from './daemon-adoption-telemetry-event' + +const stalePidRecord: ParsedDaemonPid = { + pid: 1530, + startedAtMs: 1, + entryPath: '/x/daemon-entry.js', + appVersion: '1.4.187', + launchNonce: 'n', + linuxStartTicks: null, + bootId: null, + spawnerExecPath: + '/Users/alice/Library/Caches/com.stablyai.orca.ShipIt/u/Orca.app/Contents/MacOS/Orca' +} +const origin = { app_version_match: 'different', spawner_path_class: 'updater-cache' } as const +const PID_PATH = '/fake/daemon.pid' + +beforeEach(() => { + trackMock.mockReset() + accessSyncMock.mockReset() + existsSyncMock.mockReset().mockReturnValue(true) + readFileSyncMock.mockReset().mockReturnValue(JSON.stringify(stalePidRecord)) + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('classifyDaemonAdoptionOrigin', () => { + it('compares the recorded app version and classifies the spawner path', () => { + expect(classifyDaemonAdoptionOrigin(stalePidRecord)).toEqual(origin) + expect(classifyDaemonAdoptionOrigin({ ...stalePidRecord, appVersion: '1.4.191' })).toEqual({ + app_version_match: 'same', + spawner_path_class: 'updater-cache' + }) + expect(classifyDaemonAdoptionOrigin(null)).toEqual({ + app_version_match: 'unknown', + spawner_path_class: 'unknown' + }) + }) +}) + +describe('trackDaemonAdopted', () => { + it('emits a validator-accepted payload', () => { + trackDaemonAdopted(stalePidRecord, 'intact', 7) + expect(trackMock).toHaveBeenCalledTimes(1) + const [name, props] = trackMock.mock.calls[0] + expect(name).toBe('daemon_adopted') + expect(props).toEqual({ + ...origin, + tcc_attribution: 'intact', + live_session_count_bucket: '6+' + }) + expect(validate('daemon_adopted', props).ok).toBe(true) + }) + + it('swallows a throwing telemetry client', () => { + trackMock.mockImplementationOnce(() => { + throw new Error('posthog exploded') + }) + expect(() => trackDaemonAdopted(null, 'unknown', null)).not.toThrow() + }) +}) + +describe('trackDaemonPtyCwdDeniedIfDiverged', () => { + it('emits only when the daemon was denied and the app can read the same cwd', () => { + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + expect(accessSyncMock).toHaveBeenCalledWith('/Users/alice/Documents/repo', expect.any(Number)) + expect(trackMock).toHaveBeenCalledTimes(1) + const [name, props] = trackMock.mock.calls[0] + expect(name).toBe('daemon_pty_cwd_denied') + expect(props).toEqual({ cwd_class: 'documents', ...origin }) + expect(validate('daemon_pty_cwd_denied', props).ok).toBe(true) + }) + + // False positives would drown the signal this event exists to measure, so every + // non-divergent shape must stay silent. + it('stays silent when the daemon could read the cwd or did not report', () => { + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', true, PID_PATH) + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', undefined, PID_PATH) + trackDaemonPtyCwdDeniedIfDiverged(undefined, false, PID_PATH) + expect(accessSyncMock).not.toHaveBeenCalled() + expect(trackMock).not.toHaveBeenCalled() + }) + + it('stays silent when the app cannot read the cwd either (no divergence)', () => { + accessSyncMock.mockImplementation(() => { + throw Object.assign(new Error('EACCES'), { code: 'EACCES' }) + }) + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + expect(trackMock).not.toHaveBeenCalled() + }) + + it('attributes the denial to the daemon recorded right now, not a startup snapshot', () => { + readFileSyncMock.mockReturnValue( + JSON.stringify({ + ...stalePidRecord, + appVersion: '1.4.191', + spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca' + }) + ) + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + expect(readFileSyncMock).toHaveBeenCalledWith(PID_PATH, 'utf8') + expect(trackMock.mock.calls[0][1]).toEqual({ + cwd_class: 'documents', + app_version_match: 'same', + spawner_path_class: 'applications' + }) + }) + + it('swallows a throwing app environment or pid-record read instead of failing the spawn', () => { + getVersionMock.mockImplementationOnce(() => { + throw new Error('AppEnvironment not initialized') + }) + expect(() => + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + ).not.toThrow() + expect(trackMock).not.toHaveBeenCalled() + }) + + it('stays silent off macOS', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + trackDaemonPtyCwdDeniedIfDiverged('/home/alice/Documents/repo', false, PID_PATH) + expect(accessSyncMock).not.toHaveBeenCalled() + expect(trackMock).not.toHaveBeenCalled() + }) + + it('swallows a throwing telemetry client', () => { + trackMock.mockImplementationOnce(() => { + throw new Error('posthog exploded') + }) + expect(() => + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + ).not.toThrow() + }) +}) diff --git a/src/main/daemon/daemon-adoption-telemetry-event.ts b/src/main/daemon/daemon-adoption-telemetry-event.ts new file mode 100644 index 00000000000..47f554bf9bb --- /dev/null +++ b/src/main/daemon/daemon-adoption-telemetry-event.ts @@ -0,0 +1,81 @@ +// App-side emitters for `daemon_adopted` and `daemon_pty_cwd_denied` (#17696). Both sit on the +// daemon launch / PTY spawn path, so every failure dies here — telemetry can never cost a terminal. + +import { accessSync, constants as fsConstants, existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { getAppEnvironment } from '../../shared/app-environment' +import { + classifyDaemonPtyCwd, + classifyDaemonSpawnerPath, + type DaemonAdoptedAppVersionMatch, + type DaemonSpawnerPathClass +} from '../../shared/daemon-adoption-telemetry' +import { bucketDaemonLiveSessionCount } from '../../shared/daemon-lifecycle-telemetry' +import type { EventProps } from '../../shared/telemetry-events' +import { track } from '../telemetry/client' +import { readDaemonPidRecord } from './daemon-endpoint-incarnation' +import type { ParsedDaemonPid } from './daemon-pid-file-parse' +import type { MacDaemonTccAttributionHealth } from './daemon-tcc-attribution' + +export type DaemonAdoptionOrigin = Pick< + EventProps<'daemon_pty_cwd_denied'>, + 'app_version_match' | 'spawner_path_class' +> + +/** Classifies the adopted daemon's pid record against the running app; enum-only by construction. */ +export function classifyDaemonAdoptionOrigin( + pidRecord: ParsedDaemonPid | null +): DaemonAdoptionOrigin { + const appVersionMatch: DaemonAdoptedAppVersionMatch = !pidRecord?.appVersion + ? 'unknown' + : pidRecord.appVersion === getAppEnvironment().getVersion() + ? 'same' + : 'different' + const spawnerPathClass: DaemonSpawnerPathClass = classifyDaemonSpawnerPath( + pidRecord?.spawnerExecPath ?? null, + existsSync + ) + return { app_version_match: appVersionMatch, spawner_path_class: spawnerPathClass } +} + +// Adopted a daemon that a previous app launch forked (macOS only; that is where attribution matters). +export function trackDaemonAdopted( + pidRecord: ParsedDaemonPid | null, + tccAttribution: MacDaemonTccAttributionHealth, + liveSessionCount: number | null +): void { + try { + track('daemon_adopted', { + ...classifyDaemonAdoptionOrigin(pidRecord), + tcc_attribution: tccAttribution, + live_session_count_bucket: bucketDaemonLiveSessionCount(liveSessionCount) + }) + } catch { + // Telemetry is best-effort; a dropped event must not fail daemon adoption. + } +} + +/** + * Emits only on proven divergence: the daemon reported the cwd unreadable AND this process can + * read it. A cwd neither can read (chmod, ENOENT, unmounted volume) is not the #17696 shape. + */ +export function trackDaemonPtyCwdDeniedIfDiverged( + cwd: string | undefined, + cwdReadableByDaemon: boolean | undefined, + pidPath: string | null +): void { + try { + if (process.platform !== 'darwin' || !cwd || cwdReadableByDaemon !== false) { + return + } + accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK) + // Why read now, not the adapter's startup snapshot: a respawn swaps the daemon under a + // long-lived adapter, and the denial must be attributed to the daemon that just spawned. + track('daemon_pty_cwd_denied', { + cwd_class: classifyDaemonPtyCwd(cwd, homedir()), + ...classifyDaemonAdoptionOrigin(readDaemonPidRecord(pidPath)) + }) + } catch { + // Either the app cannot read it (no divergence) or telemetry failed; neither may reach the caller. + } +} diff --git a/src/main/daemon/daemon-create-or-attach-result.ts b/src/main/daemon/daemon-create-or-attach-result.ts index d6668342487..92a7e451a10 100644 --- a/src/main/daemon/daemon-create-or-attach-result.ts +++ b/src/main/daemon/daemon-create-or-attach-result.ts @@ -14,6 +14,12 @@ export type DaemonCreateOrAttachResult = { wslDistro?: string | null agentSessionEnsure?: AgentSessionClaimedSpawnResult incarnationId?: PtyIncarnationId + /** + * Whether the daemon process itself could read the requested cwd at spawn. Only the daemon's own + * verdict counts: macOS TCC scopes folder access per process tree, so the app's view of the same + * path proves nothing about the daemon's (#17696). Omitted by daemons predating this field. + */ + cwdReadableByDaemon?: boolean } export function getDaemonSessionResultMetadata(session: { diff --git a/src/main/daemon/daemon-init-dependency-mocks.ts b/src/main/daemon/daemon-init-dependency-mocks.ts index d920a13866e..d7217d4df63 100644 --- a/src/main/daemon/daemon-init-dependency-mocks.ts +++ b/src/main/daemon/daemon-init-dependency-mocks.ts @@ -50,7 +50,8 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { unbindLocalProviderListenersMock, rebindLocalProviderListenersMock, trackDaemonReplacedMock, - trackDaemonRetiredMock + trackDaemonRetiredMock, + trackDaemonAdoptedMock } = state // Why: both fakes are annotated with constructor types so the exported factories widen to @@ -82,6 +83,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { if (result.mode) { this.handle.mode = result.mode } + if (result.adopted) { + this.handle.adopted = true + } return { socketPath: result.socketPath, tokenPath: result.tokenPath @@ -199,6 +203,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { trackDaemonReplaced: trackDaemonReplacedMock, trackDaemonRetired: trackDaemonRetiredMock }), + daemonAdoptionTelemetryEvent: () => ({ + trackDaemonAdopted: trackDaemonAdoptedMock + }), daemonSpawner: () => ({ DaemonSpawner: MockDaemonSpawner, getDaemonSocketPath: (_dir: string, version?: number) => diff --git a/src/main/daemon/daemon-init-fresh-import.ts b/src/main/daemon/daemon-init-fresh-import.ts index e1cc0416337..39f3625c063 100644 --- a/src/main/daemon/daemon-init-fresh-import.ts +++ b/src/main/daemon/daemon-init-fresh-import.ts @@ -41,7 +41,8 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) { unbindLocalProviderListenersMock, rebindLocalProviderListenersMock, trackDaemonReplacedMock, - trackDaemonRetiredMock + trackDaemonRetiredMock, + trackDaemonAdoptedMock } = state vi.resetModules() @@ -64,6 +65,7 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) { rebindLocalProviderListenersMock.mockClear() trackDaemonReplacedMock.mockClear() trackDaemonRetiredMock.mockClear() + trackDaemonAdoptedMock.mockClear() checkDaemonHealthMock.mockClear() checkDaemonHealthMock.mockResolvedValue('healthy') healthCheckDaemonMock.mockClear() diff --git a/src/main/daemon/daemon-init-mock-types.ts b/src/main/daemon/daemon-init-mock-types.ts index 8c8b805740f..341fcd26df7 100644 --- a/src/main/daemon/daemon-init-mock-types.ts +++ b/src/main/daemon/daemon-init-mock-types.ts @@ -47,6 +47,7 @@ export type MockAdapterConstructor = new (opts: MockAdapter['options']) => MockA /** Handle the fake spawner hands back from ensureRunning/getHandle. */ export type MockSpawnerHandle = { mode?: 'degraded-new-pty-fallback' + adopted?: true releaseAdoptionLease?: () => void shutdown: () => Promise } @@ -95,6 +96,7 @@ export type EnsureRunningOverride = () => Promise<{ socketPath: string tokenPath: string mode?: 'degraded-new-pty-fallback' + adopted?: true }> /** Every stub daemon-init's suites share, plus the control knobs they mutate per test. */ @@ -143,6 +145,7 @@ export type DaemonInitMockState = { rebindLocalProviderListenersMock: Mock<(...args: unknown[]) => void> trackDaemonReplacedMock: Mock<(...args: unknown[]) => void> trackDaemonRetiredMock: Mock<(...args: unknown[]) => void> + trackDaemonAdoptedMock: Mock<(...args: unknown[]) => void> } /** net.connect stubs the suites install in beforeEach. */ diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index ceb7e9dfa69..423ee9ee34f 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { isPackagedMock, + getMacDaemonTccAttributionHealthMock, + trackDaemonAdoptedMock, probeSocketExistsMock, readFileSyncMock, unlinkSyncMock, @@ -42,6 +44,7 @@ vi.mock('./daemon-process-start-time', () => moduleFactories.daemonProcessStartT vi.mock('./daemon-pid-file-parse', () => moduleFactories.daemonPidFileParse()) vi.mock('./client', () => moduleFactories.client()) vi.mock('./daemon-lifecycle-event', () => moduleFactories.daemonLifecycleEvent()) +vi.mock('./daemon-adoption-telemetry-event', () => moduleFactories.daemonAdoptionTelemetryEvent()) vi.mock('./daemon-spawner', () => moduleFactories.daemonSpawner()) vi.mock('./daemon-pty-adapter', () => moduleFactories.daemonPtyAdapter()) vi.mock('../ipc/pty', () => moduleFactories.ipcPty()) @@ -228,6 +231,48 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce() }) + // #17696: adopting a daemon from an earlier app launch is invisible to daemon_lifecycle, so + // it gets its own event — macOS only, and only for adopted (not freshly forked) daemons. + it('reports a macOS daemon adoption with its TCC attribution and live session bucket', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const mod = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/adopted-socket', + tokenPath: '/fake/adopted-token', + adopted: true + })) + getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed') + defaultListSessionsSessions.push({ sessionId: 'wt-1@@a' }, { sessionId: 'wt-1@@b' }) + + await mod.initDaemonPtyProvider() + await vi.waitFor(() => expect(trackDaemonAdoptedMock).toHaveBeenCalledOnce()) + + // null pid record: the harness has no pid file, which the emitter classifies as 'unknown'. + expect(trackDaemonAdoptedMock).toHaveBeenCalledWith(null, 'severed', 2) + vi.restoreAllMocks() + }) + + it('does not report adoption for a freshly forked daemon or off macOS', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const mod = await importFresh() + await mod.initDaemonPtyProvider() + await new Promise((resolve) => setImmediate(resolve)) + expect(trackDaemonAdoptedMock).not.toHaveBeenCalled() + vi.restoreAllMocks() + + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + const linuxMod = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/adopted-socket', + tokenPath: '/fake/adopted-token', + adopted: true + })) + await linuxMod.initDaemonPtyProvider() + await new Promise((resolve) => setImmediate(resolve)) + expect(trackDaemonAdoptedMock).not.toHaveBeenCalled() + vi.restoreAllMocks() + }) + it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ diff --git a/src/main/daemon/daemon-init-test-harness.ts b/src/main/daemon/daemon-init-test-harness.ts index 6c38720a40b..6060ed9b759 100644 --- a/src/main/daemon/daemon-init-test-harness.ts +++ b/src/main/daemon/daemon-init-test-harness.ts @@ -156,6 +156,7 @@ function createDaemonInitMockState(): DaemonInitMockState { const rebindLocalProviderListenersMock = vi.fn() const trackDaemonReplacedMock = vi.fn() const trackDaemonRetiredMock = vi.fn() + const trackDaemonAdoptedMock = vi.fn() return { getPathMock, @@ -197,7 +198,8 @@ function createDaemonInitMockState(): DaemonInitMockState { unbindLocalProviderListenersMock, rebindLocalProviderListenersMock, trackDaemonReplacedMock, - trackDaemonRetiredMock + trackDaemonRetiredMock, + trackDaemonAdoptedMock } } diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index b59535a249a..21ff31918ac 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -41,6 +41,7 @@ function createPreservedDaemonHandle( mode?: 'degraded-new-pty-fallback' ): DaemonProcessHandle { const handle: DaemonProcessHandle = { + adopted: true, shutdown: async () => { await cleanupDaemonForProtocol(runtimeDir, protocolVersion) } diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index 1881e276e97..fa794257bda 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -24,7 +24,10 @@ import { import type { DaemonProvider } from './daemon-provider-routing' import { installDaemonProvider } from './daemon-provider-state' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' +import { trackDaemonAdopted } from './daemon-adoption-telemetry-event' +import { readDaemonPidRecord } from './daemon-endpoint-incarnation' import { trackDaemonRetired } from './daemon-lifecycle-event' +import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution' import { DaemonPtyAdapter } from './daemon-pty-adapter' import type { DaemonRespawnReason } from './daemon-pty-runtime-state' import { DaemonPtyRouter } from './daemon-pty-router' @@ -156,9 +159,37 @@ export async function initDaemonPtyProvider( logDaemonMilestone('daemon-init-done', { legacyAdapters: legacyAdapters.length }) + if (process.platform === 'darwin' && newSpawner.getHandle()?.adopted) { + void reportDaemonAdoption(runtimeDir, info.socketPath, info.tokenPath, newAdapter) + } await reconcileSeededClaudeLivePtys(routedAdapter) } +// Why off the init path: this is measurement of an adopted daemon (#17696), and neither its probes nor their failure may delay or fail startup. +async function reportDaemonAdoption( + runtimeDir: string, + socketPath: string, + tokenPath: string, + adapter: DaemonPtyAdapter +): Promise { + try { + const [tccAttribution, liveSessionCount] = await Promise.all([ + getMacDaemonTccAttributionHealth(runtimeDir, socketPath, tokenPath), + adapter.listSessions().then( + (sessions) => sessions.length, + () => null + ) + ]) + trackDaemonAdopted( + readDaemonPidRecord(getDaemonPidPath(runtimeDir)), + tccAttribution, + liveSessionCount + ) + } catch { + // Best-effort measurement only. + } +} + // Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token. async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise { if (!hasSeededUnconfirmedClaudePtys()) { diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index 62c073f403e..235b286b0bc 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -4,6 +4,7 @@ import type { HistoryRecoveryContext, PendingDaemonSpawnOperation } from './daemon-pty-runtime-state' +import { trackDaemonPtyCwdDeniedIfDiverged } from './daemon-adoption-telemetry-event' import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' import { TerminalKilledError } from './daemon-pty-lifecycle-errors' import { DaemonPtySpawnResult } from './daemon-pty-spawn-result' @@ -246,6 +247,9 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { } activeSpawnContext = context const result = await this.createOrAttachSpawn(context, context.historySeedSegments) + if (result.isNew && !attachOnly) { + trackDaemonPtyCwdDeniedIfDiverged(effectiveCwd, result.cwdReadableByDaemon, this.pidPath) + } return this.finishSpawn(context, result) } diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index a0376ef0fc0..8c50b764b05 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -31,6 +31,8 @@ export type DaemonPidFile = { export type DaemonProcessHandle = { mode?: 'degraded-new-pty-fallback' + /** Set when the launcher kept a daemon some earlier app launch forked, rather than forking one. */ + adopted?: true releaseAdoptionLease?(): void shutdown(): Promise } diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index b47b497fe43..83dadf5f5d2 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -161,7 +161,10 @@ export class DaemonTerminalAdmission { ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), wslDistro: result.wslDistro, ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), + ...(result.cwdReadableByDaemon !== undefined + ? { cwdReadableByDaemon: result.cwdReadableByDaemon } + : {}) } } diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index aaaffaeb8e8..42f5bf457f4 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -54,4 +54,6 @@ export type CreateOrAttachResult = { attachToken: symbol incarnationId: PtyIncarnationId agentSessionEnsure?: AgentSessionClaimedSpawnResult + /** Daemon-process verdict on the spawn cwd; only set on a fresh spawn that was given a cwd. */ + cwdReadableByDaemon?: boolean } diff --git a/src/main/daemon/terminal-host-cwd-readability.test.ts b/src/main/daemon/terminal-host-cwd-readability.test.ts new file mode 100644 index 00000000000..aa9e08379d7 --- /dev/null +++ b/src/main/daemon/terminal-host-cwd-readability.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessHandle } from './session-subprocess-handle' +import { TerminalHost, type TerminalHostOptions } from './terminal-host' + +vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() })) + +function createMockSubprocess(): SubprocessHandle { + let onExitCb: ((code: number) => void) | null = null + return { + pid: 99999, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => { + setTimeout(() => onExitCb?.(0), 5) + }), + terminateOwnedTree: () => 'unavailable' as const, + forceKill: vi.fn(() => onExitCb?.(137)), + signal: vi.fn(), + onData() {}, + onExit(cb) { + onExitCb = cb + }, + dispose: vi.fn() + } +} + +// #17696: only the daemon process can say whether TCC lets it read the cwd, so its verdict +// rides on the create result. A non-permission failure must never read as denial. +describe('TerminalHost cwd readability verdict', () => { + let host: TerminalHost + let platformDescriptor: PropertyDescriptor | undefined + + beforeEach(() => { + platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const spawnSubprocess: TerminalHostOptions['spawnSubprocess'] = () => createMockSubprocess() + host = new TerminalHost({ spawnSubprocess }) + }) + + afterEach(async () => { + await host.dispose() + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor) + } + }) + + const create = (sessionId: string, cwd?: string) => + host.createOrAttach({ + sessionId, + cols: 80, + rows: 24, + ...(cwd ? { cwd } : {}), + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + it('reports a readable cwd as readable', async () => { + expect((await create('readable', process.cwd())).cwdReadableByDaemon).toBe(true) + }) + + it('reports a missing cwd as readable — absence is not a permission denial', async () => { + expect((await create('missing', '/definitely/not/a/real/dir')).cwdReadableByDaemon).toBe(true) + }) + + it('omits the verdict when no cwd was requested', async () => { + expect((await create('no-cwd')).cwdReadableByDaemon).toBeUndefined() + }) + + it('omits the verdict on attach to an existing session', async () => { + await create('attach', process.cwd()) + const attached = await create('attach', process.cwd()) + expect(attached.isNew).toBe(false) + expect(attached.cwdReadableByDaemon).toBeUndefined() + }) +}) diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index e1c8a22e750..9ee51c9968d 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -1,3 +1,4 @@ +import { accessSync, constants as fsConstants } from 'node:fs' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' @@ -88,6 +89,8 @@ async function spawnAndPublishSession( ctx: { size: { cols: number; rows: number }; wslDistro: string | undefined } ): Promise { const { size, wslDistro } = ctx + // Why before the fork: the shell's own cwd may already have fallen back, so probe the requested path. + const cwdReadableByDaemon = opts.cwd && !wslDistro ? isCwdReadableByThisProcess(opts.cwd) : null const subprocess = await deps.spawnSubprocess({ sessionId: opts.sessionId, cols: size.cols, @@ -184,6 +187,20 @@ async function spawnAndPublishSession( shellState: session.shellState, incarnationId: session.incarnationId, ...getDaemonSessionResultMetadata(session), + ...(cwdReadableByDaemon !== null ? { cwdReadableByDaemon } : {}), attachToken: token } } + +// Why R_OK|X_OK: listing a directory needs read, and entering it needs search — both are what +// TCC withholds. A non-permission failure (ENOENT, ENOTDIR) reads as readable so it can never +// masquerade as a permission denial. +function isCwdReadableByThisProcess(cwd: string): boolean { + try { + accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + return code !== 'EACCES' && code !== 'EPERM' + } +} diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index c1fe7d0f68d..5e649f51b84 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -24,7 +24,9 @@ let storeRef: Store | null = null const MAIN_OWNED_TELEMETRY_EVENTS = new Set([ 'app_starred_orca', + 'daemon_adopted', 'daemon_audit_eligibility', + 'daemon_pty_cwd_denied', 'star_nag_outcome', 'feature_interaction_usage_bucket_reached' ]) diff --git a/src/shared/daemon-adoption-telemetry.test.ts b/src/shared/daemon-adoption-telemetry.test.ts new file mode 100644 index 00000000000..f02aa431506 --- /dev/null +++ b/src/shared/daemon-adoption-telemetry.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { classifyDaemonPtyCwd, classifyDaemonSpawnerPath } from './daemon-adoption-telemetry' +import { eventSchemas } from './telemetry-event-registry' + +describe('classifyDaemonSpawnerPath', () => { + const alwaysExists = () => true + + it('classifies the installed app, the ShipIt staging area, and everything else', () => { + expect( + classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists) + ).toBe('applications') + expect( + classifyDaemonSpawnerPath('/private/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists) + ).toBe('applications') + expect( + classifyDaemonSpawnerPath( + '/Users/a/Library/Caches/com.stablyai.orca.ShipIt/update.abc/Orca.app/Contents/MacOS/Orca', + alwaysExists + ) + ).toBe('updater-cache') + expect( + classifyDaemonSpawnerPath('/Users/a/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists) + ).toBe('other') + expect(classifyDaemonSpawnerPath('/tmp/OrcaA.app/Contents/MacOS/Orca', alwaysExists)).toBe( + 'other' + ) + }) + + it('reports a deleted spawner as missing and an unrecorded one as unknown', () => { + expect( + classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', () => false) + ).toBe('missing') + expect(classifyDaemonSpawnerPath(null, alwaysExists)).toBe('unknown') + }) +}) + +describe('classifyDaemonPtyCwd', () => { + it('maps the TCC-protected home folders and separates the rest of home from outside it', () => { + expect(classifyDaemonPtyCwd('/Users/a/Documents/repo', '/Users/a')).toBe('documents') + expect(classifyDaemonPtyCwd('/Users/a/Desktop', '/Users/a/')).toBe('desktop') + expect(classifyDaemonPtyCwd('/Users/a/Downloads/x/y', '/Users/a')).toBe('downloads') + expect(classifyDaemonPtyCwd('/Users/a/projects/repo', '/Users/a')).toBe('other-home') + expect(classifyDaemonPtyCwd('/Users/a', '/Users/a')).toBe('other-home') + expect(classifyDaemonPtyCwd('/Volumes/ext/repo', '/Users/a')).toBe('outside-home') + // A sibling home that merely shares the prefix is not inside this home. + expect(classifyDaemonPtyCwd('/Users/ab/Documents', '/Users/a')).toBe('outside-home') + }) +}) + +// Privacy invariant: enum-only. A raw path, version, or exact count must be rejected by .strict(). +describe('daemon_adopted / daemon_pty_cwd_denied schemas', () => { + const adopted = { + app_version_match: 'different', + spawner_path_class: 'updater-cache', + tcc_attribution: 'intact', + live_session_count_bucket: '2-5' + } + const denied = { + cwd_class: 'documents', + app_version_match: 'different', + spawner_path_class: 'updater-cache' + } + + it('accepts the enum payloads', () => { + expect(eventSchemas.daemon_adopted.safeParse(adopted).success).toBe(true) + expect(eventSchemas.daemon_pty_cwd_denied.safeParse(denied).success).toBe(true) + }) + + it('rejects leaked paths, versions, counts, and unknown enum values', () => { + for (const leak of [ + { spawner_exec_path: '/Users/alice/Library/Caches/ShipIt/Orca.app' }, + { app_version: '1.4.187' }, + { live_session_count: 3 }, + { cwd: '/Users/alice/Documents' } + ]) { + expect(eventSchemas.daemon_adopted.safeParse({ ...adopted, ...leak }).success).toBe(false) + expect(eventSchemas.daemon_pty_cwd_denied.safeParse({ ...denied, ...leak }).success).toBe( + false + ) + } + expect( + eventSchemas.daemon_adopted.safeParse({ ...adopted, spawner_path_class: '/Applications' }) + .success + ).toBe(false) + expect( + eventSchemas.daemon_pty_cwd_denied.safeParse({ ...denied, cwd_class: 'Documents' }).success + ).toBe(false) + }) +}) diff --git a/src/shared/daemon-adoption-telemetry.ts b/src/shared/daemon-adoption-telemetry.ts new file mode 100644 index 00000000000..72c21647cd3 --- /dev/null +++ b/src/shared/daemon-adoption-telemetry.ts @@ -0,0 +1,67 @@ +// Enums for the `daemon_adopted` and `daemon_pty_cwd_denied` telemetry events (#17696). +// Both exist to measure how often a macOS app runs on a daemon left behind by an earlier app +// bundle, and how often such a daemon actually spawns a terminal whose cwd it cannot read. +// Enum-only: no paths, versions, or exact counts ever reach the wire. + +/** How the adopted daemon's recorded app version compares to the running app. */ +export const DAEMON_ADOPTED_APP_VERSION_MATCH = ['same', 'different', 'unknown'] as const +export type DaemonAdoptedAppVersionMatch = (typeof DAEMON_ADOPTED_APP_VERSION_MATCH)[number] + +/** + * Where the binary that forked the adopted daemon lives now. `updater-cache` is the Squirrel + * ShipIt staging area — a daemon attributed there is the reported #17696 shape. + */ +export const DAEMON_SPAWNER_PATH_CLASSES = [ + 'applications', + 'updater-cache', + 'other', + 'missing', + 'unknown' +] as const +export type DaemonSpawnerPathClass = (typeof DAEMON_SPAWNER_PATH_CLASSES)[number] + +export const DAEMON_TCC_ATTRIBUTION_VALUES = ['intact', 'severed', 'unknown'] as const + +/** Which macOS-protected folder class the denied cwd falls under. */ +export const DAEMON_PTY_CWD_CLASSES = [ + 'documents', + 'desktop', + 'downloads', + 'other-home', + 'outside-home' +] as const +export type DaemonPtyCwdClass = (typeof DAEMON_PTY_CWD_CLASSES)[number] + +export function classifyDaemonSpawnerPath( + spawnerExecPath: string | null, + exists: (path: string) => boolean +): DaemonSpawnerPathClass { + if (!spawnerExecPath) { + return 'unknown' + } + if (!exists(spawnerExecPath)) { + return 'missing' + } + if (/\/Library\/Caches\/[^/]*ShipIt\//.test(spawnerExecPath)) { + return 'updater-cache' + } + return /^(?:\/private)?\/Applications\//.test(spawnerExecPath) ? 'applications' : 'other' +} + +export function classifyDaemonPtyCwd(cwd: string, homeDir: string): DaemonPtyCwdClass { + const home = homeDir.replace(/\/+$/, '') + if (!home || !(cwd === home || cwd.startsWith(`${home}/`))) { + return 'outside-home' + } + const topLevel = cwd.slice(home.length + 1).split('/')[0] + switch (topLevel) { + case 'Documents': + return 'documents' + case 'Desktop': + return 'desktop' + case 'Downloads': + return 'downloads' + default: + return 'other-home' + } +} diff --git a/src/shared/telemetry-daemon-event-schemas.ts b/src/shared/telemetry-daemon-event-schemas.ts index a0543d4d49b..c6b2795a333 100644 --- a/src/shared/telemetry-daemon-event-schemas.ts +++ b/src/shared/telemetry-daemon-event-schemas.ts @@ -14,6 +14,12 @@ import { DAEMON_AUDIT_TRIGGER_VALUES, DAEMON_EVIDENCE_SOURCE_VALUES } from './daemon-audit-eligibility' +import { + DAEMON_ADOPTED_APP_VERSION_MATCH, + DAEMON_PTY_CWD_CLASSES, + DAEMON_SPAWNER_PATH_CLASSES, + DAEMON_TCC_ATTRIBUTION_VALUES +} from './daemon-adoption-telemetry' import { errorClassSchema, settingsChangedKeySchema } from './telemetry-property-schemas' // Why: daemon start-failure signal (fleet-wide outage like v1.4.129-rc.1); enum-only so raw stderr never reaches the wire. @@ -50,6 +56,27 @@ export const mainThreadHangDetectedSchema = z }) .strict() +// Why: #17696 — a macOS app adopting a daemon from an earlier bundle is invisible to +// `daemon_lifecycle` (nothing is replaced). Once per macOS launch that adopts; enum-only. +export const daemonAdoptedSchema = z + .object({ + app_version_match: z.enum(DAEMON_ADOPTED_APP_VERSION_MATCH), + spawner_path_class: z.enum(DAEMON_SPAWNER_PATH_CLASSES), + tcc_attribution: z.enum(DAEMON_TCC_ATTRIBUTION_VALUES), + live_session_count_bucket: z.enum(DAEMON_LIFECYCLE_SESSION_BUCKETS) + }) + .strict() + +// Why: the #17696 symptom itself — the daemon spawned a terminal into a cwd it cannot read while +// the app can. Emitted only on that proven divergence, so a missing or app-unreadable cwd never counts. +export const daemonPtyCwdDeniedSchema = z + .object({ + cwd_class: z.enum(DAEMON_PTY_CWD_CLASSES), + app_version_match: z.enum(DAEMON_ADOPTED_APP_VERSION_MATCH), + spawner_path_class: z.enum(DAEMON_SPAWNER_PATH_CLASSES) + }) + .strict() + // Why: daemon replace/retire lifecycle signal — issue #7936 was undiagnosable without asking a user for daemon.log. // Enum-only + bucketed session count so no paths, raw versions, or exact counts reach the wire. // The union keeps each reason pinned to its transition, so a death can't be reported as a replace. diff --git a/src/shared/telemetry-event-registry.ts b/src/shared/telemetry-event-registry.ts index 29479f363cb..5a91640359a 100644 --- a/src/shared/telemetry-event-registry.ts +++ b/src/shared/telemetry-event-registry.ts @@ -14,8 +14,10 @@ import { agentHookTransportBlockedSchema, agentHookUnattributedSchema, codexTrustGrantSchema, + daemonAdoptedSchema, daemonAuditEligibilitySchema, daemonLifecycleSchema, + daemonPtyCwdDeniedSchema, daemonStartFailedSchema, mainThreadHangDetectedSchema, remoteOutboundBudgetCloseSchema, @@ -122,6 +124,8 @@ export const eventSchemas = { daemon_start_failed: daemonStartFailedSchema, main_thread_hang_detected: mainThreadHangDetectedSchema, daemon_lifecycle: daemonLifecycleSchema, + daemon_adopted: daemonAdoptedSchema, + daemon_pty_cwd_denied: daemonPtyCwdDeniedSchema, daemon_audit_eligibility: daemonAuditEligibilitySchema, runtime_rpc_start_failed: runtimeRpcStartFailedSchema, remote_outbound_budget_close: remoteOutboundBudgetCloseSchema, From 7f6cf271ceedb0030c280eae4db4458bdd892c28 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:53:11 -0700 Subject: [PATCH 40/94] fix(terminal): preserve panes when restored PTY owner is unverifiable (#17860) * fix(terminal): preserve unverifiable restored pane bindings * test(terminal): cover unverifiable restored pane identity * fix(terminal): settle direct SSH retry on unverifiable owner * fix(terminal): make owner warning actionable * fix(terminal): harden owner warning recovery feedback * test(terminal): consolidate fixture imports --------- Co-authored-by: Merge Sim --- .../terminal-pane/TerminalErrorToast.test.ts | 84 ++++++++++++++++- .../terminal-pane/TerminalErrorToast.tsx | 84 ++++++++++++++--- .../terminal-pane/TerminalPaneSurface.tsx | 22 ++++- ...-connection-direct-ssh-spawn-retry.test.ts | 92 ++++++++++++++++++- .../pty-connection-session-liveness.test.ts | 65 +++++++++++++ .../deferred-session-reattach-connect.ts | 20 ++++ src/renderer/src/i18n/locales/en.json | 6 +- 7 files changed, 354 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts index 069ed371ec0..220f968cd98 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import React from 'react' -import { cleanup, render, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const environmentMocks = vi.hoisted(() => ({ @@ -15,6 +15,7 @@ vi.mock('@/lib/client-environment-info', () => ({ import { TerminalErrorToast, humanizeTerminalError, + isPaneOwnerUnverifiedError, isExplainedTerminalError, isSshReconnectOwnedTerminalError, shouldOfferDaemonRestart, @@ -69,7 +70,15 @@ describe('humanizeTerminalError', () => { it('replaces the pane-owner-unverified code with actionable copy', () => { const humanized = humanizeTerminalError('terminal_pane_owner_unverified') expect(humanized).not.toContain('terminal_pane_owner_unverified') - expect(humanized).toContain('Reopen this pane to retry') + expect(humanized).toContain('Click Retry to try reconnecting now') + expect(humanized).toContain('Orca left the saved session unchanged') + expect(humanized).not.toContain('was not closed or deleted') + }) + + it('identifies the owner-unverified safety state', () => { + expect(isPaneOwnerUnverifiedError('terminal_pane_owner_unverified')).toBe(true) + expect(isPaneOwnerUnverifiedError('Paste failed.')).toBe(false) + expect(isPaneOwnerUnverifiedError('Paste failed.\nterminal_pane_owner_unverified')).toBe(false) }) it('humanizes an IPC-wrapped pane-owner-unverified error', () => { @@ -78,6 +87,22 @@ describe('humanizeTerminalError', () => { expect(humanizeTerminalError(wrapped)).not.toContain('terminal_pane_owner_unverified') }) + it('humanizes an owner marker without classifying mixed errors as safe warnings', () => { + const mixed = humanizeTerminalError('Paste failed.\nterminal_pane_owner_unverified') + expect(mixed).toContain('Paste failed.') + expect(mixed).toContain("Orca couldn't verify this terminal's owner.") + expect(mixed).not.toContain('terminal_pane_owner_unverified') + expect(isPaneOwnerUnverifiedError('Paste failed.\nterminal_pane_owner_unverified')).toBe(false) + }) + + it('humanizes every owner marker in an aggregated warning', () => { + const repeated = humanizeTerminalError( + "terminal_pane_owner_unverified\nError invoking remote method 'pty:spawn': Error: terminal_pane_owner_unverified" + ) + + expect(repeated).not.toContain('terminal_pane_owner_unverified') + }) + it('leaves other errors untouched', () => { expect(humanizeTerminalError('Paste failed.')).toBe('Paste failed.') }) @@ -302,4 +327,59 @@ describe('TerminalErrorToast environment footer', () => { await waitFor(() => expect(environmentMocks.resolveFooter).not.toHaveBeenCalled()) }) + + it('renders owner-unverified as a warning without an issue link', () => { + const onRetry = vi.fn().mockResolvedValue(true) + const view = render( + React.createElement(TerminalErrorToast, { + error: 'terminal_pane_owner_unverified', + onDismiss: vi.fn(), + onRetry + }) + ) + + const toast = view.container.querySelector('[data-terminal-error-toast]') + expect(toast?.getAttribute('data-terminal-error-kind')).toBe('owner-unverified') + expect(toast?.querySelector('a')).toBeNull() + expect(toast?.textContent).toContain('Orca left the saved session unchanged') + expect(view.getByRole('button', { name: 'Retry' }).getAttribute('data-slot')).toBe('button') + fireEvent.click(view.getByRole('button', { name: 'Retry' })) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it('keeps Retry available when the recovery attempt rejects', async () => { + const onRetry = vi.fn().mockRejectedValue(new Error('recovery unavailable')) + const view = render( + React.createElement(TerminalErrorToast, { + error: 'terminal_pane_owner_unverified', + onDismiss: vi.fn(), + onRetry + }) + ) + + fireEvent.click(view.getByRole('button', { name: 'Retry' })) + + await waitFor(() => + expect((view.getByRole('button', { name: 'Retry' }) as HTMLButtonElement).disabled).toBe( + false + ) + ) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it('explains when Retry is temporarily unavailable', async () => { + const onRetry = vi.fn().mockResolvedValue(false) + const view = render( + React.createElement(TerminalErrorToast, { + error: 'terminal_pane_owner_unverified', + onDismiss: vi.fn(), + onRetry + }) + ) + + fireEvent.click(view.getByRole('button', { name: 'Retry' })) + await waitFor(() => + expect(view.container.textContent).toContain('Retry could not reconnect yet') + ) + }) }) diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index ee876ae719e..2ba98a180d6 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { translate } from '@/i18n/i18n' import { resolveClientEnvironmentFooter } from '@/lib/client-environment-info' +import { Button } from '@/components/ui/button' import { hasClientEnvironmentFooter } from '../../../../shared/client-environment-info' const SSH_PREFIX = 'SSH connection is not active' @@ -78,6 +79,11 @@ export function isExplainedTerminalError(error: string): boolean { ) } +export function isPaneOwnerUnverifiedError(error: string): boolean { + const lines = error.split('\n').filter((line) => line.length > 0) + return lines.length > 0 && lines.every((line) => line.includes(PANE_OWNER_UNVERIFIED_MARKER)) +} + function humanizeUnreattachableSession(error: string): string { const explanation = translate( 'auto.components.terminal.pane.TerminalErrorToast.sessionUnavailable', @@ -94,13 +100,16 @@ function humanizeUnreattachableSession(error: string): string { export function humanizeTerminalError(error: string): string { let humanized = error if (humanized.includes(PANE_OWNER_UNVERIFIED_MARKER)) { - humanized = humanized.replace( - PANE_OWNER_UNVERIFIED_MARKER, - translate( - 'auto.components.terminal.pane.TerminalErrorToast.7ee11bc0db', - "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry." - ) - ) + const explanation = isPaneOwnerUnverifiedError(humanized) + ? translate( + 'auto.components.terminal.pane.TerminalErrorToast.42b283ecfc', + "Orca couldn't safely reconnect this terminal because the host couldn't verify its saved session. Orca left the saved session unchanged. Click Retry to try reconnecting now. If it still cannot reconnect, open a new terminal." + ) + : translate( + 'auto.components.terminal.pane.TerminalErrorToast.ownerUnknown', + "Orca couldn't verify this terminal's owner." + ) + humanized = humanized.replaceAll(PANE_OWNER_UNVERIFIED_MARKER, () => explanation) } humanized = humanizeUnreattachableSession(humanized) if (!isExplainedTerminalError(humanized)) { @@ -127,17 +136,23 @@ export function humanizeTerminalError(error: string): string { export function TerminalErrorToast({ error, onDismiss, - onRestartDaemon + onRestartDaemon, + onRetry }: { error: string onDismiss: () => void onRestartDaemon?: () => void + onRetry?: () => Promise }): React.JSX.Element { const ssh = isSshError(error) + const paneOwnerUnverified = isPaneOwnerUnverifiedError(error) const showDaemonRestart = !ssh && onRestartDaemon && shouldOfferDaemonRestart(error) // Restart cannot recover a session after its owning daemon exits. - const showIssueLink = !ssh && !showDaemonRestart && !isExplainedTerminalError(error) + const showIssueLink = + !ssh && !paneOwnerUnverified && !showDaemonRestart && !isExplainedTerminalError(error) const displayError = humanizeTerminalError(error) + const [retrying, setRetrying] = useState(false) + const [retryFailed, setRetryFailed] = useState(false) const [environmentFooter, setEnvironmentFooter] = useState<{ error: string footer: string @@ -160,10 +175,26 @@ export function TerminalErrorToast({ }, [displayError, ssh]) const footer = environmentFooter?.error === displayError ? environmentFooter.footer : '' + const handleRetry = async (): Promise => { + if (!onRetry || retrying) { + return + } + setRetrying(true) + setRetryFailed(false) + try { + setRetryFailed(!(await onRetry())) + } catch { + // Keep the safety warning available when a best-effort remount cannot start. + setRetryFailed(true) + } finally { + setRetrying(false) + } + } return (
) : null} {!ssh && footer ? `\n\n${footer}` : null} + {paneOwnerUnverified && retryFailed + ? `\n${translate( + 'auto.components.terminal.pane.TerminalErrorToast.retryUnavailable', + 'Retry could not reconnect yet. Try again shortly.' + )}` + : null} {showDaemonRestart ? ( + ) : null} +
) diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts index 1ff70d76026..b32c88d7806 100644 --- a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts +++ b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts @@ -11,6 +11,7 @@ export type CombinedDiffViewPreferences = { setFileTreeCollapsed: (collapsed: boolean) => void setSideBySide: React.Dispatch> sideBySide: boolean + toggleDiffShowWhitespace: () => void toggleDiffWordWrap: () => void toggleSideBySide: () => void } @@ -18,6 +19,7 @@ export type CombinedDiffViewPreferences = { export function useCombinedDiffViewPreferences({ combinedDiffFileTreeVisibleByDefault, diffDefaultView, + diffShowWhitespace, diffWordWrap, registry, setSections, @@ -25,10 +27,11 @@ export function useCombinedDiffViewPreferences({ }: { combinedDiffFileTreeVisibleByDefault: boolean | undefined diffDefaultView: string | undefined + diffShowWhitespace: boolean | undefined diffWordWrap: boolean | undefined registry: CombinedDiffSectionLoadRegistry setSections: React.Dispatch> - updateSettings: (patch: { diffWordWrap: boolean }) => unknown + updateSettings: (patch: { diffShowWhitespace?: boolean; diffWordWrap?: boolean }) => unknown }): CombinedDiffViewPreferences { const { loadSchedulerRef, loadedIndicesRef, sectionsRef } = registry const [sideBySide, setSideBySide] = useState( @@ -89,12 +92,17 @@ export function useCombinedDiffViewPreferences({ void updateSettings({ diffWordWrap: diffWordWrap !== true }) }, [diffWordWrap, updateSettings]) + const toggleDiffShowWhitespace = useCallback(() => { + void updateSettings({ diffShowWhitespace: diffShowWhitespace !== true }) + }, [diffShowWhitespace, updateSettings]) + return { fileTreeCollapsed, setAllSectionsCollapsed, setFileTreeCollapsed, setSideBySide, sideBySide, + toggleDiffShowWhitespace, toggleDiffWordWrap, toggleSideBySide } diff --git a/src/renderer/src/components/editor/diff-editor-whitespace-options.test.ts b/src/renderer/src/components/editor/diff-editor-whitespace-options.test.ts new file mode 100644 index 00000000000..793914d4799 --- /dev/null +++ b/src/renderer/src/components/editor/diff-editor-whitespace-options.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { buildDiffEditorWhitespaceOptions } from './diff-editor-whitespace-options' + +describe('buildDiffEditorWhitespaceOptions', () => { + it('ignores trim whitespace by default', () => { + expect(buildDiffEditorWhitespaceOptions(undefined)).toEqual({ ignoreTrimWhitespace: true }) + expect(buildDiffEditorWhitespaceOptions(false)).toEqual({ ignoreTrimWhitespace: true }) + }) + + it('includes whitespace in the diff when the preference is on', () => { + expect(buildDiffEditorWhitespaceOptions(true)).toEqual({ ignoreTrimWhitespace: false }) + }) +}) diff --git a/src/renderer/src/components/editor/diff-editor-whitespace-options.ts b/src/renderer/src/components/editor/diff-editor-whitespace-options.ts new file mode 100644 index 00000000000..4c860fadeb7 --- /dev/null +++ b/src/renderer/src/components/editor/diff-editor-whitespace-options.ts @@ -0,0 +1,10 @@ +import type { editor } from 'monaco-editor' + +export function buildDiffEditorWhitespaceOptions( + diffShowWhitespace: boolean | undefined +): Pick { + return { + // Why: Monaco defaults this to true, which hides indentation-only diffs. + ignoreTrimWhitespace: diffShowWhitespace !== true + } +} diff --git a/src/renderer/src/components/editor/diff-section-item-props.ts b/src/renderer/src/components/editor/diff-section-item-props.ts index 29d327d100a..e5ef73860c2 100644 --- a/src/renderer/src/components/editor/diff-section-item-props.ts +++ b/src/renderer/src/components/editor/diff-section-item-props.ts @@ -13,6 +13,7 @@ export type DiffSectionItemProps = { terminalFontSize?: number terminalFontFamily?: string diffWordWrap?: boolean + diffShowWhitespace?: boolean } | null sectionHeight: number | undefined worktreeId?: string diff --git a/src/renderer/src/components/settings/DiffShowWhitespaceSetting.test.tsx b/src/renderer/src/components/settings/DiffShowWhitespaceSetting.test.tsx new file mode 100644 index 00000000000..294d627fc20 --- /dev/null +++ b/src/renderer/src/components/settings/DiffShowWhitespaceSetting.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom + +import { join } from 'node:path' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) => + selector({ settingsSearchQuery: '' }) +})) + +import { DiffShowWhitespaceSetting } from './DiffShowWhitespaceSetting' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null +}) + +function renderSetting(diffShowWhitespace: boolean, updateSettings = vi.fn()) { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render( + + ) + }) + return { container, updateSettings } +} + +describe('DiffShowWhitespaceSetting', () => { + it('defaults to off so whitespace-only diffs stay quiet', () => { + const { container } = renderSetting(false) + const off = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'Off' + ) + + expect(off?.getAttribute('aria-checked')).toBe('true') + }) + + it('shows on when the preference is enabled', () => { + const { container } = renderSetting(true) + const on = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'On' + ) + + expect(on?.getAttribute('aria-checked')).toBe('true') + }) + + it('persists the on choice', () => { + const updateSettings = vi.fn() + const { container } = renderSetting(false, updateSettings) + const on = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'On' + ) + + act(() => on?.click()) + + expect(updateSettings).toHaveBeenCalledWith({ diffShowWhitespace: true }) + }) +}) diff --git a/src/renderer/src/components/settings/DiffShowWhitespaceSetting.tsx b/src/renderer/src/components/settings/DiffShowWhitespaceSetting.tsx new file mode 100644 index 00000000000..998274b84a7 --- /dev/null +++ b/src/renderer/src/components/settings/DiffShowWhitespaceSetting.tsx @@ -0,0 +1,69 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { translate } from '@/i18n/i18n' +import { SearchableSetting } from './SearchableSetting' +import { Label } from '../ui/label' +import { SettingsSegmentedControl } from './SettingsFormControls' + +type DiffShowWhitespaceSettingProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +} + +export function DiffShowWhitespaceSetting({ + settings, + updateSettings +}: DiffShowWhitespaceSettingProps): React.JSX.Element { + return ( + +
+ +

+ {translate( + 'auto.components.settings.GeneralEditorSettingsSection.94a479cef3', + 'Show leading and trailing whitespace differences in diffs.' + )} +

+
+ updateSettings({ diffShowWhitespace: option === 'on' })} + options={[ + { + value: 'off', + label: translate( + 'auto.components.settings.GeneralEditorSettingsSection.bf16ef0af2', + 'Off' + ) + }, + { + value: 'on', + label: translate( + 'auto.components.settings.GeneralEditorSettingsSection.3f6892f307', + 'On' + ) + } + ]} + /> +
+ ) +} diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx index 6b3b211f628..f4221c2823d 100644 --- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -17,6 +17,7 @@ import { } from './SettingsFormControls' import { translate } from '@/i18n/i18n' import { RichMarkdownSpellcheckSetting } from './RichMarkdownSpellcheckSetting' +import { DiffShowWhitespaceSetting } from './DiffShowWhitespaceSetting' import { EditorWordWrapSetting } from './EditorWordWrapSetting' import { EditorFontFamilySetting } from './EditorFontFamilySetting' import { @@ -232,6 +233,8 @@ export function GeneralEditorSettingsSection({ + + Date: Wed, 2 Sep 2026 01:29:26 -0700 Subject: [PATCH 83/94] fix(relay): fail an over-budget RPC response, not the connection (#17968) The relay's control lane is a shared 1 MiB budget, and `sendResponse` admitted responses onto it with the fatal default: once the lane was full, admission closed the client. A ~900 KB `fs.listFiles` reply from a large remote workspace therefore took down the whole remote session -- every terminal on it -- rather than failing the one Quick Open request. The substitute `ResponseOverCapacity` frame already there only covered the `legacy-response` lane, because the fatal close beat it to the client. A JSON-RPC response is the droppable class of control frame: it carries an id, so one caller can be told and can retry. `pty.replay` and `notifyControl` keep the fatal default -- they are never re-sent, and a silent drop there desyncs the client with nothing to retry. Both response enqueues now pass `controlOverflow: 'reject'`, so the substitute error is what the caller sees; in the corner where even ~150 bytes will not fit, the caller's own 30s request timeout settles it and the session survives. Old clients are unaffected: they already decode this error code and message generically (`ssh-channel-multiplexer.handleResponse` rejects the pending promise with both), and the frame shape is unchanged. What changes is that a listing which used to drop the connection now returns an error on it. --- .../dispatcher-capacity-degradation.test.ts | 96 +++++++++++++++++++ src/relay/dispatcher-rpc-routing.ts | 14 ++- src/relay/fs-handler-file-range.test.ts | 8 +- src/shared/file-range-read.ts | 9 +- 4 files changed, 116 insertions(+), 11 deletions(-) diff --git a/src/relay/dispatcher-capacity-degradation.test.ts b/src/relay/dispatcher-capacity-degradation.test.ts index 7bcf81674dc..2c746168ecc 100644 --- a/src/relay/dispatcher-capacity-degradation.test.ts +++ b/src/relay/dispatcher-capacity-degradation.test.ts @@ -59,6 +59,14 @@ function makeBoundedClient(highWaterMark: number): BoundedClient { return client } +// Why: the sink accepts every write but never settles it, so control-lane bytes stay retained and the +// queue fills, while the writer keeps pumping the other lanes — the shape of a peer whose socket is behind. +function makeUnsettledWriteClient(highWaterMark: number): BoundedClient { + const client = makeBoundedClient(highWaterMark) + client.options = { ...client.options, supportsWriteCallback: true } + return client +} + function decodePayload(frame: Buffer): Record { const length = frame.readUInt32BE(9) return JSON.parse(frame.subarray(13, 13 + length).toString('utf-8')) @@ -464,4 +472,92 @@ describe('RelayDispatcher bounded-capacity degradation', () => { bounded.dispose() } }) + + it('answers an over-budget response with a capacity error instead of closing the connection', async () => { + const primary = makeUnsettledWriteClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const clientId = bounded.activeClientIds()[0] + bounded.onRequest('fs.listFiles', async () => ({ paths: 'x'.repeat(700 * 1024) })) + bounded.onRequest('workspace.get', async () => ({ name: 'workspace' })) + + bounded.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 91, method: 'fs.listFiles' }, 1, 0)) + await vi.advanceTimersByTimeAsync(0) + expect(primary.frames).toHaveLength(1) + + // The first reply still holds the shared control budget, so the second cannot fit under 1 MiB. + bounded.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 92, method: 'fs.listFiles' }, 2, 0)) + await vi.advanceTimersByTimeAsync(0) + + expect(primary.closes).toBe(0) + expect(bounded.isClientAttached(clientId)).toBe(true) + expect(primary.frames).toHaveLength(2) + const rejected = decodePayload(primary.frames[1]) as unknown as { + id: number + error: { code: number; message: string } + } + expect(rejected.id).toBe(92) + expect(rejected.error.code).toBe(RelayErrorCode.ResponseOverCapacity) + expect(rejected.error.message).toBe('Relay response exceeded the bounded transport capacity') + + // Every other pane and request on this connection keeps working. + bounded.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 93, method: 'workspace.get' }, 3, 0)) + await vi.advanceTimersByTimeAsync(0) + expect(decodePayload(primary.frames[2])).toMatchObject({ + id: 93, + result: { name: 'workspace' } + }) + + bounded.notify('pty.data', { paneId: 'pane-1', data: 'still-live' }) + expect(decodePayload(primary.frames[3])).toMatchObject({ method: 'pty.data' }) + expect(primary.closes).toBe(0) + } finally { + bounded.dispose() + } + }) + + it('still closes the client when a protocol-critical control frame overflows', () => { + const primary = makeUnsettledWriteClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const clientId = bounded.activeClientIds()[0] + bounded.notifyClient(clientId, 'workspace.stale', { blob: 'x'.repeat(700 * 1024) }) + expect(primary.closes).toBe(0) + + // Replay is never re-sent, so an unnoticed drop strands the pane: overflow here stays fatal. + bounded.notify('pty.replay', { paneKey: 'tab-1:pane-1', data: 'y'.repeat(700 * 1024) }) + expect(primary.closes).toBe(1) + } finally { + bounded.dispose() + } + }) + + it('drops an unsendable response without closing when even the capacity error will not fit', async () => { + const primary = makeUnsettledWriteClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const clientId = bounded.activeClientIds()[0] + const settlements: SinkWriteSettlement[] = [] + bounded.onRequest('workspace.get', async (_params, context) => { + context.onResponseSettled?.((result) => settlements.push(result)) + return { name: 'workspace' } + }) + for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { + bounded.notifyClient(clientId, `control.${index}`) + } + const framesBefore = primary.frames.length + + bounded.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 94, method: 'workspace.get' }, 1, 0)) + await vi.advanceTimersByTimeAsync(0) + + // Nothing goes out, but the connection lives and the caller's own request timeout settles it. + expect(primary.closes).toBe(0) + expect(primary.frames).toHaveLength(framesBefore) + expect(settlements).toEqual([ + { ok: false, error: new Error('Relay response was not admitted') } + ]) + } finally { + bounded.dispose() + } + }) }) diff --git a/src/relay/dispatcher-rpc-routing.ts b/src/relay/dispatcher-rpc-routing.ts index 164a375e1c6..a6e6c24190c 100644 --- a/src/relay/dispatcher-rpc-routing.ts +++ b/src/relay/dispatcher-rpc-routing.ts @@ -203,12 +203,17 @@ export abstract class RelayDispatcherRpcRouting extends RelayDispatcherFrameCode const frame = this.prepareFrame(msg) const lane = frame.frameBytes > DISPATCHER_CONTROL_QUEUE_MAX_BYTES ? 'legacy-response' : 'control' - const accepted = this.enqueuePreparedFrame(client, frame, lane, onSettled) + // Why 'reject': the control lane is a shared budget, so a reply that fits the 1 MiB ceiling alone + // still overflows it under concurrent traffic. Fatal admission would close the connection — every + // pane on the host — over one listing. A response is the droppable class of control frame: it + // carries an id, so the substitute below tells that one caller, and pty.replay/notifyControl keep + // the fatal default because a silent drop there desyncs the client with nothing to retry. + const accepted = this.enqueuePreparedFrame(client, frame, lane, onSettled, 'reject') if (accepted) { return true } // Why: an oversized response must fail its own request; closing would kill every pane on the host. - // A rejected first enqueue either left onSettled untouched or closed the client, so exactly one settlement happens. + // A rejected first enqueue leaves onSettled untouched, so exactly one settlement happens. return this.enqueuePreparedFrame( client, this.prepareFrame({ @@ -227,7 +232,10 @@ export abstract class RelayDispatcherRpcRouting extends RelayDispatcherFrameCode settlement.ok ? { ok: false, error: new Error(RESPONSE_OVER_CAPACITY_MESSAGE) } : settlement - ) + ), + // Why 'reject': if even ~150 bytes will not fit, the caller's own request timeout settles it. + // Closing to report that one request failed is the outcome this whole path exists to avoid. + 'reject' ) } diff --git a/src/relay/fs-handler-file-range.test.ts b/src/relay/fs-handler-file-range.test.ts index 3ecca25ec4c..0d601b9b9fe 100644 --- a/src/relay/fs-handler-file-range.test.ts +++ b/src/relay/fs-handler-file-range.test.ts @@ -105,10 +105,10 @@ describe('readRelayFileRange', () => { // which the writer refuses once the producer queue is busy -- so an over-wide // cap fails with ResponseOverCapacity depending on unrelated load. // - // Fitting the lane once is not enough: the control queue is a SHARED budget - // and overflowing it closes the client, so a full-cap frame has to leave room - // for a second one. Anything wider lets two pipelined tail reads -- or one - // read racing an unrelated response -- kill the connection. + // Fitting the lane once is not enough: the control queue is a SHARED budget, + // so a full-cap frame has to leave room for a second one. Anything wider lets + // two pipelined tail reads -- or one read racing an unrelated response -- + // fail as ResponseOverCapacity on load that has nothing to do with them. it('leaves control-queue headroom for a second full-cap window', async () => { const contents = Buffer.allocUnsafe(MAX_FILE_RANGE_READ_BYTES) for (let i = 0; i < contents.length; i++) { diff --git a/src/shared/file-range-read.ts b/src/shared/file-range-read.ts index fe695e9a2c9..61401f6d40f 100644 --- a/src/shared/file-range-read.ts +++ b/src/shared/file-range-read.ts @@ -8,10 +8,11 @@ * frames to ~350 KB, so a full-cap response takes the control lane instead. * * The control lane is a shared budget, not a per-frame one: two full-cap - * responses fit alongside each other, and the third overflows -- which for a - * response is fatal, it closes the client. That is the same exposure every - * control-lane response already carries (`fs.readFile` frames any sub-1 MiB - * file the same way), and the two-deep headroom is pinned by a test. Widening + * responses fit alongside each other, and the third is refused. That refusal + * costs the one request -- `sendResponse` admits responses with + * `controlOverflow: 'reject'` and substitutes a `ResponseOverCapacity` error + * rather than closing the connection -- but it still turns on unrelated load, + * so the two-deep headroom that keeps it rare is pinned by a test. Widening * the cap spends that headroom, so bigger transfers belong on the ack-paced * bulk lane (`fs.readFileStream`) rather than on a wider window here. * From 7eb13c184c48d61d42f058eeddbe8e18cea0cba9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:29:30 -0700 Subject: [PATCH 84/94] fix(ssh): keep remote PowerShell commands inside what sshd's cmd.exe accepts (#17947) * fix(ssh): keep remote Windows commands inside cmd.exe's command-line limit Windows OpenSSH runs every exec request through sshd's DefaultShell, which is cmd.exe on a stock install, and cmd.exe refuses a line over 8191 characters with exit 1 and a localized "The command line is too long". `-EncodedCommand` spends 2.67 characters per script character, so five commands on the first-connect path were already over: the stale upload-stage recovery that opens a fresh install (23,210), promote (20,646), cleanup (19,798), the install-lock steal (11,798) and reserve (9,434). A Windows-to-Windows `ssh:connect` died on the first of them before the relay was ever uploaded (#16126). powerShellCommand now falls back to a gzip self-extracting bootstrap once the inline form passes the budget - these scripts are repetitive enough that the worst one lands at 6.5KB - and throws a message naming the limit if even that cannot fit, rather than letting cmd.exe answer in the host's locale. Commands that already fit are byte-identical. The real-binary PowerShell suite in ssh-relay-upload-stage-commands.test.ts exercises the bootstrap end to end, including `exit` and here-string semantics through Invoke-Expression. * fix(ssh): cite the real command-line budget and reuse the cmd.exe ceiling --- src/main/providers/windows-shell-args.ts | 3 +- .../ssh-relay-sftp-namespace-install.test.ts | 4 +- src/main/ssh/ssh-remote-commands.test.ts | 4 +- src/main/ssh/ssh-remote-powershell.ts | 50 ++++++++++++ ...-remote-windows-command-line-limit.test.ts | 78 +++++++++++++++++++ 5 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 src/main/ssh/ssh-remote-windows-command-line-limit.test.ts diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 74e5af534f2..70fd22a06ad 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -14,7 +14,8 @@ import { } from '../powershell-osc133-bootstrap' import { quoteStartupArg } from '../../shared/tui-agent-startup-shell' -const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 +/** cmd.exe's own documented ceiling; callers that go through sshd budget below it. */ +export const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000 const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000 const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul' diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts index 4c0874979b3..10ebce2dac4 100644 --- a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts +++ b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts @@ -85,6 +85,7 @@ import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' import { parseUnameToRelayPlatform } from './relay-protocol' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' import { abandonInstall, finalizeInstall, @@ -155,8 +156,7 @@ function issuedMarkerNames(): string[] { } function decodeCommand(command: string): string { - const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) - return match ? Buffer.from(match[1], 'base64').toString('utf16le') : command + return decodeRemotePowerShellScript(command) } function execCommands(): string[] { diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index b69715b23bf..363a87a645d 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -13,6 +13,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' import { lockAgeSecondsCommand, tryCreateInstallLockCommand, @@ -63,8 +64,7 @@ const powerShell51Executable = : undefined function decodePowerShellCommand(command: string): string { - const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) - return match ? Buffer.from(match[1], 'base64').toString('utf16le') : '' + return command.includes('-EncodedCommand ') ? decodeRemotePowerShellScript(command) : '' } function runShellCommand(command: string): Promise { diff --git a/src/main/ssh/ssh-remote-powershell.ts b/src/main/ssh/ssh-remote-powershell.ts index cbc3b4faddc..8c94fd3c483 100644 --- a/src/main/ssh/ssh-remote-powershell.ts +++ b/src/main/ssh/ssh-remote-powershell.ts @@ -1,9 +1,59 @@ +import { gunzipSync, gzipSync } from 'node:zlib' import { encodePowerShellCommand } from '../../shared/powershell-command-encoding' +import { CMD_EXE_COMMAND_LINE_MAX_CHARS } from '../providers/windows-shell-args' export { quotePowerShellLiteral as powerShellLiteral, quotePowerShellNativeArgument as powerShellNativeArg } from '../../shared/powershell-native-argument' +// Why cmd.exe and not the 32767 CreateProcess cap: Windows OpenSSH runs every exec request +// through sshd's DefaultShell, cmd.exe on a stock install. Budget under cmd.exe's own ceiling +// to leave room for the `/c` wrapper sshd adds before cmd.exe counts the line. +const WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS = 8_000 + export function powerShellCommand(script: string): string { + const inline = encodedPowerShellCommand(script) + if (inline.length <= WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS) { + return inline + } + // Why: these scripts are repetitive enough that gzip beats the UTF-16LE tax by + // ~4x, which is the difference between a line cmd.exe runs and one it refuses. + const compressed = encodedPowerShellCommand(selfExtractingPowerShellScript(script)) + if (compressed.length > WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS) { + throw new Error( + `Remote Windows command needs ${compressed.length} characters; Orca budgets ${WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS} for a line sshd hands to cmd.exe, which itself refuses more than ${CMD_EXE_COMMAND_LINE_MAX_CHARS}.` + ) + } + return compressed +} + +function encodedPowerShellCommand(script: string): string { return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}` } + +/** Orca-prefixed names so the payload can never shadow the bootstrap's own state. */ +function selfExtractingPowerShellScript(script: string): string { + const payload = gzipSync(Buffer.from(script, 'utf-8'), { level: 9 }).toString('base64') + return [ + `$OrcaScriptBytes = [Convert]::FromBase64String('${payload}')`, + '$OrcaScriptMemory = New-Object System.IO.MemoryStream -ArgumentList (,$OrcaScriptBytes)', + '$OrcaScriptGzip = New-Object System.IO.Compression.GZipStream -ArgumentList $OrcaScriptMemory, ([System.IO.Compression.CompressionMode]::Decompress)', + '$OrcaScriptReader = New-Object System.IO.StreamReader -ArgumentList $OrcaScriptGzip, ([System.Text.Encoding]::UTF8)', + '$OrcaScriptText = $OrcaScriptReader.ReadToEnd()', + '$OrcaScriptReader.Dispose()', + 'Invoke-Expression $OrcaScriptText' + ].join('\n') +} + +/** Inverse of `powerShellCommand`: the script the host will actually run. */ +export function decodeRemotePowerShellScript(command: string): string { + const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/u)?.[1] + if (!encoded) { + return command + } + const script = Buffer.from(encoded, 'base64').toString('utf16le') + const payload = script.match( + /^\$OrcaScriptBytes = \[Convert\]::FromBase64String\('([A-Za-z0-9+/=]+)'\)/u + )?.[1] + return payload ? gunzipSync(Buffer.from(payload, 'base64')).toString('utf-8') : script +} diff --git a/src/main/ssh/ssh-remote-windows-command-line-limit.test.ts b/src/main/ssh/ssh-remote-windows-command-line-limit.test.ts new file mode 100644 index 00000000000..c104078238e --- /dev/null +++ b/src/main/ssh/ssh-remote-windows-command-line-limit.test.ts @@ -0,0 +1,78 @@ +import { gunzipSync } from 'node:zlib' +import { describe, expect, it } from 'vitest' +import { CMD_EXE_COMMAND_LINE_MAX_CHARS } from '../providers/windows-shell-args' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { tryStealInstallLockCommand } from './ssh-relay-install-lock-commands' +import { decodeRemotePowerShellScript, powerShellCommand } from './ssh-remote-powershell' +import { + cleanupOwnedRelayUploadStageCommand, + promoteOwnedRelayUploadStageCommand, + recoverOneStaleRelayUploadStageCommand, + reserveRelayUploadStageCommand, + type RelayUploadStageSlot +} from './ssh-relay-upload-stage-commands' + +const windows = getRemoteHostPlatform('win32-x64') +const owner = '.sftp-namespace-123e4567e89b12d3a456426614174000' +const pool = 'C:\\Users\\orca\\.orca-remote\\.upload-stages' +const stage: RelayUploadStageSlot = { + poolDir: pool, + slotName: 'slot-0', + slotDir: `${pool}\\slot-0`, + claimDir: `${pool}\\claim-0`, + deleteDir: `${pool}\\delete-0` +} + +// Why: sshd runs an exec request through its DefaultShell, which is cmd.exe on a +// stock Windows OpenSSH install, and cmd.exe refuses a longer line with exit 1 +// and a localized "The command line is too long" — the whole connect dies there. +describe('Windows remote command line limit', () => { + it.each([ + ['recover stale upload stage', recoverOneStaleRelayUploadStageCommand(windows, pool)], + ['reserve upload stage', reserveRelayUploadStageCommand(windows, pool, owner)], + [ + 'promote upload stage', + promoteOwnedRelayUploadStageCommand(windows, stage, owner, 'C:\\Users\\orca\\.orca-remote') + ], + ['cleanup upload stage', cleanupOwnedRelayUploadStageCommand(windows, stage, owner)], + [ + 'steal stale install lock', + tryStealInstallLockCommand(windows, 'C:\\Users\\orca\\.orca-remote\\relay', 1_200) + ] + ])('keeps the %s command inside what sshd\u2019s cmd.exe accepts', (_name, command) => { + expect(command.length).toBeLessThanOrEqual(CMD_EXE_COMMAND_LINE_MAX_CHARS) + }) + + it('leaves a command that already fits byte-identical', () => { + const script = "Write-Output ([Environment]::GetFolderPath('UserProfile'))" + expect(decodeRemotePowerShellScript(powerShellCommand(script))).toBe(script) + }) + + it('carries an oversized script through gzip without altering it', () => { + const script = Array.from( + { length: 200 }, + (_unused, index) => `Write-Output ${index}; $slot = 'C:\\Users\\orca\\stage-${index}'` + ).join('\n') + const command = powerShellCommand(script) + expect(command.length).toBeLessThanOrEqual(CMD_EXE_COMMAND_LINE_MAX_CHARS) + expect(decodeRemotePowerShellScript(command)).toBe(script) + const bootstrap = Buffer.from( + command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)$/u)?.[1] ?? '', + 'base64' + ).toString('utf16le') + const payload = bootstrap.match(/FromBase64String\('([A-Za-z0-9+/=]+)'\)/u)?.[1] ?? '' + expect(gunzipSync(Buffer.from(payload, 'base64')).toString('utf-8')).toBe(script) + expect(bootstrap).toContain('Invoke-Expression $OrcaScriptText') + }) + + it('refuses a script no encoding can fit instead of letting cmd.exe reject it', () => { + let seed = 12345 + const incompressible = Array.from({ length: 60_000 }, () => { + seed = (seed * 1103515245 + 12345) % 2147483648 + return String.fromCharCode(97 + (seed % 26)) + }).join('') + expect(() => powerShellCommand(`Write-Output '${incompressible}'`)).toThrow( + /Orca budgets 8000 for a line sshd hands to cmd\.exe/u + ) + }) +}) From 5dc1195a47b6499ff1e099b6f1a5840e3e66dbd3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:34:58 -0700 Subject: [PATCH 85/94] fix(native-chat): keep disabled CLI models out of the Claude picker (#18055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): keep disabled CLI models out of the Claude picker The Claude CLI advertises models it cannot run yet as disabled placeholder rows. On 2.1.237 `list_models` returns a sixth row alongside the four real models: {"value":"cc-update-required-1","displayName":"Fable 5.1 (disabled)", "description":"Update to 2.1.255+ to use Fable 5.1","disabled":true} `toListedModel` never read `disabled`, and for Claude the discovered list replaces the seed catalog verbatim, so the picker rendered that row as a selectable model and `/model cc-update-required-1` went to the CLI. It was also adoptable as a launch default, putting the sentinel behind `--model` on spawn. Drop disabled rows at the parse choke point, which both the native-chat picker and commit-message model discovery share. The two adjacent fixes are the same version-pinning bug the placeholder announces. `compactTerminalText` strips only whitespace, so a point release keeps its dot and the pinned consent literals (`fable5uses…`, `switchtofable5?`) stop matching a "Fable 5.1" prompt — the switch would degrade to `unknown` instead of `interaction-required`. Likewise the scoped weekly usage window matched `display_name === 'fable'` exactly, so it would disappear once the scope is named "Fable 5.1". Claude-Session: https://claude.ai/code/session_01SJy4XGrdre6YaU1wYNKak4 * fix(native-chat): make the Fable consent match version-optional Probing a 2.1.258 CLI shows the shipped Fable 5.1 row carries displayName "Fable" with the version only in the description: {"value":"claude-fable-5-1[1m]","resolvedModel":"claude-fable-5-1", "displayName":"Fable","description":"Fable 5.1 · Most capable for …"} So the consent prompt may name the model with no digits at all. Requiring a version would have missed that, the same way the old pinned literal missed "Fable 5.1". Accept both. Claude-Session: https://claude.ai/code/session_01SJy4XGrdre6YaU1wYNKak4 --------- Co-authored-by: Merge Sim Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../claude-fetcher-fable-usage.test.ts | 45 +++++++++++++++++++ .../rate-limits/claude-oauth-usage-request.ts | 6 ++- .../claude-model-switch-confirmation.test.ts | 42 +++++++++++++++++ .../claude-model-switch-confirmation.ts | 11 ++++- src/shared/claude-model-list-probe.test.ts | 26 +++++++++++ src/shared/claude-model-list-probe.ts | 6 ++- 6 files changed, 132 insertions(+), 4 deletions(-) diff --git a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts index 2a84cc4f2af..50617d95bb4 100644 --- a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts +++ b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts @@ -144,6 +144,51 @@ describe('fetchClaudeRateLimits', () => { expect(fetchViaPty).not.toHaveBeenCalled() }) + it('maps a scoped Fable window whose display name carries a point release', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'managed:account-1' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 36 }, + seven_day: { utilization: 73 }, + // Discriminating: a passing scope match must beat this fallback. + fable_weekly: { utilization: 12 }, + limits: [ + { + kind: 'weekly_scoped', + percent: 64, + resets_at: '2026-07-17T20:00:00.099908+00:00', + is_active: true, + scope: { model: { display_name: 'Fable 5.1' } } + } + ] + }), + { status: 200 } + ) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowUsagePanelSupplement: true }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + fableWeekly: { + usedPercent: 64, + resetsAt: Date.parse('2026-07-17T20:00:00.099908+00:00') + } + }) + expect(fetchViaPty).not.toHaveBeenCalled() + }) + it('surfaces inactive scoped Fable usage over the legacy OAuth fallback', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-oauth-usage-request.ts b/src/main/rate-limits/claude-oauth-usage-request.ts index 9565a064514..e29bef5f48e 100644 --- a/src/main/rate-limits/claude-oauth-usage-request.ts +++ b/src/main/rate-limits/claude-oauth-usage-request.ts @@ -32,13 +32,17 @@ async function ensureProxyFromEnvironment(): Promise { }).catch(() => {}) } +// Why: the scope name carries the shipped version once a point release exists +// ("Fable 5.1"), so exact equality would drop the window. +const FABLE_SCOPE_RE = /^fable\b/ + function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null { const scoped = Array.isArray(data.limits) ? data.limits.find( (limit) => limit?.kind === 'weekly_scoped' && Number.isFinite(limit.percent) && - limit.scope?.model?.display_name?.trim().toLowerCase() === 'fable' + FABLE_SCOPE_RE.test(limit.scope?.model?.display_name?.trim().toLowerCase() ?? '') ) : undefined return ( diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts index ed4d9f725b0..c0fb5d3aa3d 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts @@ -159,6 +159,48 @@ describe('Claude model switch confirmation detection', () => { await expect(observer.result).resolves.toBe('interaction-required') }) + it('requests interaction for a Fable point-release consent prompt', async () => { + const dataObserver = { current: (_data: string): void => {} } + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5.1', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + // Only the versioned consent line: the generic "pick Fable from /model" + // sentence must not be what carries this case. + dataObserver.current('Fable 5.1 uses usage credits and needs a one-time consent') + + await expect(observer.result).resolves.toBe('interaction-required') + }) + + it('requests interaction for a versioned Fable switch prompt', async () => { + const dataObserver = { current: (_data: string): void => {} } + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5.1', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + dataObserver.current('Switch to \u001b[1mFable 5.1\u001b[0m? This model uses usage credits.') + + await expect(observer.result).resolves.toBe('interaction-required') + }) + it('reports unknown when the PTY observer cannot be established', async () => { const observer = createClaudeModelSwitchConfirmationObserver({ ptyId: 'pty-1', diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts index f9664bacfa5..e97daf9cd23 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -62,12 +62,19 @@ function hasClaudeModelSwitchRejection(buffer: string): boolean { return compactTerminalText(buffer).includes('keptmodelas') } +// Why: compactTerminalText only strips whitespace, so a point release keeps its +// dot ("fable5.1uses..."). The version is optional because the CLI's own label +// for the newest Fable carries no number at all. +const FABLE_VERSION = String.raw`fable(?:\d+(?:\.\d+)*)?` +const FABLE_CONSENT_RE = new RegExp(`${FABLE_VERSION}usesusagecreditsandneedsaone-timeconsent`) +const FABLE_SWITCH_PROMPT_RE = new RegExp(`switchto${FABLE_VERSION}\\?`) + function hasClaudeModelSwitchInteraction(buffer: string): boolean { const text = compactTerminalText(buffer) return ( - text.includes('fable5usesusagecreditsandneedsaone-timeconsent') || + FABLE_CONSENT_RE.test(text) || text.includes('pickfablefrom/modelinaninteractivesessiontosetitup') || - (text.includes('switchtofable5?') && text.includes('usagecredits')) + (FABLE_SWITCH_PROMPT_RE.test(text) && text.includes('usagecredits')) ) } diff --git a/src/shared/claude-model-list-probe.test.ts b/src/shared/claude-model-list-probe.test.ts index 86305b45549..056b41e0a98 100644 --- a/src/shared/claude-model-list-probe.test.ts +++ b/src/shared/claude-model-list-probe.test.ts @@ -103,6 +103,32 @@ describe('parseClaudeModelList', () => { expect(parseClaudeModelList(hostile)).toEqual([]) }) + it('drops the disabled placeholder row the CLI advertises for a model it cannot run', () => { + // Captured from `claude` 2.1.237: Fable 5.1 is announced but gated on 2.1.255+. + const parsed = parseClaudeModelList( + controlResponseLine([ + { value: 'sonnet', displayName: 'Sonnet' }, + { + value: 'cc-update-required-1', + resolvedModel: 'cc-update-required-1', + displayName: 'Fable 5.1 (disabled)', + description: 'Update to 2.1.255+ to use Fable 5.1', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + disabled: true + } + ]) + ) + expect(parsed.map(({ id }) => id)).toEqual(['sonnet']) + }) + + it('keeps a model that reports disabled as anything other than true', () => { + const parsed = parseClaudeModelList( + controlResponseLine([{ value: 'fable', displayName: 'Fable', disabled: false }]) + ) + expect(parsed.map(({ id }) => id)).toEqual(['fable']) + }) + it('ignores effort levels when the model does not declare effort support', () => { const parsed = parseClaudeModelList( controlResponseLine([ diff --git a/src/shared/claude-model-list-probe.ts b/src/shared/claude-model-list-probe.ts index fa953d3da16..9be7f5c3a12 100644 --- a/src/shared/claude-model-list-probe.ts +++ b/src/shared/claude-model-list-probe.ts @@ -53,6 +53,7 @@ type RawListedModel = { supportsEffort?: unknown supportedEffortLevels?: unknown supportsFastMode?: unknown + disabled?: unknown } function toListedModel(value: unknown): ClaudeListedModel | null { @@ -61,7 +62,10 @@ function toListedModel(value: unknown): ClaudeListedModel | null { } const raw = value as RawListedModel const id = typeof raw.value === 'string' ? raw.value.trim() : '' - if (!id) { + // Why: the CLI advertises models it cannot run yet as disabled placeholder + // rows ("Fable 5.1 (disabled)", value `cc-update-required-1`); selecting one + // sends that sentinel straight to `--model`. + if (!id || raw.disabled === true) { return null } const label = typeof raw.displayName === 'string' && raw.displayName.trim() ? raw.displayName : id From 4bc20cb842b784f9202d1095575b7bc92b406a75 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:39:48 -0700 Subject: [PATCH 86/94] fix(wsl): name an explicit Windows cwd for wsl.exe spawns (#17834) * fix(wsl): name an explicit Windows cwd for wsl.exe spawns Removing the worktree Orca was launched from broke every wsl.exe spawn for the rest of the session. The WSL command builders passed `cwd: undefined` meaning "the directory is inside the command" -- but CreateProcessW reads NULL as "inherit the parent's", and the parent's was a \\wsl.localhost path Linux had just deleted. Fixes #16463 * fix(wsl): name the spawn directory at the six remaining wsl.exe sites The first commit fixed the WSL command builders. Six spawn sites were left inheriting the process cwd, which is the same deletable `\\wsl.localhost` worktree: `wsl-availability` (both probes), the WSL filesystem watcher, the agent-hook relay launch, the UNC delete, and the local worktree filesystem. `wsl-availability` is the one that matters most, and it turns the bug into a latching false negative. `isRetryableWslProbeFailure` returns false for ENOENT, so a spawn that failed only because the inherited cwd was gone is cached as "WSL is not installed" on the 10-minute definitive TTL with exponential backoff up to 30 minutes. Git keeps working and Orca reports WSL unavailable -- worse than the bug being fixed. ENOENT stays non-retryable. It is answer-shaped for the reason it is meant to be -- wsl.exe is not on PATH -- and naming the directory is what removes the one cause that was not. Making it retryable would instead re-probe every non-WSL Windows machine on the short window, and would leave the false ENOENT in place for the other five sites, which have no cache to correct. Three of these are also on the `runWslProcess` W3 migration allowlist; this is the interim until they move, and matches what #17837 does inside the runner. --- config/tsconfig.tc.web.json | 1 + .../agent-hooks/wsl-hook-relay-launch.test.ts | 25 ++++++ src/main/agent-hooks/wsl-hook-relay-launch.ts | 7 +- src/main/cli/wsl-cli-installer.test.ts | 8 +- src/main/cli/wsl-cli-scripts.ts | 10 ++- .../command-runner/wsl-command-resolution.ts | 14 +-- src/main/git/runner-command-exec.test.ts | 12 ++- src/main/git/runner-wsl-gh-fallback.test.ts | 17 +++- ...tlab-known-host-probe-wsl-fallback.test.ts | 6 +- src/main/ipc/filesystem-watcher-wsl.test.ts | 6 +- src/main/ipc/filesystem-watcher-wsl.ts | 7 +- src/main/local-worktree-filesystem.test.ts | 6 +- src/main/local-worktree-filesystem.ts | 5 ++ ...e-text-generation-local-subprocess.test.ts | 6 +- ...ge-text-generation-model-discovery.test.ts | 6 +- src/main/wsl-availability.ts | 18 +++- src/main/wsl-interop-spawn-directory.test.ts | 90 +++++++++++++++++++ src/main/wsl-interop-spawn-directory.ts | 70 +++++++++++++++ src/main/wsl-unc-delete.test.ts | 5 +- src/main/wsl-unc-delete.ts | 5 +- src/main/wsl.test.ts | 34 +++++++ src/main/wsl.ts | 21 +++-- .../wsl-probe-failure-swallow-allowlist.txt | 6 ++ 23 files changed, 357 insertions(+), 28 deletions(-) create mode 100644 src/main/agent-hooks/wsl-hook-relay-launch.test.ts create mode 100644 src/main/wsl-interop-spawn-directory.test.ts create mode 100644 src/main/wsl-interop-spawn-directory.ts diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index afe5e83024c..56253527c69 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -31,6 +31,7 @@ "../src/main/wsl-distro-retry.ts", "../src/main/wsl-running-distro-cache.ts", "../src/main/wsl.ts", + "../src/main/wsl-interop-spawn-directory.ts", "../src/main/persistence/applying-settings/ui-state-read.ts", "../src/main/persistence/applying-settings/ui-state-update.ts", "../src/main/persistence/applying-settings/ui-selection-normalization.ts", diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.test.ts b/src/main/agent-hooks/wsl-hook-relay-launch.test.ts new file mode 100644 index 00000000000..6ed9cf2625a --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-launch.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn((..._args: unknown[]) => ({ pid: 1 })) +})) + +vi.mock('node:child_process', () => ({ spawn: spawnMock })) + +import { spawnWslRelayProcess } from './wsl-hook-relay-launch' + +describe('spawnWslRelayProcess', () => { + it('names an explicit Windows directory rather than inheriting one', () => { + spawnWslRelayProcess('Ubuntu', {}, '1.2.3') + + // Why (#16463): the guest path is inside the `sh -c` command, so the Windows + // cwd only decides whether CreateProcessW succeeds. Omitting it inherits + // Orca's own — a `\\wsl.localhost` worktree the user can delete, after which + // every relay launch fails `spawn wsl.exe ENOENT` for the rest of the session. + expect(spawnMock).toHaveBeenCalledWith( + 'wsl.exe', + expect.arrayContaining(['-d', 'Ubuntu', '--exec']), + expect.objectContaining({ cwd: expect.any(String) }) + ) + }) +}) diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index 9f32de10bd5..ae1ea9c20d7 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -16,6 +16,7 @@ import { } from './wsl-hook-relay-sentinel' import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' import { runWslProcess } from '../wsl/wsl-runner' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' import { listRunningWslDistrosAsync } from '../wsl' import { WSL_HOOK_RELAY_BUNDLE_NAME, @@ -137,7 +138,11 @@ export function spawnWslRelayProcess( return spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-c', command], { env, stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true + windowsHide: true, + // Why explicit (#16463): the guest path is in `command`, so the Windows cwd + // only decides whether CreateProcessW succeeds -- and an inherited one is a + // worktree the user can delete, which kills every later relay launch. + cwd: resolveWslInteropSpawnCwd() }) } diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 717232509ba..a728e0acafe 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -340,7 +340,13 @@ describe('WslCliInstaller', () => { expect(bridge).toContain('$ForwardArgs = @($args[$ForwardArgStart..($args.Count - 1)])') expect(bridge).toContain('if ([string]::IsNullOrEmpty($WslCwd))') expect(bridge).toContain('$env:ORCA_CLI_CWD = $WslCwd') - expect(bridge).toContain('Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher)') + expect(bridge).toContain('$LauncherDirectory = Split-Path -Parent $OrcaLauncher') + expect(bridge).toContain('Push-Location -LiteralPath $LauncherDirectory') + // Why (#16463): Push-Location moves only the PowerShell provider location. + // Without an explicit WorkingDirectory the started app inherits the caller's + // Win32 cwd — the user's worktree on \\wsl.localhost — and every wsl.exe + // spawn it makes dies with ENOENT once that worktree is removed. + expect(bridge).toContain('$StartInfo.WorkingDirectory = $LauncherDirectory') expect(bridge).toContain('function ConvertTo-NativeCommandLineArgument') expect(bridge).toContain("[void]$Quoted.Append([char]'\\', $BackslashCount * 2 + 1)") expect(bridge).toContain('$StartInfo.UseShellExecute = $false') diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 8eda355cc93..8875a81d18e 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -88,7 +88,8 @@ try { } else { $env:ORCA_CLI_CWD = $WslCwd } - Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher) + $LauncherDirectory = Split-Path -Parent $OrcaLauncher + Push-Location -LiteralPath $LauncherDirectory # Why: Windows PowerShell 5.1 cannot losslessly splat strings to native argv. $StartInfo = [System.Diagnostics.ProcessStartInfo]::new() $StartInfo.FileName = $OrcaLauncher @@ -96,6 +97,13 @@ try { ConvertTo-NativeCommandLineArgument $_ }) -join ' ') $StartInfo.UseShellExecute = $false + # Why (#16463): Push-Location moves the PowerShell provider location, not the + # Win32 current directory, and an empty WorkingDirectory with UseShellExecute + # disabled means "inherit the caller's". Launched from a WSL shell that is the + # user's worktree on the 9P share, so without this the app stands in a + # directory Linux can delete -- after which every CreateProcessW it makes + # fails ERROR_PATH_NOT_FOUND, reported as: spawn wsl.exe ENOENT. + $StartInfo.WorkingDirectory = $LauncherDirectory $Process = [System.Diagnostics.Process]::Start($StartInfo) if ($null -eq $Process) { throw 'Unable to start the Orca Windows CLI launcher.' diff --git a/src/main/git/command-runner/wsl-command-resolution.ts b/src/main/git/command-runner/wsl-command-resolution.ts index a70c0ca2bc9..559e1821bd1 100644 --- a/src/main/git/command-runner/wsl-command-resolution.ts +++ b/src/main/git/command-runner/wsl-command-resolution.ts @@ -13,6 +13,7 @@ import { type WslProcessGroupTermination } from '../wsl-process-group-termination' import { translateArgForWsl, translateArgsForWsl } from './wsl-path-translation' +import { resolveWslInteropSpawnCwd } from '../../wsl-interop-spawn-directory' // Env-assignment prefix for WSL-routed git, where spawn env can't cross the wsl.exe boundary; values are shell-safe unquoted. const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV) @@ -111,7 +112,7 @@ export function resolveCommand( ...(linuxCwd ? ['-C', linuxCwd] : []), ...translatedArgs ], - cwd: undefined, + cwd: resolveWslInteropSpawnCwd(), wsl, wslMode: 'direct-git' }, @@ -130,7 +131,7 @@ export function resolveCommand( { binary: 'wsl.exe', args: buildWslExecArgs(wsl.distro, ['sh', '-lc', captured.command]), - cwd: undefined, + cwd: resolveWslInteropSpawnCwd(), wsl, wslMode: 'login-shell', captured @@ -142,7 +143,7 @@ export function resolveCommand( { binary: 'wsl.exe', args: buildWslExecArgs(wsl.distro, ['sh', '-lc', buildWslLoginShellCommand(shellCmd)]), - cwd: undefined, + cwd: resolveWslInteropSpawnCwd(), wsl, wslMode: 'login-shell' }, @@ -154,8 +155,11 @@ export function resolveCommand( { binary: 'wsl.exe', args: buildWslExecArgs(wsl.distro, ['bash', '-c', shellCmd]), - // Why: the `cd` inside bash -c handles the directory; a UNC cwd on the Node process is redundant and can break Node internals. - cwd: undefined, + // Why: the `cd` inside bash -c handles the Linux directory. This names an + // explicit Windows directory anyway, because `undefined` makes + // CreateProcessW inherit the parent's — which is a deletable WSL UNC path + // when Orca was launched from a worktree (#16463). + cwd: resolveWslInteropSpawnCwd(), wsl, wslMode: 'non-login-shell' }, diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index 1974c4e2384..27d7a72c747 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -626,7 +626,11 @@ describe('runner execFile timeout handling', () => { expect(execFileMock).toHaveBeenCalledWith( 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'sh', '-lc', expect.any(String)], - expect.objectContaining({ cwd: undefined }), + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. The Linux directory still rides inside the command (/mnt/c/repo, + // asserted below). + expect.objectContaining({ cwd: expect.any(String) }), expect.any(Function) ) // A read also warms the direct-git environment probe in the background, so @@ -659,7 +663,11 @@ describe('runner execFile timeout handling', () => { expect(execFileMock).toHaveBeenCalledWith( 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'bash', '-c', expect.any(String)], - expect.objectContaining({ cwd: undefined }), + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. The Linux directory still rides inside the command (/mnt/c/repo, + // asserted below). + expect.objectContaining({ cwd: expect.any(String) }), expect.any(Function) ) const shellCommand = execFileMock.mock.calls[0]?.[1]?.[5] as string diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 57dd86ba26c..5f933c60620 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -99,7 +99,10 @@ describe('ghExecFileAsync WSL fallback', () => { '-c', "cd '/home/jinwoo/stably/noqa' && 'gh' 'issue' 'list' '--repo' 'stablyhq/noqa' '--json' 'number,title'" ], - expect.objectContaining({ cwd: undefined }), + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. The Linux directory still rides inside the command. + expect.objectContaining({ cwd: expect.any(String) }), expect.any(Function) ) expect(execFileMock).toHaveBeenNthCalledWith( @@ -382,7 +385,11 @@ describe('ghExecFileAsync WSL fallback', () => { 2, 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'gh' 'api' 'rate_limit'"], - expect.objectContaining({ cwd: undefined }), + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. This global call has no repo directory at all, so nothing about + // where it runs changes. + expect.objectContaining({ cwd: expect.any(String) }), expect.any(Function) ) }) @@ -501,7 +508,11 @@ describe('ghExecFileAsync WSL fallback', () => { 2, 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'api' 'projects'"], - expect.objectContaining({ cwd: undefined }), + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. This global call has no repo directory at all, so nothing about + // where it runs changes. + expect.objectContaining({ cwd: expect.any(String) }), expect.any(Function) ) }) diff --git a/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts b/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts index a3fe62c9063..40da88f0c91 100644 --- a/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts +++ b/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts @@ -76,7 +76,11 @@ describe('glab known-hosts probe on Windows', () => { expect(execFileMock).toHaveBeenCalledWith( 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'auth' 'status'"], - expect.objectContaining({ cwd: undefined }), + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. This probe has no repo directory at all, so nothing about where + // it runs changes. The native `glab` assertion above keeps `undefined`. + expect.objectContaining({ cwd: expect.any(String) }), expect.any(Function) ) }) diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 13346047d86..84d8d0a2306 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -95,7 +95,11 @@ describe('createWslWatcher', () => { ['-d', 'Ubuntu', '--exec', 'sh', '-s', '--', '/home/me/repo'], expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true + windowsHide: true, + // Why a concrete directory (#16463): the watched path rides in argv, so + // an omitted cwd only means CreateProcessW inherits Orca's -- a worktree + // that can be deleted, after which every watcher start is ENOENT. + cwd: expect.any(String) }) ) }) diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index 86f10c2ee68..a444113c1cf 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -14,6 +14,7 @@ import { parseWslUncPath } from '../../shared/wsl-paths' import { createWslWatcherProcessExit, createWslWatcherStartup } from './wsl-watcher-process-exit' import { reserveWatcherChild, WatcherChildCapacityError } from './parcel-watcher-child-registry' import { createDebouncedBatch, type DebouncedBatch } from './filesystem-watcher-batch-control' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' export type WatcherSubscription = { unsubscribe(): Promise @@ -244,7 +245,11 @@ export async function createWslWatcher( try { child = spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-s', '--', linuxPath], { stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true + windowsHide: true, + // Why explicit (#16463): the watched directory rides in argv, and an + // inherited cwd is a worktree that can be deleted -- after which every + // watcher start fails `spawn wsl.exe ENOENT`. + cwd: resolveWslInteropSpawnCwd() }) } catch (error) { releaseChildReservation() diff --git a/src/main/local-worktree-filesystem.test.ts b/src/main/local-worktree-filesystem.test.ts index 52b1d16f21a..c9e97f72e90 100644 --- a/src/main/local-worktree-filesystem.test.ts +++ b/src/main/local-worktree-filesystem.test.ts @@ -188,7 +188,11 @@ describe('local worktree filesystem runtime access', () => { 1, expect.objectContaining({ program: 'wsl.exe', - args: expect.arrayContaining(['-d', 'Ubuntu']) + args: expect.arrayContaining(['-d', 'Ubuntu']), + // Why a concrete directory (#16463): the guest path is inside the + // command, and these run while a worktree is being removed -- which is + // the cwd an omitted one would inherit. + cwd: expect.any(String) }) ) const removeArgs = runProcessMock.mock.calls[2]?.[0].args as string[] diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index f5d8714e196..1078b1f50bc 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -3,6 +3,7 @@ import { lstat, readFile } from 'node:fs/promises' import { buildWslExecArgs, quotePosixShell } from '../shared/wsl-login-shell-command' import { removeHostTree } from './host-tree-removal' import { toLinuxPath } from './wsl' +import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' import type { ReadPath, StatPath } from './worktree-orphan-gitdir-proof' export { toHostFilesystemPath, toHostRemovalPath } from './host-tree-removal' @@ -36,6 +37,10 @@ async function runWslCommand(distro: string, command: string): Promise { const result = await runProcess({ program: 'wsl.exe', args: buildWslExecArgs(distro, ['sh', '-c', command]), + // Why explicit (#16463): the guest path is inside `command`, so this only + // decides whether CreateProcessW succeeds -- and these calls run while a + // worktree is being removed, which is the cwd an inherited one would be. + cwd: resolveWslInteropSpawnCwd(), timeoutMs: WSL_FILE_OPERATION_TIMEOUT_MS }) if (result.timedOut) { diff --git a/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts b/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts index fcdbe4719cc..c124cb85779 100644 --- a/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts +++ b/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts @@ -164,7 +164,11 @@ describe('generateCommitMessageFromContext', () => { 'wsl.exe', ['-d', 'Ubuntu 24.04', '--exec', 'sh', '-lc', expect.any(String)], expect.objectContaining({ - cwd: undefined, + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. The Linux directory still rides inside the command (/mnt/c/repo, + // asserted below), so the Windows-side cwd never decides where the agent runs. + cwd: expect.any(String), windowsHide: true, env: expect.objectContaining({ CODEX_HOME: '/home/tester/.codex' }) }) diff --git a/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts b/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts index d54f1d995f6..7ef438c06ae 100644 --- a/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts +++ b/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts @@ -235,7 +235,11 @@ describe('discoverCommitMessageModelsLocal', () => { 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'sh', '-lc', expect.any(String)], expect.objectContaining({ - cwd: undefined, + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. The Linux directory still rides inside the command (/mnt/c/repo, + // asserted below), so the Windows-side cwd never decides where discovery runs. + cwd: expect.any(String), windowsHide: true }) ) diff --git a/src/main/wsl-availability.ts b/src/main/wsl-availability.ts index c44476c9d60..1d14f526413 100644 --- a/src/main/wsl-availability.ts +++ b/src/main/wsl-availability.ts @@ -1,4 +1,5 @@ import { execFile, execFileSync } from 'node:child_process' +import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' type WslAvailabilityCache = | { available: true } @@ -35,6 +36,9 @@ function wslAvailabilityRetryDelayMs(cache: { retryable: boolean; failures: numb return Math.min(base * 2 ** (cache.failures - 1), WSL_AVAILABILITY_MAX_RETRY_DELAY_MS) } +// Why ENOENT stays definitive: it means wsl.exe is not on PATH. It used to also mean +// "the cwd this process inherited was deleted", which is not answer-shaped at all -- +// naming an explicit spawn directory below is what removes that source (#16463). // Why: a non-zero exit (wsl.exe ran and said no) or ENOENT (not installed) is answer-shaped, // so it earns a long window rather than the short one a timeout gets. execFileSync reports the // exit code as `status`, the execFile callback as a numeric `code`; both must count as @@ -95,7 +99,14 @@ function probeWslStatus(): Promise { execFile( 'wsl.exe', ['--status'], - { timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS, windowsHide: true }, + { + timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS, + windowsHide: true, + // Why explicit (#16463): inheriting a cwd the user deleted makes + // CreateProcessW fail ENOENT, which this cache reads as "WSL is not + // installed" and holds on the definitive TTL with backoff. + cwd: resolveWslInteropSpawnCwd() + }, (error: unknown) => { if (error) { reject(error) @@ -129,7 +140,10 @@ export function isWslAvailable(): boolean { try { execFileSync('wsl.exe', ['--status'], { stdio: ['pipe', 'pipe', 'pipe'], - timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS + timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS, + // Same reason as the async twin: they share one cache, so a false ENOENT + // from either poisons both. + cwd: resolveWslInteropSpawnCwd() }) return cacheWslAvailabilityProbeResult(null, startedAtGeneration) } catch (error) { diff --git a/src/main/wsl-interop-spawn-directory.test.ts b/src/main/wsl-interop-spawn-directory.test.ts new file mode 100644 index 00000000000..310e5d3a5ca --- /dev/null +++ b/src/main/wsl-interop-spawn-directory.test.ts @@ -0,0 +1,90 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + resetWslInteropSpawnDirectoryCache, + resolveWslInteropSpawnCwd +} from './wsl-interop-spawn-directory' + +// Regression coverage for #16463 ("Removing the worktree Orca was launched from +// breaks every wsl.exe spawn for the rest of the session"). The WSL command +// builders passed `cwd: undefined` meaning "the directory is inside the +// command", but CreateProcessW reads NULL as "inherit the parent's" — and the +// parent's was a `\\wsl.localhost\...` worktree Linux had just deleted. 1805 of +// 1806 git calls then failed `spawn wsl.exe ENOENT` until the app restarted. + +const createdRoots: string[] = [] + +function makeExistingDirectory(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-wsl-spawn-cwd-')) + createdRoots.push(dir) + return dir +} + +const ENV_KEYS = ['ORCA_USER_DATA_PATH', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH'] as const +const savedEnv = new Map() + +beforeEach(() => { + for (const key of ENV_KEYS) { + savedEnv.set(key, process.env[key]) + delete process.env[key] + } + resetWslInteropSpawnDirectoryCache() +}) + +afterEach(() => { + for (const key of ENV_KEYS) { + const saved = savedEnv.get(key) + if (saved === undefined) { + delete process.env[key] + } else { + process.env[key] = saved + } + } + resetWslInteropSpawnDirectoryCache() + while (createdRoots.length > 0) { + rmSync(createdRoots.pop()!, { recursive: true, force: true }) + } +}) + +describe('resolveWslInteropSpawnCwd', () => { + it('names the app-owned directory first, so no worktree can be the answer', () => { + const userData = makeExistingDirectory() + process.env.ORCA_USER_DATA_PATH = userData + process.env.USERPROFILE = makeExistingDirectory() + + expect(resolveWslInteropSpawnCwd()).toBe(userData) + }) + + it('skips a candidate that does not resolve instead of naming it', () => { + process.env.ORCA_USER_DATA_PATH = join(tmpdir(), 'orca-wsl-spawn-cwd-never-created') + const profile = makeExistingDirectory() + process.env.USERPROFILE = profile + + expect(resolveWslInteropSpawnCwd()).toBe(profile) + }) + + it('always names some directory rather than letting the spawn inherit one', () => { + // Why: inheriting is the failure mode. With no configured candidate at all + // the home directory and system root still stand between a spawn and the + // parent's cwd. + expect(resolveWslInteropSpawnCwd()).toEqual(expect.any(String)) + }) + + it('re-answers after the directory it memoized goes away mid-session', () => { + // This is the incident: the chosen directory was valid when the process + // started and was deleted underneath it hours later. A memo that is never + // re-validated reproduces the original bug one layer up. + const doomed = makeExistingDirectory() + process.env.ORCA_USER_DATA_PATH = doomed + const survivor = makeExistingDirectory() + expect(resolveWslInteropSpawnCwd()).toBe(doomed) + + rmSync(doomed, { recursive: true, force: true }) + process.env.ORCA_USER_DATA_PATH = survivor + + expect(resolveWslInteropSpawnCwd()).toBe(survivor) + }) +}) diff --git a/src/main/wsl-interop-spawn-directory.ts b/src/main/wsl-interop-spawn-directory.ts new file mode 100644 index 00000000000..daa8e08d830 --- /dev/null +++ b/src/main/wsl-interop-spawn-directory.ts @@ -0,0 +1,70 @@ +import { statSync } from 'node:fs' +import { homedir } from 'node:os' + +/** + * A Windows directory that is safe to hand `wsl.exe` as its working directory. + * + * Why this exists (#16463): the WSL command builders set `cwd: undefined`, + * meaning "the directory is already expressed inside the command" — but that is + * not what `undefined` means to `CreateProcessW`. libuv passes NULL for + * `lpCurrentDirectory`, and NULL means *inherit the parent's*. Orca launched by + * `orca-ide` from a WSL shell inherits `\\wsl.localhost\\...\` + * as its Win32 cwd; Linux can delete that directory out from under a Windows + * process across the 9P share, and from then on `CreateProcessW` fails + * `ERROR_PATH_NOT_FOUND` — surfaced by libuv as `spawn wsl.exe ENOENT`, for the + * rest of the process's life, for every repository. + * + * Naming an explicit directory removes the dependency on process-global state + * entirely, so a repaired or unrepaired `process.cwd()` cannot decide whether + * git works. It is never the cwd the command runs in: WSL invocations carry + * their Linux directory in `git -C`, a `cd` inside `bash -c`, or the `sh -c` + * wrapper `withGuestCwd` builds. + */ + +let cachedSpawnCwd: string | null = null + +function isExistingDirectory(path: string | undefined | null): path is string { + if (!path) { + return false + } + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + +/** Test seam: forget the memoized directory so a later probe re-validates. */ +export function resetWslInteropSpawnDirectoryCache(): void { + cachedSpawnCwd = null +} + +export function resolveWslInteropSpawnCwd(): string | undefined { + // Why re-validate: the answer is only useful while it still resolves, and the + // user's profile directory can go away on a roaming/mapped-drive host. + if (isExistingDirectory(cachedSpawnCwd)) { + return cachedSpawnCwd + } + const env = process.env + // Why this order: an app-owned directory first (it outlives every worktree), + // then the user's profile, then the system root as a floor that always exists. + // A root is fine here — nothing scans this directory, it is only the value + // `CreateProcessW` receives for `lpCurrentDirectory`. + const candidates: (string | undefined)[] = [ + env.ORCA_USER_DATA_PATH, + env.USERPROFILE, + env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined, + homedir(), + env.SystemDrive ? `${env.SystemDrive}\\` : 'C:\\' + ] + for (const candidate of candidates) { + if (isExistingDirectory(candidate)) { + cachedSpawnCwd = candidate + return candidate + } + } + cachedSpawnCwd = null + // Why undefined rather than a guess: inheriting is still better than naming a + // directory we just proved does not exist. + return undefined +} diff --git a/src/main/wsl-unc-delete.test.ts b/src/main/wsl-unc-delete.test.ts index 4ca6c43312c..a902b4b6d53 100644 --- a/src/main/wsl-unc-delete.test.ts +++ b/src/main/wsl-unc-delete.test.ts @@ -50,8 +50,11 @@ describe('tryDeleteWslUncPath', () => { }) expect(execFileMock).toHaveBeenCalledTimes(1) - const [binary, spawnArgs] = execFileMock.mock.calls[0] + const [binary, spawnArgs, spawnOptions] = execFileMock.mock.calls[0] expect(binary).toBe('wsl.exe') + // Why a concrete directory (#16463): this deletes worktrees, so the cwd it + // would otherwise inherit is the very directory about to disappear. + expect(spawnOptions).toEqual(expect.objectContaining({ cwd: expect.any(String) })) expect(spawnArgs).toEqual([ '-d', 'Ubuntu', diff --git a/src/main/wsl-unc-delete.ts b/src/main/wsl-unc-delete.ts index 9cc62c9b236..b094a152beb 100644 --- a/src/main/wsl-unc-delete.ts +++ b/src/main/wsl-unc-delete.ts @@ -1,5 +1,6 @@ import { execFile } from 'node:child_process' import { parseWslPath } from './wsl' +import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' import { containedDeleteCommand, rejectionFromWslDeleteStderr, @@ -69,7 +70,9 @@ function execFileWsl(distro: string, command: string[]): Promise { ['-d', distro, '--exec', ...command], // Why: a generous bound so deleting a large directory tree on the WSL fs // doesn't abort mid-delete, while still capping a wedged wsl.exe. - { encoding: 'utf-8', timeout: 30000 }, + // Why an explicit cwd (#16463): the target rides in argv, and this deletes + // worktrees -- so an inherited cwd is exactly the directory about to go. + { encoding: 'utf-8', timeout: 30000, cwd: resolveWslInteropSpawnCwd() }, (error, _stdout, stderr) => { if (error) { reject(wslDeleteError(error, stderr)) diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index bc3498b1145..6ef8adbbb61 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -392,6 +392,40 @@ describe('WSL availability cache', () => { }) }) + // Why this site matters more than the other wsl.exe spawns (#16463): ENOENT is + // deliberately non-retryable here, so a spawn that failed only because the + // inherited cwd had been deleted was cached as "WSL is not installed" on the + // 10-minute definitive TTL with exponential backoff. Git kept working and Orca + // reported WSL unavailable -- a worse state than the bug being fixed. Naming + // the directory is what keeps ENOENT meaning "wsl.exe is not on PATH". + it('names an explicit spawn directory on both probes, so no deleted cwd can read as ENOENT', async () => { + execFileSyncMock.mockReturnValueOnce('') + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(null, '', '') + }) + + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(true) + }) + expect(execFileSyncMock).toHaveBeenCalledWith( + 'wsl.exe', + ['--status'], + expect.objectContaining({ cwd: expect.any(String) }) + ) + + // The two probes share one cache, so a false ENOENT from either poisons both. + _resetWslCachesForTests() + await withPlatformAsync('win32', async () => { + await expect(isWslAvailableAsync()).resolves.toBe(true) + }) + expect(execFileMock).toHaveBeenCalledWith( + 'wsl.exe', + ['--status'], + expect.objectContaining({ cwd: expect.any(String) }), + expect.any(Function) + ) + }) + it('shares one wsl.exe spawn between concurrent async probes', async () => { execFileMock.mockImplementation((_command, _args, _options, callback) => { setTimeout(() => callback(null, '', ''), 0) diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 59e74a7f4df..579031de934 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -7,6 +7,7 @@ import { _setWslAvailabilityCacheForTests, dropStaleWslAvailabilityFailure } from './wsl-availability' +import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' import { _resetRunningWslDistroCacheForTests, resolveRunningWslDistros @@ -76,7 +77,8 @@ export function wslUncDirectoryExists(uncPath: string): boolean | null { const stdout = execFileSync('wsl.exe', getWslDirectoryProbeArgs(info), { stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000, - encoding: 'utf8' + encoding: 'utf8', + cwd: resolveWslInteropSpawnCwd() }) return parseWslDirectoryProbeOutput(stdout) } catch { @@ -93,7 +95,8 @@ export function wslUncDirectoryExistsAsync(uncPath: string): Promise { - execFile('wsl.exe', getWslDirectoryProbeArgs(info), { timeout: 5000 }, (_error, stdout) => { + const probeOpts = { timeout: 5000, cwd: resolveWslInteropSpawnCwd() } + execFile('wsl.exe', getWslDirectoryProbeArgs(info), probeOpts, (_error, stdout) => { // Why: wsl.exe uses numeric exits for both guest results and host failures; only the guest marker is authoritative. resolve(parseWslDirectoryProbeOutput(stdout)) }) @@ -181,7 +184,8 @@ export function listWslDistros(): string[] { const output = execFileSync('wsl.exe', ['--list', '--quiet'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 + timeout: 5000, + cwd: resolveWslInteropSpawnCwd() }) return cacheWslDistroList(parseWslDistros(output), probeSequence) } catch { @@ -283,7 +287,8 @@ export function getWslHome(distro: string): string | null { const home = execFileSync('wsl.exe', ['-d', distro, '--exec', 'bash', '-c', 'echo $HOME'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 + timeout: 5000, + cwd: resolveWslInteropSpawnCwd() }).trim() if (!home || !home.startsWith('/')) { @@ -382,7 +387,13 @@ function execFileUtf8(command: string, args: string[], env?: NodeJS.ProcessEnv): execFile( command, args, - { encoding: 'utf-8', env, timeout: 5000, windowsHide: true }, + { + encoding: 'utf-8', + env, + timeout: 5000, + windowsHide: true, + cwd: resolveWslInteropSpawnCwd() + }, (error, stdout) => { if (error) { reject(error) diff --git a/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt b/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt index db6392e21f3..f0a4c4f1082 100644 --- a/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt @@ -23,3 +23,9 @@ main/ipc/preflight-command-exec.ts main/ipc/preflight-test-harness.ts main/ipc/preflight-wsl-agent-detection.ts main/wsl.ts +# Scanned only because the filename starts with `wsl`; it answers nothing about a +# distro. The swallow is a `statSync` on a LOCAL WINDOWS directory, and only the +# positive answer is memoized -- and re-validated on every call, which is the +# point of the module (#16463). A failed stat drops to the next candidate for +# that one call and is re-asked on the next, so there is no value to pin. +main/wsl-interop-spawn-directory.ts From 81972689560b8af2759a9b67ac6f1c8029809b90 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:23:45 -0700 Subject: [PATCH 87/94] fix(pty,remote): close the pty master fd leak, and two remote-terminal defects (#17914) * fix(pty,remote): close the pty master fd leak and two remote-terminal defects so on Linux every later child of the process -- both later pty children and plain child_process spawns -- inherits it and keeps the /dev/pts device alive. Measured on Linux with stock node-pty 1.1.0: master fd flags 0404002 (cloexec=false), and 17 -> /dev/pts/ptmx present in both a later pty child's /proc/self/fd and a later child_process child's. Extend the existing node-pty patch with pty_cloexec() on both PtyFork spawn paths; after the patch the flags read 02404002 (cloexec=true) and neither child sees the master. This covers the app and terminal daemon only -- the SSH relay installs node-pty from npm on the remote host, so it stays exposed (see the report). rejecting inspection as a renderer-global unhandledrejection, which an unreachable runtime produced on every cadence tick. path cleared the close intent for it exactly like a dropped connection, so a host that keeps republishing the dead surface re-materialized the pane the user just closed. Keep that intent and drop its TTL. Also route the banner's "Remote terminal was closed." line through translate() so it stops mixing English into a localized banner. * test(pty,remote): make the fd-leak evidence positive and size the close intent to its RPC The Linux 'does not hand an earlier pty master to a later pty child' case only asserted that ptmx was absent from the captured listing, so any run that produced no listing passed without inspecting a single fd. Block the child on stdin, emit a sentinel, and assert both the sentinel and a real /dev/pts fd row before the negative assertion. Verified in node:24-bookworm: passes with the patch, and with pty_cloexec() reverted it fails on four inherited /dev/pts/ptmx rows. The close intent's TTL was a 10s literal while the close RPC that can still answer tab_not_found had its own 15s literal. A host that answered slowly while republishing the surface had its intent evicted by the republish path's own pending-check, so makeWebSessionCloseIntentDurable found nothing to flip and #9194 reproduced. Derive the TTL from the shared session.tabs RPC timeout so the two cannot cross, with an invariant test and a regression test for the slow answer. --- config/patches/node-pty@1.1.0.patch | 61 +++++++++- pnpm-lock.yaml | 6 +- src/main/daemon/node-pty-fd-leak.test.ts | 107 +++++++++++++++++- .../terminal-pane/TerminalErrorToast.tsx | 12 ++ ...ection-queue-rejection-containment.test.ts | 72 ++++++++++++ .../agent-process-inspection-queue.ts | 18 ++- ...l-error-remote-closed-localization.test.ts | 24 ++++ src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- ...runtime-session-tab-activate-close.test.ts | 79 +++++++++++++ .../web-runtime-session-tab-lifecycle.ts | 21 +++- .../runtime/web-session-close-intent.test.ts | 19 +++- .../src/runtime/web-session-close-intent.ts | 37 +++++- .../runtime/web-session-tab-rpc-timeout.ts | 5 + 17 files changed, 445 insertions(+), 31 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/agent-process-inspection-queue-rejection-containment.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-error-remote-closed-localization.test.ts create mode 100644 src/renderer/src/runtime/web-session-tab-rpc-timeout.ts diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index 9ee2ebd39b4..348ce6ef7ce 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -176,7 +176,7 @@ index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd process.send!({ consoleProcessList }); process.exit(0); diff --git a/src/unix/pty.cc b/src/unix/pty.cc -index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d15c4dd44 100644 +index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..2ae787c5bd4f3eba470584dc658a01a52c690e0a 100644 --- a/src/unix/pty.cc +++ b/src/unix/pty.cc @@ -23,7 +23,9 @@ @@ -215,7 +215,17 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d /* Some platforms name VWERASE and VDISCARD differently */ #if !defined(VWERASE) && defined(VWERSE) #define VWERASE VWERSE -@@ -237,13 +258,23 @@ pty_getproc(int, char *); +@@ -228,6 +249,9 @@ Napi::Value PtyGetProc(const Napi::CallbackInfo& info); + static int + pty_nonblock(int); + ++static int ++pty_cloexec(int); ++ + #if defined(__APPLE__) + static char * + pty_getproc(int); +@@ -237,13 +261,23 @@ pty_getproc(int, char *); #endif #if defined(__APPLE__) || defined(__OpenBSD__) @@ -240,7 +250,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d #endif struct DelBuf { -@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { +@@ -367,14 +401,18 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { argv[i + 3] = strdup(arg.c_str()); } @@ -256,7 +266,48 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d } if (pty_nonblock(master) == -1) { throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); -@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) { + } ++ if (pty_cloexec(master) == -1) { ++ throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec."); ++ } + #else + int argc = argv_.Length(); + int argl = argc + 2; +@@ -445,6 +483,9 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } ++ if (pty_cloexec(master) == -1) { ++ throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec."); ++ } + } + #endif + +@@ -586,6 +627,23 @@ pty_nonblock(int fd) { + return fcntl(fd, F_SETFL, flags | O_NONBLOCK); + } + ++/** ++ * Orca: close-on-exec FD ++ * ++ * forkpty()/posix_openpt() have no atomic O_CLOEXEC, so a master left without ++ * FD_CLOEXEC is inherited by every later child of this process -- including ++ * later pty children -- which keeps its /dev/pts device and buffers alive long ++ * after its own session ends (#8362). ++ */ ++ ++static int ++pty_cloexec(int fd) { ++ int flags = fcntl(fd, F_GETFD); ++ if (flags == -1) return -1; ++ if (flags & FD_CLOEXEC) return 0; ++ return fcntl(fd, F_SETFD, flags | FD_CLOEXEC); ++} ++ + /** + * pty_getproc + * Taken from tmux. +@@ -684,15 +742,73 @@ pty_getproc(int fd, char *tty) { #endif #if defined(__APPLE__) @@ -332,7 +383,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d for (; count < 3; count++) { low_fds[count] = posix_openpt(O_RDWR); -@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env, +@@ -706,80 +822,118 @@ pty_posix_spawn(char** argv, char** env, POSIX_SPAWN_SETSID; *master = posix_openpt(O_RDWR); if (*master == -1) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b5b4c054a0..1abec6c0590 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,7 +115,7 @@ patchedDependencies: '@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e '@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 - node-pty@1.1.0: 572a46f539dd9da26e259702da974e1e693329e299625c97eb7c28e4e642500e + node-pty@1.1.0: 40b6b6b814c89a8a29c995495ea86cff2cf6766f42124b5702aedf4ce0565c0e importers: @@ -156,7 +156,7 @@ importers: version: 3.3.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=572a46f539dd9da26e259702da974e1e693329e299625c97eb7c28e4e642500e) + version: 1.1.0(patch_hash=40b6b6b814c89a8a29c995495ea86cff2cf6766f42124b5702aedf4ce0565c0e) posthog-node: specifier: ^5.33.3 version: 5.33.3 @@ -12194,7 +12194,7 @@ snapshots: node-int64@0.4.0: {} - node-pty@1.1.0(patch_hash=572a46f539dd9da26e259702da974e1e693329e299625c97eb7c28e4e642500e): + node-pty@1.1.0(patch_hash=40b6b6b814c89a8a29c995495ea86cff2cf6766f42124b5702aedf4ce0565c0e): dependencies: node-addon-api: 7.1.1 diff --git a/src/main/daemon/node-pty-fd-leak.test.ts b/src/main/daemon/node-pty-fd-leak.test.ts index 91958b49975..f021f7d48df 100644 --- a/src/main/daemon/node-pty-fd-leak.test.ts +++ b/src/main/daemon/node-pty-fd-leak.test.ts @@ -1,5 +1,6 @@ -import { execFileSync } from 'node:child_process' -import { existsSync, renameSync } from 'node:fs' +import { execFileSync, spawn } from 'node:child_process' +import { once } from 'node:events' +import { existsSync, readdirSync, readFileSync, readlinkSync, renameSync } from 'node:fs' import { setTimeout as delay } from 'node:timers/promises' import * as pty from 'node-pty' import { describe, expect, it } from 'vitest' @@ -90,3 +91,105 @@ describeOnDarwin('node-pty macOS spawn fd handling', () => { expect(after - before).toBe(0) }, 15000) }) + +// Linux is the only platform where node-pty takes the forkpty() path, which has no atomic +// O_CLOEXEC. /proc is what makes the inheritance observable, so the assertions live here. +const describeOnLinux = process.platform === 'linux' ? describe : describe.skip + +const O_CLOEXEC = 0o2000000 + +const LISTING_READY = '__fd_listing_ready__' + +function ptyMasterFd(term: pty.IPty): number { + return (term as unknown as { fd: number }).fd +} + +function isCloseOnExec(fd: number): boolean { + const flags = /flags:\s*(\d+)/.exec(readFileSync(`/proc/self/fdinfo/${fd}`, 'utf8')) + expect(flags).toBeTruthy() + return (Number.parseInt(flags![1]!, 8) & O_CLOEXEC) !== 0 +} + +function openFdTargets(pid: number): string[] { + return readdirSync(`/proc/${pid}/fd`).map((entry) => { + try { + return readlinkSync(`/proc/${pid}/fd/${entry}`) + } catch { + return '' + } + }) +} + +describeOnLinux('node-pty Linux forkpty fd handling', () => { + it('marks pty masters close-on-exec so later children cannot inherit them', async () => { + const terms: pty.IPty[] = [] + let child: ReturnType | null = null + try { + for (let i = 0; i < 3; i++) { + terms.push( + pty.spawn('/bin/sh', ['-c', 'sleep 30'], { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd: process.cwd(), + env: { ...process.env, ORCA_FD_LEAK_TEST_INDEX: String(i) } + }) + ) + } + + // The masters this process owns must not survive an exec in any child it forks later. + expect(terms.map((term) => isCloseOnExec(ptyMasterFd(term)))).toEqual([true, true, true]) + + child = spawn('/bin/sh', ['-c', 'sleep 5'], { stdio: 'ignore' }) + await once(child, 'spawn') + const inherited = openFdTargets(child.pid!).filter((target) => target.includes('ptmx')) + expect(inherited).toEqual([]) + } finally { + child?.kill() + for (const term of terms) { + term.kill() + } + } + }, 15000) + + it('does not hand an earlier pty master to a later pty child', async () => { + const first = pty.spawn('/bin/sh', ['-c', 'sleep 30'], { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd: process.cwd(), + env: { ...process.env } + }) + try { + // Why the read: the child must not be able to run its listing before onData is armed, or an + // empty capture would satisfy the negative assertion without inspecting a single fd. + const second = pty.spawn( + '/bin/sh', + ['-c', `IFS= read -r _; printf '${LISTING_READY}\\n'; ls -l /proc/self/fd; exit 0`], + { + name: 'xterm-256color', + cols: 200, + rows: 24, + cwd: process.cwd(), + env: { ...process.env } + } + ) + let output = '' + second.onData((data) => { + output += data + }) + second.write('go\n') + await new Promise((resolve) => { + second.onExit(() => resolve()) + }) + await delay(100) + + // The listing is the evidence; assert it arrived before reading anything into its absence. + expect(output).toContain(LISTING_READY) + expect(output).toMatch(/\d+ -> \/dev\/pts\//) + expect(output).not.toMatch(/ptmx/) + } finally { + first.kill() + } + }, 15000) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index 11543b96ccc..dcb838007b9 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -19,6 +19,10 @@ const STALE_DAEMON_CWD_MARKERS = [ ] // Thrown by ipc/pty.ts when a persisted pane owner can't be proven alive or dead (STA-3536). const PANE_OWNER_UNVERIFIED_MARKER = 'terminal_pane_owner_unverified' +// remote-runtime-pty-transport.ts surfaces this English literal as a wire-level marker, so it is +// translated here rather than at the source -- otherwise the banner mixes English with the +// localized chrome around it (#9194). +const REMOTE_TERMINAL_CLOSED_MARKER = 'Remote terminal was closed.' // Why one source: the test and replace forms must match the same token, and a lone /g regex carries // lastIndex state across .test() calls. Capture the leading boundary so replacement can restore it. const TERMINAL_HOST_GONE_SOURCE = '(^|[^a-z0-9_])terminal_host_gone(?=$|[^a-z0-9_])' @@ -127,6 +131,14 @@ export function humanizeTerminalError(error: string): string { 'Reconnecting this terminal — its output is being re-established. The session is still running.' ) ) + if (humanized.includes(REMOTE_TERMINAL_CLOSED_MARKER)) { + humanized = humanized.replaceAll(REMOTE_TERMINAL_CLOSED_MARKER, () => + translate( + 'auto.components.terminal.pane.TerminalErrorToast.remoteTerminalClosed', + 'Remote terminal was closed.' + ) + ) + } humanized = humanizeUnreattachableSession(humanized) if (!isExplainedTerminalError(humanized)) { return humanized diff --git a/src/renderer/src/components/terminal-pane/agent-process-inspection-queue-rejection-containment.test.ts b/src/renderer/src/components/terminal-pane/agent-process-inspection-queue-rejection-containment.test.ts new file mode 100644 index 00000000000..639c457016f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-process-inspection-queue-rejection-containment.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + enqueueAgentProcessInspection, + resetAgentProcessInspectionQueueForTests +} from './agent-process-inspection-queue' + +// Node emits 'unhandledRejection' a turn after the microtask queue drains. +async function settleRejections(): Promise { + for (let index = 0; index < 4; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +async function collectUnhandledRejections(run: () => Promise): Promise { + const unhandled: unknown[] = [] + const onUnhandledRejection = (reason: unknown): void => { + unhandled.push(reason) + } + process.on('unhandledRejection', onUnhandledRejection) + try { + await run() + } finally { + process.off('unhandledRejection', onUnhandledRejection) + } + return unhandled +} + +describe('agent process inspection queue rejection containment', () => { + afterEach(() => { + resetAgentProcessInspectionQueueForTests() + }) + + it('contains an unreachable-runtime inspection failure instead of raising unhandledrejection', async () => { + const unhandled = await collectUnhandledRejections(async () => { + enqueueAgentProcessInspection({ + priority: 'cadence', + canRun: () => true, + run: () => + Promise.reject( + new Error( + "Error invoking remote method 'runtimeEnvironments:call': RemoteRuntimeClientError: Could not connect to the remote Orca runtime." + ) + ) + }) + await settleRejections() + }) + + expect(unhandled).toEqual([]) + }) + + it('keeps draining the queue after a rejecting inspection', async () => { + const ran: string[] = [] + const unhandled = await collectUnhandledRejections(async () => { + enqueueAgentProcessInspection({ + priority: 'cadence', + canRun: () => true, + run: () => Promise.reject(new Error('unreachable')) + }) + enqueueAgentProcessInspection({ + priority: 'cadence', + canRun: () => true, + run: async () => { + ran.push('second') + } + }) + await settleRejections() + }) + + expect(unhandled).toEqual([]) + expect(ran).toEqual(['second']) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts b/src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts index f7512ad8c60..6dc1dc4ccf2 100644 --- a/src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts +++ b/src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts @@ -63,12 +63,18 @@ function pumpInspectionQueue(): void { activeInspections += 1 inspectionStarts.push(now) - void next.run().finally(() => { - activeInspections = Math.max(0, activeInspections - 1) - if (inspectionQueue.length > 0) { - scheduleInspectionPump() - } - }) + // Why the catch before finally: an unreachable runtime rejects the inspection on a cadence, and a + // bare `.finally()` chain re-raises it as a renderer-global unhandledrejection. Coordinators own + // their own failure/backoff state, so the queue only has to keep its accounting running. + void next + .run() + .catch(() => {}) + .finally(() => { + activeInspections = Math.max(0, activeInspections - 1) + if (inspectionQueue.length > 0) { + scheduleInspectionPump() + } + }) if (inspectionQueue.length > 0) { scheduleInspectionPump() diff --git a/src/renderer/src/components/terminal-pane/terminal-error-remote-closed-localization.test.ts b/src/renderer/src/components/terminal-pane/terminal-error-remote-closed-localization.test.ts new file mode 100644 index 00000000000..12ff0dbda82 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-error-remote-closed-localization.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' + +// Why a locale stand-in: the banner's own chrome is already translated, so the only way to see the +// mixed-language regression (#9194) is to render the message through a non-English catalog. +vi.mock('@/i18n/i18n', () => ({ + translate: (key: string, fallback: string) => + key === 'auto.components.terminal.pane.TerminalErrorToast.remoteTerminalClosed' + ? '远程终端已关闭。' + : fallback +})) + +import { humanizeTerminalError } from './TerminalErrorToast' + +describe('remote-closed terminal banner localization', () => { + it('translates the remote-closed line instead of pinning it to English', () => { + expect(humanizeTerminalError('Remote terminal was closed.')).toBe('远程终端已关闭。') + }) + + it('translates the line when it is accumulated with other errors', () => { + expect(humanizeTerminalError('Paste failed.\nRemote terminal was closed.')).toBe( + 'Paste failed.\n远程终端已关闭。' + ) + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 99805af4c89..c7a66f2decc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3088,7 +3088,8 @@ "42b283ecfc": "Orca couldn't safely reconnect this terminal because the host couldn't verify its saved session. Orca left the saved session unchanged. Click Retry to try reconnecting now. If it still cannot reconnect, open a new terminal.", "e16012e31e": "The terminal daemon that owned this session exited, so the session and its scrollback could not be recovered. Open a new terminal to continue.", "sessionUnavailable": "Orca couldn't reattach to this pane's terminal session on the host. Open a new terminal to continue.", - "sourceRestoring": "Reconnecting this terminal — its output is being re-established. The session is still running." + "sourceRestoring": "Reconnecting this terminal — its output is being re-established. The session is still running.", + "remoteTerminalClosed": "Remote terminal was closed." }, "TerminalProcessExitOverlay": { "capacityTitle": "Git Bash console limit reached", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index be6e087060d..e55defa7518 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2741,7 +2741,8 @@ "e4aa243f8c": "Reiniciar servicio", "a7e2fd2699": "abre un issue", "5c8ce20be6": "Si esto persiste, por favor", - "cc6d997c65": "Reinicia el servicio del terminal desde aquí para borrar el estado obsoleto." + "cc6d997c65": "Reinicia el servicio del terminal desde aquí para borrar el estado obsoleto.", + "remoteTerminalClosed": "La terminal remota se cerró." }, "TerminalProcessExitOverlay": { "capacityTitle": "Se alcanzó el límite de consolas de Git Bash", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 20e2fca6553..5d97966fca0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2741,7 +2741,8 @@ "e4aa243f8c": "デーモンを再起動します", "a7e2fd2699": "Issue を登録", "5c8ce20be6": "この状態が続く場合は、", - "cc6d997c65": "ここからターミナルデーモンを再起動して、古いデーモン状態をクリアします。" + "cc6d997c65": "ここからターミナルデーモンを再起動して、古いデーモン状態をクリアします。", + "remoteTerminalClosed": "リモートターミナルが閉じられました。" }, "TerminalProcessExitOverlay": { "capacityTitle": "Git Bash のコンソール上限に達しました", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e9b9c48fcdf..795d359bce0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2746,7 +2746,8 @@ "e4aa243f8c": "데몬 재시작", "a7e2fd2699": "이슈 등록", "5c8ce20be6": "문제가 계속되면", - "cc6d997c65": "오래된 데몬 상태를 지우려면 여기에서 terminal 데몬을 다시 시작하세요." + "cc6d997c65": "오래된 데몬 상태를 지우려면 여기에서 terminal 데몬을 다시 시작하세요.", + "remoteTerminalClosed": "원격 터미널이 종료되었습니다." }, "TerminalProcessExitOverlay": { "capacityTitle": "Git Bash 콘솔 한도에 도달했습니다", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 49560989924..3aa8333b872 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2756,7 +2756,8 @@ "e4aa243f8c": "重新启动守护进程", "a7e2fd2699": "提交议题", "5c8ce20be6": "如果这种情况持续存在,请", - "cc6d997c65": "从此处重新启动终端守护进程以清除失效的守护进程状态。" + "cc6d997c65": "从此处重新启动终端守护进程以清除失效的守护进程状态。", + "remoteTerminalClosed": "远程终端已关闭。" }, "TerminalProcessExitOverlay": { "capacityTitle": "已达到 Git Bash 控制台上限", diff --git a/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts index 6ed42c3c841..b535bd31836 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts @@ -9,6 +9,8 @@ import { recordWebSessionCloseIntent, resetWebSessionCloseIntentForTests } from './web-session-close-intent' +import { WEB_SESSION_TAB_RPC_TIMEOUT_MS } from './web-session-tab-rpc-timeout' +import { toHostSessionTabId } from './web-terminal-surface-id' import { ENVIRONMENT_ID, WORKTREE_ID, makeSnapshot } from './web-runtime-session-test-harness' const mocks = vi.hoisted(() => ({ @@ -305,6 +307,83 @@ describe('web runtime session tab actions', () => { ).resolves.toBe(outcome) }) + // #9194: a host can answer tab_not_found and still keep republishing the surface. The close + // intent is what hides the mirror, so letting it age out handed the user back a phantom pane + // whose handle is already gone -- and closing it again just restarted the same TTL loop. + it.each([ + ['tab_not_found', true], + ['runtime_rpc_timeout', false] + ])('keeps a %s close suppressed past the close-intent TTL: %s', async (code, stillPending) => { + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ id: 'close', ok: false, error: { code, message: code } }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'user' + }) + + const hostTabId = toHostSessionTabId('local-browser-unified') + expect( + isWebSessionCloseIntentPending( + { environmentId: ENVIRONMENT_ID }, + WORKTREE_ID, + hostTabId, + Date.now() + 60_000 + ) + ).toBe(stillPending) + }) + + // #9194, slow host: the close RPC can answer `tab_not_found` at any point up to its own timeout, + // and a host that still republishes the surface keeps querying the intent in the meantime. That + // query is what evicts an expired entry, so a TTL under the RPC timeout leaves nothing for the + // durable flip to reach and the pane the user closed comes back. + it('keeps a tab_not_found close suppressed when the host answers slower than the old TTL', async () => { + const startedAt = 1_700_000_000_000 + let clock = startedAt + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + const owner = { environmentId: ENVIRONMENT_ID } + const hostTabIds = [toHostSessionTabId('local-browser-unified'), 'host-browser-unified'] + let pendingWhileHostRepublished: boolean[] = [] + try { + const runtimeCall = vi + .fn() + .mockImplementationOnce(() => { + clock = startedAt + WEB_SESSION_TAB_RPC_TIMEOUT_MS - 1 + pendingWhileHostRepublished = hostTabIds.map((hostTabId) => + isWebSessionCloseIntentPending(owner, WORKTREE_ID, hostTabId, clock) + ) + return Promise.resolve({ + id: 'close', + ok: false, + error: { code: 'tab_not_found', message: 'tab_not_found' } + }) + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'user' + }) + ).resolves.toBe('unknown-tab') + + expect(pendingWhileHostRepublished).toEqual([true, true]) + expect( + hostTabIds.map((hostTabId) => + isWebSessionCloseIntentPending(owner, WORKTREE_ID, hostTabId, clock + 60_000) + ) + ).toEqual([true, true]) + } finally { + nowSpy.mockRestore() + } + }) + it('fails closed when reconnect routes a lifecycle close to an older host', async () => { const runtimeCall = vi .fn() diff --git a/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts b/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts index a7381f10d6a..56800f3cefb 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts @@ -6,11 +6,16 @@ import type { import { useAppStore } from '../store' import { hasRuntimeRpcErrorCode, unwrapRuntimeRpcResult } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' -import { clearWebSessionCloseIntent, recordWebSessionCloseIntent } from './web-session-close-intent' +import { + clearWebSessionCloseIntent, + makeWebSessionCloseIntentDurable, + recordWebSessionCloseIntent +} from './web-session-close-intent' import { clearWebSessionFocusIntentIfMatches, recordWebSessionFocusIntent } from './web-session-focus-intent' +import { WEB_SESSION_TAB_RPC_TIMEOUT_MS } from './web-session-tab-rpc-timeout' import { toHostSessionTabId } from './web-terminal-surface-id' import { captureRuntimeEnvironmentCall, @@ -134,7 +139,7 @@ async function callWebRuntimeSessionTabMethod( ? { reason: args.reason } : {}) }, - timeoutMs: 15_000 + timeoutMs: WEB_SESSION_TAB_RPC_TIMEOUT_MS }) const result = unwrapRuntimeRpcResult( response as RuntimeRpcResponse @@ -157,8 +162,16 @@ async function callWebRuntimeSessionTabMethod( if (activationHostTabId) { clearWebSessionFocusIntentIfMatches(intentOwner, args.worktreeId, activationHostTabId) } + // Why the split: only 'tab_not_found' is absence proof (see the outcome doc above). Restoring the + // mirror on it hands the user back a pane the host cannot close and whose handle is already gone + // (#9194), so keep the suppression and drop its TTL instead. Every other failure is a "not now". + const hostHasNoSuchTab = hasRuntimeRpcErrorCode(error, 'tab_not_found') for (const hostTabId of closeIntentTabIds) { - clearWebSessionCloseIntent(intentOwner, args.worktreeId, hostTabId) + if (hostHasNoSuchTab) { + makeWebSessionCloseIntentDurable(intentOwner, args.worktreeId, hostTabId) + } else { + clearWebSessionCloseIntent(intentOwner, args.worktreeId, hostTabId) + } } if (isLifecycleClose) { const { acceptReplayedWebSessionTabsSnapshot } = await import('./web-session-tabs-sync') @@ -171,6 +184,6 @@ async function callWebRuntimeSessionTabMethod( `[web-runtime-session] failed to ${isClose ? 'close' : 'activate'} tab:`, error instanceof Error ? error.message : String(error) ) - return hasRuntimeRpcErrorCode(error, 'tab_not_found') ? 'unknown-tab' : 'failed' + return hostHasNoSuchTab ? 'unknown-tab' : 'failed' } } diff --git a/src/renderer/src/runtime/web-session-close-intent.test.ts b/src/renderer/src/runtime/web-session-close-intent.test.ts index 92ab8dbca96..418a8544840 100644 --- a/src/renderer/src/runtime/web-session-close-intent.test.ts +++ b/src/renderer/src/runtime/web-session-close-intent.test.ts @@ -6,8 +6,10 @@ import { isWebSessionCloseIntentPending, reconcileWebSessionCloseIntents, recordWebSessionCloseIntent, - resetWebSessionCloseIntentForTests + resetWebSessionCloseIntentForTests, + WEB_SESSION_CLOSE_INTENT_TTL_MS } from './web-session-close-intent' +import { WEB_SESSION_TAB_RPC_TIMEOUT_MS } from './web-session-tab-rpc-timeout' const WT = 'repo::/wt' const OWNER = { environmentId: 'runtime-a', pairingRevision: 1 } @@ -26,7 +28,20 @@ describe('web session close intent', () => { it('expires a never-confirmed close', () => { recordWebSessionCloseIntent(OWNER, WT, 'host-tab-1', 1000) - expect(isWebSessionCloseIntentPending(OWNER, WT, 'host-tab-1', 12_000)).toBe(false) + expect( + isWebSessionCloseIntentPending( + OWNER, + WT, + 'host-tab-1', + 1000 + WEB_SESSION_CLOSE_INTENT_TTL_MS + 1 + ) + ).toBe(false) + }) + + // The durable flip in the tab_not_found path can only reach an entry that is still there, so the + // TTL must outlast the RPC that produces that answer. Two independent literals would drift. + it('outlives the tab RPC that can still answer tab_not_found', () => { + expect(WEB_SESSION_CLOSE_INTENT_TTL_MS).toBeGreaterThan(WEB_SESSION_TAB_RPC_TIMEOUT_MS) }) it('scopes intents by owner, pairing revision, and worktree', () => { diff --git a/src/renderer/src/runtime/web-session-close-intent.ts b/src/renderer/src/runtime/web-session-close-intent.ts index 012dfd81d26..5a80fdf87f3 100644 --- a/src/renderer/src/runtime/web-session-close-intent.ts +++ b/src/renderer/src/runtime/web-session-close-intent.ts @@ -1,10 +1,19 @@ // Why: closing a remote tab prunes the local mirror immediately for responsiveness, so stale pre-close snapshots must not rematerialize it. import { webSessionIntentOwnerKey, type WebSessionIntentOwner } from './web-session-intent-owner' +import { WEB_SESSION_TAB_RPC_TIMEOUT_MS } from './web-session-tab-rpc-timeout' -const CLOSE_INTENT_TTL_MS = 10_000 +/** + * Why derived rather than a literal: `makeWebSessionCloseIntentDurable` can only flip an entry that + * still exists, and the close RPC may answer `tab_not_found` at any point up to its own timeout. A + * TTL shorter than that timeout lets a republishing host's pending-check delete the entry mid-call, + * the durable flip then no-ops, and the pane the user closed comes back (#9194). + */ +const CLOSE_INTENT_ANSWER_GRACE_MS = 5_000 +export const WEB_SESSION_CLOSE_INTENT_TTL_MS = + WEB_SESSION_TAB_RPC_TIMEOUT_MS + CLOSE_INTENT_ANSWER_GRACE_MS -type CloseIntent = { recordedAt: number } +type CloseIntent = { recordedAt: number; durable: boolean } const pendingCloseByOwnerAndWorktree = new Map>() @@ -28,7 +37,27 @@ export function recordWebSessionCloseIntent( byTab = new Map() pendingCloseByOwnerAndWorktree.set(partitionKey, byTab) } - byTab.set(trimmed, { recordedAt: now }) + byTab.set(trimmed, { recordedAt: now, durable: byTab.get(trimmed)?.durable === true }) +} + +/** + * Why no TTL: `tab_not_found` is the host's definitive answer that it does not have this tab, yet a + * host can keep republishing the surface in its snapshot (#9194). Letting that intent age out + * re-materializes a pane whose handle is already gone, and the pane the user just closed comes back + * showing "Remote terminal was closed." with no way to dismiss it. The intent still clears the + * moment the surface leaves a snapshot, so a host that recovers the tab is never suppressed forever. + */ +export function makeWebSessionCloseIntentDurable( + owner: WebSessionIntentOwner, + worktreeId: string, + hostTabId: string +): void { + const intent = pendingCloseByOwnerAndWorktree + .get(closeIntentPartitionKey(owner, worktreeId)) + ?.get(hostTabId) + if (intent) { + intent.durable = true + } } export function isWebSessionCloseIntentPending( @@ -43,7 +72,7 @@ export function isWebSessionCloseIntentPending( if (!intent) { return false } - if (now - intent.recordedAt > CLOSE_INTENT_TTL_MS) { + if (!intent.durable && now - intent.recordedAt > WEB_SESSION_CLOSE_INTENT_TTL_MS) { byTab!.delete(hostTabId) if (byTab!.size === 0) { pendingCloseByOwnerAndWorktree.delete(partitionKey) diff --git a/src/renderer/src/runtime/web-session-tab-rpc-timeout.ts b/src/renderer/src/runtime/web-session-tab-rpc-timeout.ts new file mode 100644 index 00000000000..876ff56a9c0 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tab-rpc-timeout.ts @@ -0,0 +1,5 @@ +/** + * The budget for a `session.tabs.*` RPC. Client-side suppression that has to outlive one of these + * calls (the close intent) derives its own lifetime from this, so the two cannot drift apart. + */ +export const WEB_SESSION_TAB_RPC_TIMEOUT_MS = 15_000 From 34999e328e03e42edd8f0ed2b78dde82edac221f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:49:09 -0700 Subject: [PATCH 88/94] fix(orcad): stop demanding a spawn-helper only macOS builds (#18122) node-pty declares the spawn-helper target inside binding.gyp's OS=="mac" block and pty.cc execs it only under __APPLE__. Asserting it on `!== 'win32'` made every Linux orcad boot degraded with spawn_helper_missing while its terminals worked fine. Route all four sites through one shared `usesNodePtySpawnHelper` predicate: the precondition verdict, the prebuilt slot install, the +x repair, and the prebuilds build script (which threw outright on a Linux slot build). Fixes #17844 --- config/scripts/build-orcad-prebuilds.mjs | 7 ++-- src/main/orcad/node-pty-prebuilt-slot.test.ts | 32 +++++++++++++--- src/main/orcad/node-pty-prebuilt-slot.ts | 5 ++- src/main/orcad/node-pty-precondition.test.ts | 37 ++++++++++++++++--- src/main/orcad/node-pty-precondition.ts | 5 ++- src/main/providers/local-pty-utils.ts | 4 +- src/shared/node-pty-spawn-helper.test.ts | 14 +++++++ src/shared/node-pty-spawn-helper.ts | 11 ++++++ 8 files changed, 96 insertions(+), 19 deletions(-) create mode 100644 src/shared/node-pty-spawn-helper.test.ts create mode 100644 src/shared/node-pty-spawn-helper.ts diff --git a/config/scripts/build-orcad-prebuilds.mjs b/config/scripts/build-orcad-prebuilds.mjs index efbafbe9a1b..2d818d5d255 100644 --- a/config/scripts/build-orcad-prebuilds.mjs +++ b/config/scripts/build-orcad-prebuilds.mjs @@ -177,10 +177,11 @@ function build() { copyFileSync(builtBinary, join(slotDir, 'pty.node')) console.log(`[orcad-prebuilds] stored ${slot}/pty.node`) - // Why spawn-helper ships too: on Unix node-pty posix_spawns build/Release/spawn-helper, + // Why spawn-helper ships too: on macOS node-pty posix_spawns build/Release/spawn-helper, // so a slot without it installs cleanly and then fails ENOENT the first time a user - // opens a terminal. Windows has no spawn-helper. - if (process.platform !== 'win32') { + // opens a terminal. binding.gyp builds the helper only under OS=="mac"; every other + // platform forks directly, so demanding one there fails a healthy Linux slot build. + if (process.platform === 'darwin') { const helperSource = join(dirname(builtBinary), 'spawn-helper') if (!existsSync(helperSource)) { throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`) diff --git a/src/main/orcad/node-pty-prebuilt-slot.test.ts b/src/main/orcad/node-pty-prebuilt-slot.test.ts index 00880eee1e5..85aafddc7a1 100644 --- a/src/main/orcad/node-pty-prebuilt-slot.test.ts +++ b/src/main/orcad/node-pty-prebuilt-slot.test.ts @@ -17,6 +17,14 @@ const LINUX_GLIBC: NativeHostAbi = { nodeAbi: '127' } +const DARWIN_ARM64: NativeHostAbi = { + platform: 'darwin', + arch: 'arm64', + libc: 'none', + glibcVersion: null, + nodeAbi: '127' +} + const dirs: string[] = [] const temp = (): string => { const dir = mkdtempSync(join(tmpdir(), 'orcad-slot-')) @@ -48,18 +56,32 @@ describe('resolveOrcadPrebuildsDir', () => { }) describe('installPrebuiltSlot', () => { - it('installs the slot binary and spawn-helper into build/Release', () => { + it('installs the slot binary and spawn-helper into build/Release on macOS', () => { + const prebuilds = temp() + const nodePtyDir = temp() + stageSlot(prebuilds, 'darwin-arm64') + + const outcome = installPrebuiltSlot({ abi: DARWIN_ARM64, nodePtyDir, prebuildsDir: prebuilds }) + + expect(outcome).toEqual({ installed: true, slot: 'darwin-arm64', spawnHelper: true }) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(true) + // Without the executable bit every spawn fails EACCES at the moment a user opens a terminal. + const helper = statSync(join(nodePtyDir, 'build', 'Release', 'spawn-helper')) + expect(helper.mode & 0o111).not.toBe(0) + }) + + it('installs a Linux slot without claiming a spawn-helper it never execs', () => { + // node-pty builds spawn-helper only under binding.gyp's OS=="mac"; reporting one off + // macOS is what made every Linux orcad boot degraded on spawn_helper_missing (#17844). const prebuilds = temp() const nodePtyDir = temp() stageSlot(prebuilds, 'linux-x64-glibc') const outcome = installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds }) - expect(outcome).toEqual({ installed: true, slot: 'linux-x64-glibc', spawnHelper: true }) + expect(outcome).toEqual({ installed: true, slot: 'linux-x64-glibc', spawnHelper: false }) expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(true) - // Without the executable bit every spawn fails EACCES at the moment a user opens a terminal. - const helper = statSync(join(nodePtyDir, 'build', 'Release', 'spawn-helper')) - expect(helper.mode & 0o111).not.toBe(0) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'spawn-helper'))).toBe(false) }) it('will not load a glibc slot on a musl host', () => { diff --git a/src/main/orcad/node-pty-prebuilt-slot.ts b/src/main/orcad/node-pty-prebuilt-slot.ts index d0623689902..7dcdebba3da 100644 --- a/src/main/orcad/node-pty-prebuilt-slot.ts +++ b/src/main/orcad/node-pty-prebuilt-slot.ts @@ -16,6 +16,7 @@ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import process from 'node:process' +import { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper' import { nativeSlotName, type NativeHostAbi } from './native-host-abi' export type PrebuiltSlotManifest = { @@ -103,11 +104,11 @@ export function installPrebuiltSlot(options: { mkdirSync(releaseDir, { recursive: true }) copyFileSync(source, join(releaseDir, 'pty.node')) - // Why this matters as much as pty.node: on Unix node-pty posix_spawns + // Why this matters as much as pty.node: on macOS node-pty posix_spawns // build/Release/spawn-helper. Without it every spawn fails with ENOENT at the moment // a user opens a terminal, long after the "install succeeded" line. let spawnHelper = false - if (options.abi.platform !== 'win32') { + if (usesNodePtySpawnHelper(options.abi.platform)) { const helperSource = join(prebuildsDir, slot, 'spawn-helper') if (existsSync(helperSource)) { const helperDest = join(releaseDir, 'spawn-helper') diff --git a/src/main/orcad/node-pty-precondition.test.ts b/src/main/orcad/node-pty-precondition.test.ts index fadb144d1ff..76d842a9456 100644 --- a/src/main/orcad/node-pty-precondition.test.ts +++ b/src/main/orcad/node-pty-precondition.test.ts @@ -31,10 +31,10 @@ const realNodePtyLoads = ((): boolean => { if (!existsSync(REAL_PTY_NODE)) { return false } - // Why spawn-helper too: a slot without it is legitimately 'degraded', so a test that - // expects 'ok' has an unsatisfiable premise on a host that lacks it. CI has the - // binding but not the helper, which is what made the previous gate insufficient. - if (process.platform !== 'win32' && !existsSync(REAL_SPAWN_HELPER)) { + // Why spawn-helper too: on macOS a slot without it is legitimately 'degraded', so a + // test that expects 'ok' has an unsatisfiable premise on a host that lacks it. Only + // macOS builds the helper, so gating other platforms on it never lets them run. + if (process.platform === 'darwin' && !existsSync(REAL_SPAWN_HELPER)) { return false } const probe = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(REAL_PTY_NODE)})`], { @@ -240,8 +240,8 @@ describe('checkNodePtyPrecondition', () => { // Why gated on the real binding: this asserts a LOAD outcome, so it needs a pty.node // built for the Node ABI. CI's shard never runs ensure-native-runtime, so the copy - // ENOENT'd there. - it.runIf(process.platform !== 'win32' && realNodePtyLoads)( + // ENOENT'd there. macOS only — it is the only platform that execs spawn-helper. + it.runIf(process.platform === 'darwin' && realNodePtyLoads)( 'degrades rather than blocks when only spawn-helper is missing', () => { // node-pty posix_spawns spawn-helper, so this host loads fine and then fails ENOENT @@ -258,6 +258,31 @@ describe('checkNodePtyPrecondition', () => { } ) + // Same staging as above, read through a Linux ABI: node-pty builds spawn-helper only + // under binding.gyp's OS=="mac", so demanding one here called every healthy Linux + // orcad degraded while its terminals worked (#17844). Gated on a loadable binding for + // the same reason as the macOS case; the ABI is what makes it a Linux verdict. + it.runIf(process.platform !== 'win32' && realNodePtyLoads)( + 'does not call a Linux host degraded over a spawn-helper it never execs', + () => { + const dir = stageNodePty() + cpSync( + join(REAL_NODE_PTY, 'build', 'Release', 'pty.node'), + join(dir, 'build', 'Release', 'pty.node') + ) + expect(existsSync(join(dir, 'build', 'Release', 'spawn-helper'))).toBe(false) + + const verdict = checkNodePtyPrecondition({ + nodePtyDir: dir, + prebuildsDir: null, + abi: { platform: 'linux', arch: 'x64', libc: 'glibc', glibcVersion: '2.31', nodeAbi: '127' } + }) + + expect(verdict).toMatchObject({ status: 'ok', slot: 'linux-x64-glibc' }) + expect(verdict.reason).toBeUndefined() + } + ) + // Why split: the "ok" half needs a REAL loadable pty.node, which only exists after // `ensure-native-runtime --runtime=node`. CI's shard runs vitest directly, so copying // from node_modules ENOENT'd there. Slot *placement* is the logic worth checking on diff --git a/src/main/orcad/node-pty-precondition.ts b/src/main/orcad/node-pty-precondition.ts index 7d633bd0f9a..ff41a14cf81 100644 --- a/src/main/orcad/node-pty-precondition.ts +++ b/src/main/orcad/node-pty-precondition.ts @@ -19,6 +19,7 @@ import { existsSync, accessSync, constants } from 'node:fs' import { dirname, join } from 'node:path' import process from 'node:process' import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' +import { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper' import type { RuntimeTerminalUnavailableReason } from '../../shared/runtime-types' import { buildToolchainProbeCommand, @@ -302,12 +303,12 @@ export function checkNodePtyPrecondition( } } - // Loaded. The remaining way terminals fail is spawn-time: node-pty posix_spawns + // Loaded. The remaining way terminals fail is spawn-time: on macOS node-pty posix_spawns // build/Release/spawn-helper, and a missing one turns every terminal.create into ENOENT // on a host that otherwise looks healthy. That is a degradation, not a boot blocker. const outcome = readNodePtyProbeOutcome(result) const loadedDir = outcome.kind === 'loaded' ? outcome.loadedDir : null - if (abi.platform !== 'win32') { + if (usesNodePtySpawnHelper(abi.platform)) { const helper = join(loadedDir || join(nodePtyDir, 'build', 'Release'), 'spawn-helper') if (!isExecutableFile(helper)) { return { diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 5649db65534..735393449f2 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -1,6 +1,7 @@ import { basename, isAbsolute, join } from 'node:path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs' import type * as pty from 'node-pty' +import { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper' import { hostReportsChildExitStatus, wrapShellSpawnForMacosTccAttribution @@ -82,9 +83,10 @@ export function resolveUnixShellPath(shellPath: string): string { * Why: when Electron packages the app via asar, the native spawn-helper * binary may lose its +x permission. This function detects and repairs * that so pty.spawn() does not fail with EACCES on first launch. + * macOS only — no other platform builds or execs the helper. */ export function ensureNodePtySpawnHelperExecutable(): void { - if (didEnsureSpawnHelperExecutable || process.platform === 'win32') { + if (didEnsureSpawnHelperExecutable || !usesNodePtySpawnHelper(process.platform)) { return } didEnsureSpawnHelperExecutable = true diff --git a/src/shared/node-pty-spawn-helper.test.ts b/src/shared/node-pty-spawn-helper.test.ts new file mode 100644 index 00000000000..4562c2bc9b7 --- /dev/null +++ b/src/shared/node-pty-spawn-helper.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { usesNodePtySpawnHelper } from './node-pty-spawn-helper' + +describe('usesNodePtySpawnHelper', () => { + it('is macOS only', () => { + // The predicate this file exists for: node-pty's binding.gyp declares the + // spawn-helper target inside OS=="mac". Reading it as "every non-Windows platform" + // is what reported spawn_helper_missing on healthy Linux hosts (#17844). + expect(usesNodePtySpawnHelper('darwin')).toBe(true) + for (const platform of ['linux', 'win32', 'freebsd', 'openbsd', 'sunos', 'aix']) { + expect(usesNodePtySpawnHelper(platform)).toBe(false) + } + }) +}) diff --git a/src/shared/node-pty-spawn-helper.ts b/src/shared/node-pty-spawn-helper.ts new file mode 100644 index 00000000000..c3e40df101a --- /dev/null +++ b/src/shared/node-pty-spawn-helper.ts @@ -0,0 +1,11 @@ +/** + * Whether node-pty execs its `spawn-helper` binary on a platform. + * + * Only macOS: binding.gyp declares the `spawn-helper` target inside `OS=="mac"`, and + * `src/unix/pty.cc` reads the helper path only under `#if defined(__APPLE__)`. Every + * other platform forks directly, so requiring the helper there calls a working host + * broken. + */ +export function usesNodePtySpawnHelper(platform: string): boolean { + return platform === 'darwin' +} From 0da52453a75cc75b369a0a71969780625921d485 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:55:38 -0700 Subject: [PATCH 89/94] fix(settings): surface why CLI registration failed (#18125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Settings CLI panel treated every resolved `cli:install` as a success, so a refusal that arrives as data (conflict, missing launcher, unreadable Windows PATH) produced a green "Registered `orca` in PATH." toast while the switch stayed off. A thrown refusal fared little better: the raw Electron `Error invoking remote method 'cli:install': ...` string went into a toast that then disappeared, leaving the panel indistinguishable from "not yet installed". Inspect the returned status with the predicate the onboarding and agent-skill flows already use (`state !== 'installed'`), unwrap the IPC transport prefix off thrown installer messages, and persist the existing main-process reason inline per STYLEGUIDE (toasts disappear; errors the user must act on stay inline). No new error taxonomy — the reasons already carry path and remedy; a conflict status, which names the path but not the remedy, gets the installer's own remedy sentence. Closes #3952 --- .../CliSection.install-failure.test.tsx | 154 ++++++++++++++++++ .../src/components/settings/CliSection.tsx | 113 +++++-------- .../settings/cli-install-failure.test.ts | 108 ++++++++++++ .../settings/cli-install-failure.ts | 40 +++++ .../settings/use-cli-registration-actions.ts | 127 +++++++++++++++ src/renderer/src/i18n/locales/en.json | 4 +- 6 files changed, 472 insertions(+), 74 deletions(-) create mode 100644 src/renderer/src/components/settings/CliSection.install-failure.test.tsx create mode 100644 src/renderer/src/components/settings/cli-install-failure.test.ts create mode 100644 src/renderer/src/components/settings/cli-install-failure.ts create mode 100644 src/renderer/src/components/settings/use-cli-registration-actions.ts diff --git a/src/renderer/src/components/settings/CliSection.install-failure.test.tsx b/src/renderer/src/components/settings/CliSection.install-failure.test.tsx new file mode 100644 index 00000000000..414a5ace240 --- /dev/null +++ b/src/renderer/src/components/settings/CliSection.install-failure.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import type { CliInstallStatus } from '../../../../shared/cli-install-types' +import { CliSection } from './CliSection' + +const toasts = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() })) +const dialog = vi.hoisted(() => ({ + props: null as null | { onInstall: () => Promise; open: boolean } +})) + +vi.mock('sonner', () => ({ toast: toasts })) + +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['global'], + useInstalledAgentSkill: () => ({ + installed: false, + loading: false, + error: null, + refresh: vi.fn() + }) +})) + +vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ + useActiveProjectSkillRuntime: () => ({ canUseLocalSkillFreshness: true }) +})) + +vi.mock('./AgentSkillSetupPanel', () => ({ + AgentSkillSetupPanel: () =>
+})) + +vi.mock('./WslCliRegistration', () => ({ WslCliRegistration: () => null })) + +vi.mock('./CliRegistrationDialog', () => ({ + CliRegistrationDialog: function CliRegistrationDialog(props: { + onInstall: () => Promise + open: boolean + }) { + dialog.props = props + return null + } +})) + +function notInstalledStatus(overrides: Partial = {}): CliInstallStatus { + return { + platform: 'darwin', + commandName: 'orca', + commandPath: '/usr/local/bin/orca', + pathDirectory: '/usr/local/bin', + pathConfigured: true, + launcherPath: '/Applications/Orca.app/Contents/Resources/bin/orca', + installMethod: 'symlink', + supported: true, + state: 'not_installed', + currentTarget: null, + unsupportedReason: null, + detail: 'Register /usr/local/bin/orca to use Orca from the terminal.', + ...overrides + } +} + +async function renderCliSectionAndInstall(install: () => Promise): Promise { + Object.assign(window, { + api: { + cli: { + getInstallStatus: vi.fn().mockResolvedValue(notInstalledStatus()), + getWslInstallStatus: vi.fn(), + install: vi.fn(install), + remove: vi.fn() + }, + shell: { openPath: vi.fn() } + } + }) + + render() + await screen.findByRole('switch') + await act(async () => { + await dialog.props?.onInstall() + }) +} + +afterEach(() => { + cleanup() + dialog.props = null + toasts.error.mockReset() + toasts.success.mockReset() +}) + +describe('CliSection install failure surfacing', () => { + it('shows the thrown conflict reason and its remedy instead of a success toast', async () => { + await renderCliSectionAndInstall(async () => { + throw new Error( + "Error invoking remote method 'cli:install': Error: Refusing to replace non-Orca " + + 'command at /usr/local/bin/orca. Remove it and register again if it is no longer needed.' + ) + }) + + const alert = screen.getByRole('alert') + expect(alert.textContent).toContain('Failed to register `orca` in PATH.') + expect(alert.textContent).toContain( + 'Refusing to replace non-Orca command at /usr/local/bin/orca.' + ) + expect(alert.textContent).toContain('Remove it and register again if it is no longer needed.') + // The Electron transport wrapper must not leak into the panel. + expect(alert.textContent).not.toContain('invoking remote method') + expect(toasts.success).not.toHaveBeenCalled() + expect(toasts.error).toHaveBeenCalledTimes(1) + }) + + it('names the path and the remedy when install resolves with a conflict', async () => { + await renderCliSectionAndInstall(async () => + notInstalledStatus({ + state: 'conflict', + detail: '/usr/local/bin/orca exists but is not an Orca symlink.' + }) + ) + + const alert = screen.getByRole('alert') + expect(alert.textContent).toContain('/usr/local/bin/orca exists but is not an Orca symlink.') + expect(alert.textContent).toContain( + 'Remove /usr/local/bin/orca and register again if it is no longer needed.' + ) + expect(toasts.success).not.toHaveBeenCalled() + }) + + it('does not claim success when install resolves without registering', async () => { + await renderCliSectionAndInstall(async () => + notInstalledStatus({ + state: 'unsupported', + supported: false, + unsupportedReason: 'launcher_missing', + detail: 'The bundled CLI launcher is missing from this Orca build.' + }) + ) + + expect(screen.getByRole('alert').textContent).toContain( + 'The bundled CLI launcher is missing from this Orca build.' + ) + expect(toasts.success).not.toHaveBeenCalled() + expect(toasts.error).toHaveBeenCalledTimes(1) + }) + + it('keeps the success toast and shows no failure notice when registration lands', async () => { + await renderCliSectionAndInstall(async () => + notInstalledStatus({ state: 'installed', detail: null }) + ) + + expect(screen.queryByRole('alert')).toBeNull() + expect(toasts.success).toHaveBeenCalledTimes(1) + expect(toasts.error).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 32cbb3e5766..364a866df5b 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -33,6 +33,7 @@ import { getWslCliDistroRequest } from './CliSkillRuntimeSetup' import { WslCliRegistration } from './WslCliRegistration' +import { useCliRegistrationActions } from './use-cli-registration-actions' import { useLocalCliSkillFreshnessName } from './use-local-cli-skill-freshness-name' import { translate } from '@/i18n/i18n' @@ -81,7 +82,6 @@ export function CliSection({ const [status, setStatus] = useState(null) const [loading, setLoading] = useState(true) const [dialogOpen, setDialogOpen] = useState(false) - const [busyAction, setBusyAction] = useState<'install' | 'remove' | null>(null) const mountedRef = useMountedRef() const agentRuntime = useMemo( () => @@ -132,8 +132,19 @@ export function CliSection({ [mountedRef] ) + const closeDialog = useCallback((): void => setDialogOpen(false), []) + const commandName = status?.commandName ?? getFallbackCommandName(currentPlatform) + const { busyAction, installFailure, clearInstallFailure, install, remove } = + useCliRegistrationActions({ + commandName, + mountedRef, + onStatusChange: handleStatusChange, + onSettled: closeDialog + }) + const refreshStatus = useCallback(async (): Promise => { setLoading(true) + clearInstallFailure() try { handleStatusChange(await window.api.cli.getInstallStatus()) } catch (error) { @@ -152,7 +163,7 @@ export function CliSection({ setLoading(false) } } - }, [handleStatusChange, mountedRef]) + }, [clearInstallFailure, handleStatusChange, mountedRef]) useEffect(() => { void refreshStatus() @@ -163,78 +174,9 @@ export function CliSection({ const isSupported = status?.supported ?? false const isBrowserManaged = status?.unsupportedReason === 'launch_mode_unavailable' const revealLabel = getRevealLabel(currentPlatform) - const commandName = status?.commandName ?? getFallbackCommandName(currentPlatform) const canRevealCommandPath = status?.commandPath != null && ['installed', 'stale', 'conflict'].includes(status.state) - const handleInstall = async (): Promise => { - setBusyAction('install') - try { - const next = await window.api.cli.install() - if (mountedRef.current) { - setStatus(next) - setDialogOpen(false) - toast.success( - translate( - 'auto.components.settings.CliSection.9cbcd31338', - 'Registered `{{value0}}` in PATH.', - { value0: next.commandName } - ) - ) - } - } catch (error) { - if (mountedRef.current) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.settings.CliSection.a2b13efa94', - 'Failed to register `{{value0}}` in PATH.', - { value0: commandName } - ) - ) - } - } finally { - if (mountedRef.current) { - setBusyAction(null) - } - } - } - - const handleRemove = async (): Promise => { - setBusyAction('remove') - try { - const next = await window.api.cli.remove() - if (mountedRef.current) { - setStatus(next) - setDialogOpen(false) - toast.success( - translate( - 'auto.components.settings.CliSection.af5540930c', - 'Removed `{{value0}}` from PATH.', - { value0: next.commandName } - ) - ) - } - } catch (error) { - if (mountedRef.current) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.settings.CliSection.d77352f2df', - 'Failed to remove `{{value0}}` from PATH.', - { value0: commandName } - ) - ) - } - } finally { - if (mountedRef.current) { - setBusyAction(null) - } - } - } - return (
@@ -336,6 +278,31 @@ export function CliSection({

{status.detail}

) : null} + {installFailure ? ( +
+

+ {translate( + 'auto.components.settings.CliSection.a2b13efa94', + 'Failed to register `{{value0}}` in PATH.', + { value0: commandName } + )} +

+

{installFailure.reason}

+ {installFailure.conflictCommandPath ? ( +

+ {translate( + 'auto.components.settings.CliSection.installFailureConflictRemedy', + 'Remove {{value0}} and register again if it is no longer needed.', + { value0: installFailure.conflictCommandPath } + )} +

+ ) : null} +
+ ) : null} +
{status?.commandPath ? (
diff --git a/src/renderer/src/components/settings/cli-install-failure.test.ts b/src/renderer/src/components/settings/cli-install-failure.test.ts new file mode 100644 index 00000000000..dfd39f15649 --- /dev/null +++ b/src/renderer/src/components/settings/cli-install-failure.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import type { CliInstallStatus } from '../../../../shared/cli-install-types' +import { readCliInstallFailure, readCliInstallRejection } from './cli-install-failure' + +const FALLBACK = 'Orca could not finish CLI registration and reported no reason.' + +function cliStatus(overrides: Partial = {}): CliInstallStatus { + return { + platform: 'darwin', + commandName: 'orca', + commandPath: '/usr/local/bin/orca', + pathDirectory: '/usr/local/bin', + pathConfigured: true, + launcherPath: '/Applications/Orca.app/Contents/Resources/bin/orca', + installMethod: 'symlink', + supported: true, + state: 'installed', + currentTarget: null, + unsupportedReason: null, + detail: null, + ...overrides + } +} + +describe('readCliInstallFailure', () => { + it('reports no failure for a landed registration', () => { + expect(readCliInstallFailure(cliStatus(), FALLBACK)).toBeNull() + }) + + it('surfaces the main-process reason verbatim without re-classifying it', () => { + expect( + readCliInstallFailure( + cliStatus({ + state: 'unsupported', + supported: false, + unsupportedReason: 'launcher_missing', + detail: 'The bundled CLI launcher is missing from this Orca build.' + }), + FALLBACK + ) + ).toEqual({ + reason: 'The bundled CLI launcher is missing from this Orca build.', + conflictCommandPath: null + }) + }) + + it('names the conflicting path so the panel can offer the remedy', () => { + expect( + readCliInstallFailure( + cliStatus({ + state: 'conflict', + detail: '/usr/local/bin/orca exists but is not an Orca symlink.' + }), + FALLBACK + ) + ).toEqual({ + reason: '/usr/local/bin/orca exists but is not an Orca symlink.', + conflictCommandPath: '/usr/local/bin/orca' + }) + }) + + it('falls back when the main process reported no detail', () => { + expect(readCliInstallFailure(cliStatus({ state: 'not_installed' }), FALLBACK)).toEqual({ + reason: FALLBACK, + conflictCommandPath: null + }) + }) +}) + +describe('readCliInstallRejection', () => { + it('strips the Electron transport prefix off the installer message', () => { + expect( + readCliInstallRejection( + new Error( + "Error invoking remote method 'cli:install': Error: Refusing to replace non-Orca " + + 'command at /usr/local/bin/orca. Remove it and register again if it is no longer needed.' + ), + FALLBACK + ) + ).toEqual({ + reason: + 'Refusing to replace non-Orca command at /usr/local/bin/orca. ' + + 'Remove it and register again if it is no longer needed.', + conflictCommandPath: null + }) + }) + + it('keeps the registration-lock remedy that names the lock file', () => { + const failure = readCliInstallRejection( + new Error( + "Error invoking remote method 'cli:install': Error: Timed out waiting for another Orca " + + 'process to finish CLI registration (waited 330s). If no other Orca is running, remove ' + + '/home/u/.cache/orca/appimage/.cli-registration.lock and retry.' + ), + FALLBACK + ) + + expect(failure.reason).toContain('.cli-registration.lock and retry.') + expect(failure.reason.startsWith('Timed out waiting')).toBe(true) + }) + + it('falls back for a non-Error rejection with no message', () => { + expect(readCliInstallRejection(new Error(' '), FALLBACK)).toEqual({ + reason: FALLBACK, + conflictCommandPath: null + }) + }) +}) diff --git a/src/renderer/src/components/settings/cli-install-failure.ts b/src/renderer/src/components/settings/cli-install-failure.ts new file mode 100644 index 00000000000..8ca55a43299 --- /dev/null +++ b/src/renderer/src/components/settings/cli-install-failure.ts @@ -0,0 +1,40 @@ +import type { CliInstallStatus } from '../../../../shared/cli-install-types' + +// Why: Electron re-wraps a rejected `ipcMain.handle` as +// `Error invoking remote method '': Error: `, so the installer's +// own sentence is buried behind transport noise by the time it reaches the panel. +const IPC_INVOKE_PREFIX = /^Error invoking remote method '[^']*':\s*(?:Error:\s*)?/ + +export type CliInstallFailure = { + /** The main-process reason verbatim; installer throws already embed their own remedy. */ + reason: string + /** Set only for a conflict, whose status detail names the path but stops short of the remedy. */ + conflictCommandPath: string | null +} + +/** + * A registration call that resolved without landing. The main process already + * reported why in `detail`, so this only decides that it failed — it does not + * re-classify the reason. + */ +export function readCliInstallFailure( + status: CliInstallStatus, + fallbackReason: string +): CliInstallFailure | null { + if (status.state === 'installed') { + return null + } + return { + reason: status.detail?.trim() || fallbackReason, + conflictCommandPath: status.state === 'conflict' ? status.commandPath : null + } +} + +/** A registration call that threw: unwrap the transport prefix off the installer's message. */ +export function readCliInstallRejection(error: unknown, fallbackReason: string): CliInstallFailure { + const message = error instanceof Error ? error.message : String(error) + return { + reason: message.replace(IPC_INVOKE_PREFIX, '').trim() || fallbackReason, + conflictCommandPath: null + } +} diff --git a/src/renderer/src/components/settings/use-cli-registration-actions.ts b/src/renderer/src/components/settings/use-cli-registration-actions.ts new file mode 100644 index 00000000000..29737764e9c --- /dev/null +++ b/src/renderer/src/components/settings/use-cli-registration-actions.ts @@ -0,0 +1,127 @@ +import { useCallback, useState, type MutableRefObject } from 'react' +import { toast } from 'sonner' +import type { CliInstallStatus } from '../../../../shared/cli-install-types' +import { translate } from '@/i18n/i18n' +import { + readCliInstallFailure, + readCliInstallRejection, + type CliInstallFailure +} from './cli-install-failure' + +type CliRegistrationActionsOptions = { + commandName: string + mountedRef: MutableRefObject + onStatusChange: (status: CliInstallStatus) => void + onSettled: () => void +} + +export type CliRegistrationActions = { + busyAction: 'install' | 'remove' | null + installFailure: CliInstallFailure | null + clearInstallFailure: () => void + install: () => Promise + remove: () => Promise +} + +function unknownReason(): string { + return translate( + 'auto.components.settings.CliSection.installFailureUnknownReason', + 'Orca could not finish CLI registration and reported no reason.' + ) +} + +function failedTitle(commandName: string): string { + return translate( + 'auto.components.settings.CliSection.a2b13efa94', + 'Failed to register `{{value0}}` in PATH.', + { value0: commandName } + ) +} + +export function useCliRegistrationActions({ + commandName, + mountedRef, + onStatusChange, + onSettled +}: CliRegistrationActionsOptions): CliRegistrationActions { + const [busyAction, setBusyAction] = useState<'install' | 'remove' | null>(null) + const [installFailure, setInstallFailure] = useState(null) + const clearInstallFailure = useCallback((): void => setInstallFailure(null), []) + + const install = useCallback(async (): Promise => { + setBusyAction('install') + try { + const next = await window.api.cli.install() + if (!mountedRef.current) { + return + } + onStatusChange(next) + onSettled() + // Why: `install()` resolves with the post-registration status, so a refusal + // (conflict, unsupported build, unreadable PATH) arrives as data, not a throw. + const failure = readCliInstallFailure(next, unknownReason()) + setInstallFailure(failure) + if (failure) { + toast.error(failedTitle(next.commandName), { description: failure.reason }) + return + } + toast.success( + translate( + 'auto.components.settings.CliSection.9cbcd31338', + 'Registered `{{value0}}` in PATH.', + { value0: next.commandName } + ) + ) + } catch (error) { + if (!mountedRef.current) { + return + } + const failure = readCliInstallRejection(error, unknownReason()) + setInstallFailure(failure) + // Why: closing reveals the persistent notice the toast is only a preview of. + onSettled() + toast.error(failedTitle(commandName), { description: failure.reason }) + } finally { + if (mountedRef.current) { + setBusyAction(null) + } + } + }, [commandName, mountedRef, onSettled, onStatusChange]) + + const remove = useCallback(async (): Promise => { + setBusyAction('remove') + try { + const next = await window.api.cli.remove() + if (mountedRef.current) { + onStatusChange(next) + onSettled() + setInstallFailure(null) + toast.success( + translate( + 'auto.components.settings.CliSection.af5540930c', + 'Removed `{{value0}}` from PATH.', + { value0: next.commandName } + ) + ) + } + } catch (error) { + if (mountedRef.current) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.settings.CliSection.d77352f2df', + 'Failed to remove `{{value0}}` from PATH.', + { value0: commandName } + ) + ) + } + } finally { + if (mountedRef.current) { + setBusyAction(null) + } + } + }, [commandName, mountedRef, onSettled, onStatusChange]) + + return { busyAction, installFailure, clearInstallFailure, install, remove } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c7a66f2decc..a641c2cf7dd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6754,7 +6754,9 @@ "8a9b784c60": "stale", "d363e5929b": "Checking CLI registration…", "cliSkillTerminalTitle": "CLI skill setup", - "cliSkillTerminalAria": "CLI skill install terminal" + "cliSkillTerminalAria": "CLI skill install terminal", + "installFailureUnknownReason": "Orca could not finish CLI registration and reported no reason.", + "installFailureConflictRemedy": "Remove {{value0}} and register again if it is no longer needed." }, "CliSkillRuntimeSetup": { "04325573f8": "WSL", From 62e9949141f6de571f8808e0d819bc6af5565f80 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:01:07 -0700 Subject: [PATCH 90/94] perf(renderer): index worktree owner lookups instead of rescanning every workspace (#18130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `worktreeUsesRemoteConnection`, `getRemoteConnectionIdForWorktree`, `worktreeUsesWslPath` and `rightSidebarShowsPullRequestData` each did `Object.values(state.worktreesByRepo).flat().find(...)` plus a linear `repos.find(...)`. They are called from unmemoized Zustand selectors (`use-tab-agent.ts:263`, `use-visible-review-refresh.ts:45`), so every store write re-ran the whole scan once per open tab. Measured on a real instance (10 repos / 423 worktrees / 382 tabs): the `.find()` predicate alone ran 1,320,424 times in 30s — 44,000 worktree visits/sec — while the app was idle. Switched to the existing WeakMap-cached `getIndexedWorktreeMap` / `getIndexedRepoMap` from `store/worktree-repo-index.ts`, matching what `connection-owner-resolution.ts` already does. Same duplicate-id and host-collision semantics; no behavior change. Benchmark at that scale, 200 store writes x 382 tabs x 3 lookups: before 2.762ms per store write after 0.167ms per store write (16.6x) At ~20 store writes/sec that is 55.2ms/sec of renderer CPU down to 3.3ms/sec. The new scale test counts worktree `id` reads: 160,000 before, 800 after. --- .../src/lib/right-sidebar-visibility.ts | 9 +- .../terminal-workspace-routing.scale.test.ts | 98 +++++++++++++++++++ .../terminals/terminal-workspace-routing.ts | 25 ++--- 3 files changed, 113 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/store/terminals/terminal-workspace-routing.scale.test.ts diff --git a/src/renderer/src/lib/right-sidebar-visibility.ts b/src/renderer/src/lib/right-sidebar-visibility.ts index 673fff6e01e..4eb9375efb4 100644 --- a/src/renderer/src/lib/right-sidebar-visibility.ts +++ b/src/renderer/src/lib/right-sidebar-visibility.ts @@ -1,4 +1,5 @@ import type { AppState } from '@/store/types' +import { getIndexedRepoMap, getIndexedWorktreeMap } from '@/store/worktree-repo-index' import { isFolderRepo } from '../../../shared/repo-kind' type ActiveView = AppState['activeView'] @@ -37,11 +38,11 @@ export function rightSidebarShowsPullRequestData( return false } - const activeWorktree = Object.values(state.worktreesByRepo) - .flat() - .find((worktree) => worktree.id === state.activeWorktreeId) + const activeWorktree = state.activeWorktreeId + ? getIndexedWorktreeMap(state.worktreesByRepo).get(state.activeWorktreeId) + : undefined const activeRepo = activeWorktree - ? state.repos.find((repo) => repo.id === activeWorktree.repoId) + ? getIndexedRepoMap(state.repos).get(activeWorktree.repoId) : null if (!activeRepo || isFolderRepo(activeRepo)) { return false diff --git a/src/renderer/src/store/terminals/terminal-workspace-routing.scale.test.ts b/src/renderer/src/store/terminals/terminal-workspace-routing.scale.test.ts new file mode 100644 index 00000000000..998ad871d86 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-workspace-routing.scale.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '../types' +import { + getRemoteConnectionIdForWorktree, + worktreeUsesRemoteConnection, + worktreeUsesWslPath +} from './terminal-workspace-routing' +import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' + +const REPO_COUNT = 10 +const WORKTREE_COUNT = 400 + +/** Counts every `id` read so a rescan shows up as a multiple of the row count. */ +function buildCountingState(): { + state: AppState + reads: () => number + worktreeId: string +} { + let idReads = 0 + const repos = Array.from({ length: REPO_COUNT }, (_, index) => ({ + id: `repo-${index}`, + name: `repo-${index}`, + path: `/repos/repo-${index}`, + connectionId: null + })) + const worktreesByRepo: Record = {} + const perRepo = WORKTREE_COUNT / REPO_COUNT + for (let repoIndex = 0; repoIndex < REPO_COUNT; repoIndex++) { + worktreesByRepo[`repo-${repoIndex}`] = Array.from({ length: perRepo }, (_, index) => { + const id = `repo-${repoIndex}::/repos/repo-${repoIndex}/wt-${index}` + return { + get id() { + idReads++ + return id + }, + repoId: `repo-${repoIndex}`, + path: `/repos/repo-${repoIndex}/wt-${index}`, + branch: `branch-${index}`, + hostId: 'local' + } + }) + } + return { + state: { + repos, + worktreesByRepo, + folderWorkspaces: [], + projectGroups: [], + activeView: 'worktrees', + activeWorktreeId: 'repo-9::/repos/repo-9/wt-39', + rightSidebarOpen: true, + rightSidebarTab: 'checks' + } as unknown as AppState, + reads: () => idReads, + worktreeId: 'repo-9::/repos/repo-9/wt-39' + } +} + +describe('terminal workspace routing scales with tab count, not workspace count', () => { + it('answers repeated owner lookups without rescanning every worktree', () => { + const { state, reads, worktreeId } = buildCountingState() + const CALLS = 200 + + for (let call = 0; call < CALLS; call++) { + worktreeUsesRemoteConnection(state, worktreeId) + getRemoteConnectionIdForWorktree(state, worktreeId) + worktreeUsesWslPath(state, worktreeId) + rightSidebarShowsPullRequestData(state) + } + + // One index build over every row, then O(1) map hits. A per-call scan would + // read at least CALLS x WORKTREE_COUNT ids. + expect(reads()).toBeLessThanOrEqual(WORKTREE_COUNT * 2) + expect(reads()).toBeLessThan(CALLS * WORKTREE_COUNT) + }) + + it('still resolves the owning repo and its connection', () => { + const { state, worktreeId } = buildCountingState() + const remoteState = { + ...state, + repos: state.repos.map((repo) => + repo.id === 'repo-9' ? { ...repo, connectionId: 'ssh-host-1' } : repo + ) + } as AppState + + expect(worktreeUsesRemoteConnection(remoteState, worktreeId)).toBe(true) + expect(getRemoteConnectionIdForWorktree(remoteState, worktreeId)).toBe('ssh-host-1') + expect(worktreeUsesRemoteConnection(state, worktreeId)).toBe(false) + expect(getRemoteConnectionIdForWorktree(state, worktreeId)).toBeNull() + expect(worktreeUsesWslPath(state, worktreeId)).toBe(false) + }) + + it('returns null for a worktree id that no repo owns', () => { + const { state } = buildCountingState() + expect(getRemoteConnectionIdForWorktree(state, 'ghost::/nowhere')).toBeNull() + expect(worktreeUsesRemoteConnection(state, 'ghost::/nowhere')).toBe(false) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-workspace-routing.ts b/src/renderer/src/store/terminals/terminal-workspace-routing.ts index 9148b9a5f7c..ae07c3e7b8e 100644 --- a/src/renderer/src/store/terminals/terminal-workspace-routing.ts +++ b/src/renderer/src/store/terminals/terminal-workspace-routing.ts @@ -7,6 +7,7 @@ import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shar import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getIndexedRepoMap, getIndexedWorktreeMap } from '../worktree-repo-index' export function isWindowsRendererRuntime(): boolean { return typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows') @@ -61,9 +62,7 @@ export function worktreeUsesWslPath( ) return folderWorkspace ? isWslUncPath(folderWorkspace.folderPath) : false } - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((entry) => entry.id === worktreeId) + const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId) return worktree ? isWslUncPath(worktree.path) : false } @@ -75,15 +74,13 @@ export function worktreeUsesRemoteConnection( if (parsedWorkspaceKey?.type === 'folder') { return Boolean(getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId)) } - const directRepoId = getRepoIdFromWorktreeId(worktreeId) - const directRepo = state.repos.find((repo) => repo.id === directRepoId) + const repoMap = getIndexedRepoMap(state.repos) + const directRepo = repoMap.get(getRepoIdFromWorktreeId(worktreeId)) if (directRepo) { return Boolean(directRepo.connectionId) } - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((entry) => entry.id === worktreeId) - const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null + const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId) + const repo = worktree ? repoMap.get(worktree.repoId) : null return Boolean(repo?.connectionId) } @@ -95,15 +92,13 @@ export function getRemoteConnectionIdForWorktree( if (parsedWorkspaceKey?.type === 'folder') { return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId) ?? null } - const directRepoId = getRepoIdFromWorktreeId(worktreeId) - const directRepo = state.repos.find((repo) => repo.id === directRepoId) + const repoMap = getIndexedRepoMap(state.repos) + const directRepo = repoMap.get(getRepoIdFromWorktreeId(worktreeId)) if (directRepo) { return directRepo.connectionId?.trim() || null } - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((entry) => entry.id === worktreeId) - const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null + const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId) + const repo = worktree ? repoMap.get(worktree.repoId) : null return repo?.connectionId?.trim() || null } From aa3ae6f56ec54d9c28c6b65f257c36d1e207fe0b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:02:27 -0700 Subject: [PATCH 91/94] fix(ssh): close the pty master fd leak on relay hosts too (#17920) * fix(ssh): close the pty master fd leak on Linux relay hosts The app gets the FD_CLOEXEC patch through pnpm patchedDependencies (#17914); the relay installs stock node-pty from npm, where no pnpm patch reaches. Linux is where that matters -- it is the only relay platform that takes forkpty()'s no-atomic-O_CLOEXEC path, and it is also the only one that already compiles node-pty at install time, so the fix costs a second compile rather than a first. Ships the patch as a relay asset applied like the existing Windows console-list one, and rebuilds only after the probe has proven node-pty loadable. The rebuild is non-fatal by construction: the working build is moved aside first and moved back on any failure, a failed attempt drops a skip marker so the compile is attempted at most once per relay directory, and the caller swallows the whole step. macOS and Windows relays never run it. Measured on node:22 with a relay-style npm install: before, the master is cloexec=false and shows up as `26 -> /dev/pts/ptmx` in both a later pty child and a later child_process child; after, cloexec=true and neither child sees it. Closes #17915. * test(ssh): feed the cloexec patch exec to the hand-rolled namespace fixtures These sequences are positional, so the new Linux-only patch exec swallowed the READY slot and every install/repair case timed out waiting for the relay. * fix(ssh): patch the pty master before publishing the shared native-deps tree * fix(ssh): refuse to publish a native-deps tree whose cloexec patch did not take --- .../node-pty-1.1.0-master-cloexec-patch.cjs | 318 +++++++ .../__fixtures__/node-pty-1.1.0-unix-pty.cc | 799 ++++++++++++++++++ config/scripts/build-relay.mjs | 11 + .../node-pty-master-cloexec-patch.test.mjs | 229 +++++ src/main/ssh/ssh-relay-deploy.ts | 122 ++- ...ssh-relay-native-deps-cache-deploy.test.ts | 7 + src/main/ssh/ssh-relay-native-deps-cache.ts | 6 + .../ssh-relay-native-deps-install-fixture.ts | 6 + .../ssh/ssh-relay-native-deps-install.test.ts | 1 + ...sh-relay-native-deps-probe-verdict.test.ts | 6 + .../ssh-relay-node-pty-spawn-repair.test.ts | 4 + ...h-relay-pty-master-cloexec-install.test.ts | 336 ++++++++ .../ssh-relay-sftp-namespace-install.test.ts | 8 + src/shared/relay-artifacts.ts | 5 + src/shared/relay-optional-artifacts.test.ts | 8 + 15 files changed, 1865 insertions(+), 1 deletion(-) create mode 100644 config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs create mode 100644 config/scripts/__fixtures__/node-pty-1.1.0-unix-pty.cc create mode 100644 config/scripts/node-pty-master-cloexec-patch.test.mjs create mode 100644 src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts diff --git a/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs b/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs new file mode 100644 index 00000000000..f4f4f87619a --- /dev/null +++ b/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs @@ -0,0 +1,318 @@ +/** + * Relay-side pty-master close-on-exec patch for node-pty 1.1.0 (#17915). + * + * The app gets this through pnpm `patchedDependencies`; the relay installs stock + * node-pty from npm onto the host, where no pnpm patch reaches. Without it every + * later child of the relay -- pty children, git helpers, probes, agent CLIs -- + * inherits each live master fd and keeps its /dev/pts device alive for the life + * of the relay (#8362). + * + * Linux only, deliberately: it is the only relay platform that takes forkpty()'s + * no-atomic-O_CLOEXEC path, and the only one that already compiles node-pty at + * install time, so the rebuild costs a second compile rather than a first one. + * macOS re-opens the tty through uv_tty_init's cloexec dup and Windows has no fds. + * + * Non-fatal by construction: the working build is moved aside before anything is + * touched and moved back on any failure, and a failed attempt drops a skip marker + * so the compile is attempted at most once per relay directory. + */ + +const { spawnSync } = require('node:child_process') +const { createHash } = require('node:crypto') +const { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +} = require('node:fs') +const { dirname, join, resolve } = require('node:path') + +const EXPECTED_NODE_PTY_VERSION = '1.1.0' +const ORIGINAL_SOURCE_SHA256 = '5e1005d6bdcfbe97b486ee415419fe7adae99035047f07340fbad36419e0bae6' +const PATCHED_SOURCE_SHA256 = '97dea52199216c01b62070758f0f38621ae53adc16c221271dd35ae2d8ee3482' + +const STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' +const SKIP_MARKER_FILENAME = '.node-pty-cloexec-skip' +const BACKUP_DIRNAME = '.orca-cloexec-prepatch-release' +// Under the caller's 240s SSH command timeout, so the rollback below still runs. +const REBUILD_TIMEOUT_MS = 200000 +const VERIFY_TIMEOUT_MS = 15000 + +const FORWARD_DECLARATION = [ + 'static int\npty_nonblock(int);\n', + 'static int\npty_nonblock(int);\n\nstatic int\npty_cloexec(int);\n' +] + +const DEFINITION = [ + `static int +pty_nonblock(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags == -1) return -1; + return fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} +`, + `static int +pty_nonblock(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags == -1) return -1; + return fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} + +/** + * Orca: close-on-exec FD + * + * forkpty()/posix_openpt() have no atomic O_CLOEXEC, so a master left without + * FD_CLOEXEC is inherited by every later child of this process -- including + * later pty children -- which keeps its /dev/pts device and buffers alive long + * after its own session ends (#8362). + */ + +static int +pty_cloexec(int fd) { + int flags = fcntl(fd, F_GETFD); + if (flags == -1) return -1; + if (flags & FD_CLOEXEC) return 0; + return fcntl(fd, F_SETFD, flags | FD_CLOEXEC); +} +` +] + +const FORKPTY_CALL_SITE = [ + ` default: + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } + } +`, + ` default: + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } + if (pty_cloexec(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec."); + } + } +` +] + +const REPLACEMENTS = [FORWARD_DECLARATION, DEFINITION, FORKPTY_CALL_SITE] + +function sourceSha256(source) { + return createHash('sha256').update(source).digest('hex') +} + +function nodePtyDir(relayDir) { + return resolve(relayDir, 'node_modules', 'node-pty') +} + +function inspectNodePtyUnixSource(relayDir) { + const ptyDir = nodePtyDir(relayDir) + const sourcePath = join(ptyDir, 'src', 'unix', 'pty.cc') + const version = JSON.parse(readFileSync(join(ptyDir, 'package.json'), 'utf8')).version + if (version !== EXPECTED_NODE_PTY_VERSION) { + throw new Error(`Refusing to patch node-pty ${version}; expected ${EXPECTED_NODE_PTY_VERSION}`) + } + return { ptyDir, sourcePath, source: readFileSync(sourcePath, 'utf8') } +} + +function writeSourceAtomically(sourcePath, contents) { + const temporaryPath = `${sourcePath}.orca-patch-${process.pid}` + // Why: a terminated install must leave one of the two known source versions on disk. + try { + writeFileSync(temporaryPath, contents) + renameSync(temporaryPath, sourcePath) + } finally { + rmSync(temporaryPath, { force: true }) + } +} + +function rewriteSource(source, reverse) { + let rewritten = source + for (const [original, patched] of REPLACEMENTS) { + const from = reverse ? patched : original + const to = reverse ? original : patched + if (rewritten.split(from).length - 1 !== 1) { + throw new Error('Refusing to rewrite unexpected node-pty pty.cc source') + } + rewritten = rewritten.replace(from, to) + } + return rewritten +} + +/** True when the patch was applied, false when it was already installed. */ +function patchNodePtyMasterCloexecSource(relayDir = process.cwd()) { + const inspected = inspectNodePtyUnixSource(relayDir) + const hash = sourceSha256(inspected.source) + if (hash === PATCHED_SOURCE_SHA256) { + return false + } + if (hash !== ORIGINAL_SOURCE_SHA256) { + throw new Error('Refusing to patch unexpected node-pty pty.cc source') + } + writeSourceAtomically(inspected.sourcePath, rewriteSource(inspected.source, false)) + assertPatchedNodePtyMasterCloexecSource(relayDir) + return true +} + +function assertPatchedNodePtyMasterCloexecSource(relayDir = process.cwd()) { + const inspected = inspectNodePtyUnixSource(relayDir) + if (sourceSha256(inspected.source) !== PATCHED_SOURCE_SHA256) { + throw new Error('node-pty pty master close-on-exec patch is not installed') + } +} + +function revertNodePtyMasterCloexecSource(relayDir = process.cwd()) { + const inspected = inspectNodePtyUnixSource(relayDir) + if (sourceSha256(inspected.source) === ORIGINAL_SOURCE_SHA256) { + return false + } + writeSourceAtomically(inspected.sourcePath, rewriteSource(inspected.source, true)) + return true +} + +function rebuildNodePty(relayDir) { + const result = spawnSync('npm', ['rebuild', '--ignore-scripts=false', 'node-pty'], { + cwd: relayDir, + encoding: 'utf8', + timeout: REBUILD_TIMEOUT_MS, + windowsHide: true + }) + if (result.error) { + throw new Error(`npm rebuild node-pty failed: ${result.error.message}`) + } + if (result.status !== 0) { + const tail = `${result.stdout || ''}${result.stderr || ''}`.trim().slice(-300) + throw new Error(`npm rebuild node-pty exited ${result.status ?? result.signal}: ${tail}`) + } +} + +// Why a child: a bad build can abort the process on require, which would strand the +// moved-aside working build. Why the reachability check: a host without /proc cannot +// show inheritance, and an unobservable flag is not evidence the rebuild was wrong. +const VERIFY_SCRIPT = ` +const pty = require(process.argv[1]); +const term = pty.spawn('/bin/sh', ['-c', 'exit 0'], { + name: 'xterm-256color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env +}); +const probe = require('node:child_process').spawnSync('/bin/sh', ['-c', 'ls -l /proc/self/fd'], { encoding: 'utf8' }); +try { term.kill() } catch {} +const listing = probe.stdout || ''; +if (probe.status !== 0 || !listing.includes('->')) { console.log('UNVERIFIED'); process.exit(0) } +console.log(listing.includes('ptmx') ? 'INHERITED' : 'ISOLATED'); +process.exit(0); +` + +/** 'isolated' when a later plain child no longer inherits the master, 'unverified' when /proc cannot say. */ +function verifyMasterNotInheritedByLaterChild(relayDir) { + const result = spawnSync(process.execPath, ['-e', VERIFY_SCRIPT, nodePtyDir(relayDir)], { + cwd: relayDir, + encoding: 'utf8', + timeout: VERIFY_TIMEOUT_MS, + windowsHide: true + }) + const output = `${result.stdout || ''}` + if (result.status !== 0 || result.error) { + const tail = `${output}${result.stderr || ''}`.trim().slice(-300) + throw new Error( + `rebuilt node-pty did not load: ${tail || result.error?.message || result.signal}` + ) + } + if (output.includes('INHERITED')) { + throw new Error('rebuilt node-pty still leaks the pty master into later children') + } + return output.includes('ISOLATED') ? 'isolated' : 'unverified' +} + +function rollback(relayDir, releaseDir, backupDir) { + rmSync(releaseDir, { recursive: true, force: true }) + try { + revertNodePtyMasterCloexecSource(relayDir) + } catch { + // The build that is about to be restored predates the patch either way. + } + if (existsSync(backupDir)) { + mkdirSync(dirname(releaseDir), { recursive: true }) + renameSync(backupDir, releaseDir) + } +} + +/** + * Patch and rebuild the host's node-pty, or leave it exactly as found. + * Never throws: the caller is on the connect path and a leaky relay beats no relay. + */ +function applyNodePtyMasterCloexecPatch(relayDir = process.cwd(), options = {}) { + const platform = options.platform || process.platform + const rebuild = options.rebuild || rebuildNodePty + const verify = options.verify || verifyMasterNotInheritedByLaterChild + if (platform !== 'linux') { + return 'skipped:not-linux' + } + const skipMarkerPath = join(relayDir, SKIP_MARKER_FILENAME) + if (existsSync(skipMarkerPath)) { + return 'skipped:earlier-attempt-failed' + } + const releaseDir = join(nodePtyDir(relayDir), 'build', 'Release') + const backupDir = join(nodePtyDir(relayDir), BACKUP_DIRNAME) + // A backup stranded by a connection that died mid-rebuild is stale by definition: + // whatever repaired node-pty since built from the source now on disk. + rmSync(backupDir, { recursive: true, force: true }) + + let inspected + try { + inspected = inspectNodePtyUnixSource(relayDir) + } catch (err) { + return `skipped:${err.message}` + } + const hash = sourceSha256(inspected.source) + if (hash === PATCHED_SOURCE_SHA256) { + return 'already-patched' + } + if (hash !== ORIGINAL_SOURCE_SHA256) { + return 'skipped:unexpected-source' + } + // No compiled build means the host runs a prebuild or nothing at all; rebuilding + // could only take away the artifact the probe just proved loadable. + if (!existsSync(join(releaseDir, 'pty.node'))) { + return 'skipped:no-compiled-build' + } + + try { + renameSync(releaseDir, backupDir) + } catch (err) { + return `skipped:${err.message}` + } + try { + patchNodePtyMasterCloexecSource(relayDir) + rebuild(relayDir) + const verdict = verify(relayDir) + rmSync(backupDir, { recursive: true, force: true }) + return verdict === 'isolated' ? 'patched' : 'patched-unverified' + } catch (err) { + rollback(relayDir, releaseDir, backupDir) + // Bounded on purpose: one compile attempt per relay directory, never a retry loop. + try { + writeFileSync(skipMarkerPath, `${new Date().toISOString()} ${err.message}\n`) + } catch { + // A relay dir we cannot write to will fail the cheap checks above next time anyway. + } + return `failed:${err.message}` + } +} + +if (require.main === module) { + console.log(`${STATUS_PREFIX}${applyNodePtyMasterCloexecPatch()}`) +} + +module.exports = { + EXPECTED_NODE_PTY_VERSION, + ORIGINAL_SOURCE_SHA256, + PATCHED_SOURCE_SHA256, + SKIP_MARKER_FILENAME, + STATUS_PREFIX, + applyNodePtyMasterCloexecPatch, + assertPatchedNodePtyMasterCloexecSource, + patchNodePtyMasterCloexecSource, + revertNodePtyMasterCloexecSource +} diff --git a/config/scripts/__fixtures__/node-pty-1.1.0-unix-pty.cc b/config/scripts/__fixtures__/node-pty-1.1.0-unix-pty.cc new file mode 100644 index 00000000000..7b4b9e1f990 --- /dev/null +++ b/config/scripts/__fixtures__/node-pty-1.1.0-unix-pty.cc @@ -0,0 +1,799 @@ +/** + * Copyright (c) 2012-2015, Christopher Jeffrey (MIT License) + * Copyright (c) 2017, Daniel Imms (MIT License) + * + * pty.cc: + * This file is responsible for starting processes + * with pseudo-terminal file descriptors. + * + * See: + * man pty + * man tty_ioctl + * man termios + * man forkpty + */ + +/** + * Includes + */ + +#define NODE_ADDON_API_DISABLE_DEPRECATED +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +/* forkpty */ +/* http://www.gnu.org/software/gnulib/manual/html_node/forkpty.html */ +#if defined(__linux__) +#include +#elif defined(__APPLE__) +#include +#elif defined(__FreeBSD__) +#include +#include +#elif defined(__OpenBSD__) +#include +#include +#endif + +/* Some platforms name VWERASE and VDISCARD differently */ +#if !defined(VWERASE) && defined(VWERSE) +#define VWERASE VWERSE +#endif +#if !defined(VDISCARD) && defined(VDISCRD) +#define VDISCARD VDISCRD +#endif + +/* for pty_getproc */ +#if defined(__linux__) +#include +#include +#elif defined(__APPLE__) +#include +#include +#include +#include +#include +#include +#include +#endif + +/* NSIG - macro for highest signal + 1, should be defined */ +#ifndef NSIG +#define NSIG 32 +#endif + +/* macOS 10.14 back does not define this constant */ +#ifndef POSIX_SPAWN_SETSID + #define POSIX_SPAWN_SETSID 1024 +#endif + +/* environ for execvpe */ +/* node/src/node_child_process.cc */ +#if !defined(__APPLE__) +extern char **environ; +#endif + +#if defined(__APPLE__) +extern "C" { +// Changes the current thread's directory to a path or directory file +// descriptor. libpthread only exposes a syscall wrapper starting in +// macOS 10.12, but the system call dates back to macOS 10.5. On older OSes, +// the syscall is issued directly. +int pthread_chdir_np(const char* dir) API_AVAILABLE(macosx(10.12)); +int pthread_fchdir_np(int fd) API_AVAILABLE(macosx(10.12)); +} + +#define HANDLE_EINTR(x) ({ \ + int eintr_wrapper_counter = 0; \ + decltype(x) eintr_wrapper_result; \ + do { \ + eintr_wrapper_result = (x); \ + } while (eintr_wrapper_result == -1 && errno == EINTR && \ + eintr_wrapper_counter++ < 100); \ + eintr_wrapper_result; \ +}) +#endif + +struct ExitEvent { + int exit_code = 0, signal_code = 0; +}; + +void SetupExitCallback(Napi::Env env, Napi::Function cb, pid_t pid) { + std::thread *th = new std::thread; + // Don't use Napi::AsyncWorker which is limited by UV_THREADPOOL_SIZE. + auto tsfn = Napi::ThreadSafeFunction::New( + env, + cb, // JavaScript function called asynchronously + "SetupExitCallback_resource", // Name + 0, // Unlimited queue + 1, // Only one thread will use this initially + [th](Napi::Env) { // Finalizer used to clean threads up + th->join(); + delete th; + }); + *th = std::thread([tsfn = std::move(tsfn), pid] { + auto callback = [](Napi::Env env, Napi::Function cb, ExitEvent *exit_event) { + cb.Call({Napi::Number::New(env, exit_event->exit_code), + Napi::Number::New(env, exit_event->signal_code)}); + delete exit_event; + }; + + int ret; + int stat_loc; +#if defined(__APPLE__) + // Based on + // https://source.chromium.org/chromium/chromium/src/+/main:base/process/kill_mac.cc;l=35-69? + int kq = HANDLE_EINTR(kqueue()); + struct kevent change = {0}; + EV_SET(&change, pid, EVFILT_PROC, EV_ADD, NOTE_EXIT, 0, NULL); + ret = HANDLE_EINTR(kevent(kq, &change, 1, NULL, 0, NULL)); + if (ret == -1) { + if (errno == ESRCH) { + // At this point, one of the following has occurred: + // 1. The process has died but has not yet been reaped. + // 2. The process has died and has already been reaped. + // 3. The process is in the process of dying. It's no longer + // kqueueable, but it may not be waitable yet either. Mark calls + // this case the "zombie death race". + ret = HANDLE_EINTR(waitpid(pid, &stat_loc, WNOHANG)); + if (ret == 0) { + ret = kill(pid, SIGKILL); + if (ret != -1) { + HANDLE_EINTR(waitpid(pid, &stat_loc, 0)); + } + } + } + } else { + struct kevent event = {0}; + ret = HANDLE_EINTR(kevent(kq, NULL, 0, &event, 1, NULL)); + if (ret == 1) { + if ((event.fflags & NOTE_EXIT) && + (event.ident == static_cast(pid))) { + // The process is dead or dying. This won't block for long, if at + // all. + HANDLE_EINTR(waitpid(pid, &stat_loc, 0)); + } + } + } +#else + while (true) { + errno = 0; + if ((ret = waitpid(pid, &stat_loc, 0)) != pid) { + if (ret == -1 && errno == EINTR) { + continue; + } + if (ret == -1 && errno == ECHILD) { + // XXX node v0.8.x seems to have this problem. + // waitpid is already handled elsewhere. + ; + } else { + assert(false); + } + } + break; + } +#endif + ExitEvent *exit_event = new ExitEvent; + if (WIFEXITED(stat_loc)) { + exit_event->exit_code = WEXITSTATUS(stat_loc); // errno? + } + if (WIFSIGNALED(stat_loc)) { + exit_event->signal_code = WTERMSIG(stat_loc); + } + auto status = tsfn.BlockingCall(exit_event, callback); // In main thread + switch (status) { + case napi_closing: + break; + + case napi_queue_full: + Napi::Error::Fatal("SetupExitCallback", "Queue was full"); + + case napi_ok: + if (tsfn.Release() != napi_ok) { + Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.Release() failed"); + } + break; + + default: + Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.BlockingCall() failed"); + } + }); +} + +/** + * Methods + */ + +Napi::Value PtyFork(const Napi::CallbackInfo& info); +Napi::Value PtyOpen(const Napi::CallbackInfo& info); +Napi::Value PtyResize(const Napi::CallbackInfo& info); +Napi::Value PtyGetProc(const Napi::CallbackInfo& info); + +/** + * Functions + */ + +static int +pty_nonblock(int); + +#if defined(__APPLE__) +static char * +pty_getproc(int); +#else +static char * +pty_getproc(int, char *); +#endif + +#if defined(__APPLE__) || defined(__OpenBSD__) +static void +pty_posix_spawn(char** argv, char** env, + const struct termios *termp, + const struct winsize *winp, + int* master, + pid_t* pid, + int* err); +#endif + +struct DelBuf { + int len; + DelBuf(int len) : len(len) {} + void operator()(char **p) { + if (p == nullptr) + return; + for (int i = 0; i < len; i++) + free(p[i]); + delete[] p; + } +}; + +Napi::Value PtyFork(const Napi::CallbackInfo& info) { + Napi::Env napiEnv(info.Env()); + Napi::HandleScope scope(napiEnv); + + if (info.Length() != 11 || + !info[0].IsString() || + !info[1].IsArray() || + !info[2].IsArray() || + !info[3].IsString() || + !info[4].IsNumber() || + !info[5].IsNumber() || + !info[6].IsNumber() || + !info[7].IsNumber() || + !info[8].IsBoolean() || + !info[9].IsString() || + !info[10].IsFunction()) { + throw Napi::Error::New(napiEnv, "Usage: pty.fork(file, args, env, cwd, cols, rows, uid, gid, utf8, helperPath, onexit)"); + } + + // file + std::string file = info[0].As(); + + // args + Napi::Array argv_ = info[1].As(); + + // env + Napi::Array env_ = info[2].As(); + int envc = env_.Length(); + std::unique_ptr env_unique_ptr(new char *[envc + 1], DelBuf(envc + 1)); + char **env = env_unique_ptr.get(); + env[envc] = NULL; + for (int i = 0; i < envc; i++) { + std::string pair = env_.Get(i).As(); + env[i] = strdup(pair.c_str()); + } + + // cwd + std::string cwd_ = info[3].As(); + + // size + struct winsize winp; + winp.ws_col = info[4].As().Int32Value(); + winp.ws_row = info[5].As().Int32Value(); + winp.ws_xpixel = 0; + winp.ws_ypixel = 0; + +#if !defined(__APPLE__) + // uid / gid + int uid = info[6].As().Int32Value(); + int gid = info[7].As().Int32Value(); +#endif + + // termios + struct termios t = termios(); + struct termios *term = &t; + term->c_iflag = ICRNL | IXON | IXANY | IMAXBEL | BRKINT; + if (info[8].As().Value()) { +#if defined(IUTF8) + term->c_iflag |= IUTF8; +#endif + } + term->c_oflag = OPOST | ONLCR; + term->c_cflag = CREAD | CS8 | HUPCL; + term->c_lflag = ICANON | ISIG | IEXTEN | ECHO | ECHOE | ECHOK | ECHOKE | ECHOCTL; + + term->c_cc[VEOF] = 4; + term->c_cc[VEOL] = -1; + term->c_cc[VEOL2] = -1; + term->c_cc[VERASE] = 0x7f; + term->c_cc[VWERASE] = 23; + term->c_cc[VKILL] = 21; + term->c_cc[VREPRINT] = 18; + term->c_cc[VINTR] = 3; + term->c_cc[VQUIT] = 0x1c; + term->c_cc[VSUSP] = 26; + term->c_cc[VSTART] = 17; + term->c_cc[VSTOP] = 19; + term->c_cc[VLNEXT] = 22; + term->c_cc[VDISCARD] = 15; + term->c_cc[VMIN] = 1; + term->c_cc[VTIME] = 0; + + #if (__APPLE__) + term->c_cc[VDSUSP] = 25; + term->c_cc[VSTATUS] = 20; + #endif + + cfsetispeed(term, B38400); + cfsetospeed(term, B38400); + + // helperPath + std::string helper_path = info[9].As(); + + pid_t pid; + int master; +#if defined(__APPLE__) + int argc = argv_.Length(); + int argl = argc + 4; + std::unique_ptr argv_unique_ptr(new char *[argl], DelBuf(argl)); + char **argv = argv_unique_ptr.get(); + argv[0] = strdup(helper_path.c_str()); + argv[1] = strdup(cwd_.c_str()); + argv[2] = strdup(file.c_str()); + argv[argl - 1] = NULL; + for (int i = 0; i < argc; i++) { + std::string arg = argv_.Get(i).As(); + argv[i + 3] = strdup(arg.c_str()); + } + + int err = -1; + pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err); + if (err != 0) { + throw Napi::Error::New(napiEnv, "posix_spawnp failed."); + } + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } +#else + int argc = argv_.Length(); + int argl = argc + 2; + std::unique_ptr argv_unique_ptr(new char *[argl], DelBuf(argl)); + char** argv = argv_unique_ptr.get(); + argv[0] = strdup(file.c_str()); + argv[argl - 1] = NULL; + for (int i = 0; i < argc; i++) { + std::string arg = argv_.Get(i).As(); + argv[i + 1] = strdup(arg.c_str()); + } + + sigset_t newmask, oldmask; + struct sigaction sig_action; + // temporarily block all signals + // this is needed due to a race condition in openpty + // and to avoid running signal handlers in the child + // before exec* happened + sigfillset(&newmask); + pthread_sigmask(SIG_SETMASK, &newmask, &oldmask); + + pid = forkpty(&master, nullptr, static_cast(term), static_cast(&winp)); + + if (!pid) { + // remove all signal handler from child + sig_action.sa_handler = SIG_DFL; + sig_action.sa_flags = 0; + sigemptyset(&sig_action.sa_mask); + for (int i = 0 ; i < NSIG ; i++) { // NSIG is a macro for all signals + 1 + sigaction(i, &sig_action, NULL); + } + } + + // reenable signals + pthread_sigmask(SIG_SETMASK, &oldmask, NULL); + + switch (pid) { + case -1: + throw Napi::Error::New(napiEnv, "forkpty(3) failed."); + case 0: + if (strlen(cwd_.c_str())) { + if (chdir(cwd_.c_str()) == -1) { + perror("chdir(2) failed."); + _exit(1); + } + } + + if (uid != -1 && gid != -1) { + if (setgid(gid) == -1) { + perror("setgid(2) failed."); + _exit(1); + } + if (setuid(uid) == -1) { + perror("setuid(2) failed."); + _exit(1); + } + } + + { + char **old = environ; + environ = env; + execvp(argv[0], argv); + environ = old; + perror("execvp(3) failed."); + _exit(1); + } + default: + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } + } +#endif + + Napi::Object obj = Napi::Object::New(napiEnv); + obj.Set("fd", Napi::Number::New(napiEnv, master)); + obj.Set("pid", Napi::Number::New(napiEnv, pid)); + obj.Set("pty", Napi::String::New(napiEnv, ptsname(master))); + + // Set up process exit callback. + Napi::Function cb = info[10].As(); + SetupExitCallback(napiEnv, cb, pid); + return obj; +} + +Napi::Value PtyOpen(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + if (info.Length() != 2 || + !info[0].IsNumber() || + !info[1].IsNumber()) { + throw Napi::Error::New(env, "Usage: pty.open(cols, rows)"); + } + + // size + struct winsize winp; + winp.ws_col = info[0].As().Int32Value(); + winp.ws_row = info[1].As().Int32Value(); + winp.ws_xpixel = 0; + winp.ws_ypixel = 0; + + // pty + int master, slave; + int ret = openpty(&master, &slave, nullptr, NULL, static_cast(&winp)); + + if (ret == -1) { + throw Napi::Error::New(env, "openpty(3) failed."); + } + + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(env, "Could not set master fd to nonblocking."); + } + + if (pty_nonblock(slave) == -1) { + throw Napi::Error::New(env, "Could not set slave fd to nonblocking."); + } + + Napi::Object obj = Napi::Object::New(env); + obj.Set("master", Napi::Number::New(env, master)); + obj.Set("slave", Napi::Number::New(env, slave)); + obj.Set("pty", Napi::String::New(env, ptsname(master))); + + return obj; +} + +Napi::Value PtyResize(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + if (info.Length() != 3 || + !info[0].IsNumber() || + !info[1].IsNumber() || + !info[2].IsNumber()) { + throw Napi::Error::New(env, "Usage: pty.resize(fd, cols, rows)"); + } + + int fd = info[0].As().Int32Value(); + + struct winsize winp; + winp.ws_col = info[1].As().Int32Value(); + winp.ws_row = info[2].As().Int32Value(); + winp.ws_xpixel = 0; + winp.ws_ypixel = 0; + + if (ioctl(fd, TIOCSWINSZ, &winp) == -1) { + switch (errno) { + case EBADF: + throw Napi::Error::New(env, "ioctl(2) failed, EBADF"); + case EFAULT: + throw Napi::Error::New(env, "ioctl(2) failed, EFAULT"); + case EINVAL: + throw Napi::Error::New(env, "ioctl(2) failed, EINVAL"); + case ENOTTY: + throw Napi::Error::New(env, "ioctl(2) failed, ENOTTY"); + } + throw Napi::Error::New(env, "ioctl(2) failed"); + } + + return env.Undefined(); +} + +/** + * Foreground Process Name + */ +Napi::Value PtyGetProc(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + +#if defined(__APPLE__) + if (info.Length() != 1 || + !info[0].IsNumber()) { + throw Napi::Error::New(env, "Usage: pty.process(pid)"); + } + + int fd = info[0].As().Int32Value(); + char *name = pty_getproc(fd); +#else + if (info.Length() != 2 || + !info[0].IsNumber() || + !info[1].IsString()) { + throw Napi::Error::New(env, "Usage: pty.process(fd, tty)"); + } + + int fd = info[0].As().Int32Value(); + + std::string tty_ = info[1].As(); + char *tty = strdup(tty_.c_str()); + char *name = pty_getproc(fd, tty); + free(tty); +#endif + + if (name == NULL) { + return env.Undefined(); + } + + Napi::String name_ = Napi::String::New(env, name); + free(name); + return name_; +} + +/** + * Nonblocking FD + */ + +static int +pty_nonblock(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags == -1) return -1; + return fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} + +/** + * pty_getproc + * Taken from tmux. + */ + +// Taken from: tmux (http://tmux.sourceforge.net/) +// Copyright (c) 2009 Nicholas Marriott +// Copyright (c) 2009 Joshua Elsasser +// Copyright (c) 2009 Todd Carson +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF MIND, USE, DATA OR PROFITS, WHETHER +// IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +// OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +#if defined(__linux__) + +static char * +pty_getproc(int fd, char *tty) { + FILE *f; + char *path, *buf; + size_t len; + int ch; + pid_t pgrp; + int r; + + if ((pgrp = tcgetpgrp(fd)) == -1) { + return NULL; + } + + r = asprintf(&path, "/proc/%lld/cmdline", (long long)pgrp); + if (r == -1 || path == NULL) return NULL; + + if ((f = fopen(path, "r")) == NULL) { + free(path); + return NULL; + } + + free(path); + + len = 0; + buf = NULL; + while ((ch = fgetc(f)) != EOF) { + if (ch == '\0') break; + buf = (char *)realloc(buf, len + 2); + if (buf == NULL) return NULL; + buf[len++] = ch; + } + + if (buf != NULL) { + buf[len] = '\0'; + } + + fclose(f); + return buf; +} + +#elif defined(__APPLE__) + +static char * +pty_getproc(int fd) { + int mib[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, 0 }; + size_t size; + struct kinfo_proc kp; + + if ((mib[3] = tcgetpgrp(fd)) == -1) { + return NULL; + } + + size = sizeof kp; + if (sysctl(mib, 4, &kp, &size, NULL, 0) == -1) { + return NULL; + } + + if (size != (sizeof kp) || *kp.kp_proc.p_comm == '\0') { + return NULL; + } + + return strdup(kp.kp_proc.p_comm); +} + +#else + +static char * +pty_getproc(int fd, char *tty) { + return NULL; +} + +#endif + +#if defined(__APPLE__) +static void +pty_posix_spawn(char** argv, char** env, + const struct termios *termp, + const struct winsize *winp, + int* master, + pid_t* pid, + int* err) { + int low_fds[3]; + size_t count = 0; + + for (; count < 3; count++) { + low_fds[count] = posix_openpt(O_RDWR); + if (low_fds[count] >= STDERR_FILENO) + break; + } + + int flags = POSIX_SPAWN_CLOEXEC_DEFAULT | + POSIX_SPAWN_SETSIGDEF | + POSIX_SPAWN_SETSIGMASK | + POSIX_SPAWN_SETSID; + *master = posix_openpt(O_RDWR); + if (*master == -1) { + return; + } + + int res = grantpt(*master) || unlockpt(*master); + if (res == -1) { + return; + } + + // Use TIOCPTYGNAME instead of ptsname() to avoid threading problems. + int slave; + char slave_pty_name[128]; + res = ioctl(*master, TIOCPTYGNAME, slave_pty_name); + if (res == -1) { + return; + } + + slave = open(slave_pty_name, O_RDWR | O_NOCTTY); + if (slave == -1) { + return; + } + + if (termp) { + res = tcsetattr(slave, TCSANOW, termp); + if (res == -1) { + return; + }; + } + + if (winp) { + res = ioctl(slave, TIOCSWINSZ, winp); + if (res == -1) { + return; + } + } + + posix_spawn_file_actions_t acts; + posix_spawn_file_actions_init(&acts); + posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO); + posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO); + posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO); + posix_spawn_file_actions_addclose(&acts, slave); + posix_spawn_file_actions_addclose(&acts, *master); + + posix_spawnattr_t attrs; + posix_spawnattr_init(&attrs); + *err = posix_spawnattr_setflags(&attrs, flags); + if (*err != 0) { + goto done; + } + + sigset_t signal_set; + /* Reset all signal the child to their default behavior */ + sigfillset(&signal_set); + *err = posix_spawnattr_setsigdefault(&attrs, &signal_set); + if (*err != 0) { + goto done; + } + + /* Reset the signal mask for all signals */ + sigemptyset(&signal_set); + *err = posix_spawnattr_setsigmask(&attrs, &signal_set); + if (*err != 0) { + goto done; + } + + do + *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env); + while (*err == EINTR); +done: + posix_spawn_file_actions_destroy(&acts); + posix_spawnattr_destroy(&attrs); + + for (; count > 0; count--) { + close(low_fds[count]); + } +} +#endif + +/** + * Init + */ + +Napi::Object init(Napi::Env env, Napi::Object exports) { + exports.Set("fork", Napi::Function::New(env, PtyFork)); + exports.Set("open", Napi::Function::New(env, PtyOpen)); + exports.Set("resize", Napi::Function::New(env, PtyResize)); + exports.Set("process", Napi::Function::New(env, PtyGetProc)); + return exports; +} + +NODE_API_MODULE(NODE_GYP_MODULE_NAME, init) diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 506036ede3a..289c7a957bd 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -57,6 +57,13 @@ const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join( 'relay-assets', NODE_PTY_CONSOLE_LIST_PATCH_FILENAME ) +const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs' +const NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE = join( + ROOT, + 'config', + 'relay-assets', + NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME +) // Written by build-windows-process-tree-relay-addon.mjs, which only runs on a // Windows machine. const WINDOWS_PROCESS_TREE_BUILD_DIR = join(ROOT, '.build', 'windows-process-tree') @@ -126,6 +133,10 @@ for (const platform of RELAY_BUILD_PLATFORMS) { join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME) ) } + copyFileSync( + NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE, + join(outDir, NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME) + ) stageWindowsProcessTreeAddon(platform, outDir) await build({ diff --git a/config/scripts/node-pty-master-cloexec-patch.test.mjs b/config/scripts/node-pty-master-cloexec-patch.test.mjs new file mode 100644 index 00000000000..16013cbf0a3 --- /dev/null +++ b/config/scripts/node-pty-master-cloexec-patch.test.mjs @@ -0,0 +1,229 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + SKIP_MARKER_FILENAME, + applyNodePtyMasterCloexecPatch, + assertPatchedNodePtyMasterCloexecSource, + patchNodePtyMasterCloexecSource, + revertNodePtyMasterCloexecSource +} = require('../relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs') + +// Byte-exact src/unix/pty.cc from the npm tarball the relay installs. The patch is keyed by its +// sha256, so a fixture that drifted from what npm ships would make every assertion below vacuous. +const STOCK_SOURCE = readFileSync( + resolve(import.meta.dirname, '__fixtures__', 'node-pty-1.1.0-unix-pty.cc'), + 'utf8' +) +const projectDir = resolve(import.meta.dirname, '..', '..') +const cleanupDirs = [] + +afterEach(() => { + for (const dir of cleanupDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('SSH relay node-pty pty-master close-on-exec patch', () => { + it('adds the forkpty close-on-exec call and reverts to the published bytes', () => { + const fixture = writeRelayFixture() + + expect(patchNodePtyMasterCloexecSource(fixture.root)).toBe(true) + const patched = readFileSync(fixture.sourcePath, 'utf8') + expect(patched).toContain('pty_cloexec(int fd)') + expect(patched).toContain('if (pty_cloexec(master) == -1)') + expect(() => assertPatchedNodePtyMasterCloexecSource(fixture.root)).not.toThrow() + + expect(patchNodePtyMasterCloexecSource(fixture.root)).toBe(false) + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(patched) + + expect(revertNodePtyMasterCloexecSource(fixture.root)).toBe(true) + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) + }) + + it('refuses a different node-pty version or an unrecognized source', () => { + const wrongVersion = writeRelayFixture({ version: '1.2.0-beta.4' }) + expect(() => patchNodePtyMasterCloexecSource(wrongVersion.root)).toThrow('expected 1.1.0') + + const drifted = writeRelayFixture({ + source: `${STOCK_SOURCE}\n// drift\n` + }) + expect(() => patchNodePtyMasterCloexecSource(drifted.root)).toThrow('unexpected node-pty') + + const tampered = writeRelayFixture() + patchNodePtyMasterCloexecSource(tampered.root) + writeFileSync(tampered.sourcePath, `${readFileSync(tampered.sourcePath, 'utf8')}\n// drift\n`) + expect(() => assertPatchedNodePtyMasterCloexecSource(tampered.root)).toThrow('not installed') + }) + + it('keeps the rebuilt addon once a later child no longer inherits the master', () => { + const fixture = writeRelayFixture() + const calls = [] + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => { + calls.push('rebuild') + writeBuild(fixture, 'patched-build') + }, + verify: () => 'isolated' + }) + + expect(status).toBe('patched') + expect(calls).toEqual(['rebuild']) + expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build') + expect(readFileSync(fixture.sourcePath, 'utf8')).not.toBe(STOCK_SOURCE) + expect(existsSync(fixture.backupDir)).toBe(false) + expect(existsSync(fixture.skipMarkerPath)).toBe(false) + }) + + it('keeps a rebuilt addon whose flag /proc could not confirm', () => { + const fixture = writeRelayFixture() + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => writeBuild(fixture, 'patched-build'), + verify: () => 'unverified' + }) + + expect(status).toBe('patched-unverified') + expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build') + }) + + it('restores the working build when the compile fails, and never retries it', () => { + const fixture = writeRelayFixture() + const calls = [] + + const failed = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => { + calls.push('rebuild') + throw new Error('npm rebuild node-pty exited 1: no C++ toolchain') + }, + verify: () => 'isolated' + }) + + expect(failed).toContain('failed:') + expect(failed).toContain('no C++ toolchain') + expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build') + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) + expect(existsSync(fixture.backupDir)).toBe(false) + expect(existsSync(fixture.skipMarkerPath)).toBe(true) + + // Bounded, not backed off: a relay directory gets one compile attempt, ever. + const again = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => calls.push('rebuild'), + verify: () => 'isolated' + }) + expect(again).toBe('skipped:earlier-attempt-failed') + expect(calls).toEqual(['rebuild']) + }) + + it('restores the working build when the rebuilt addon still leaks the master', () => { + const fixture = writeRelayFixture() + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => writeBuild(fixture, 'still-leaky-build'), + verify: () => { + throw new Error('rebuilt node-pty still leaks the pty master into later children') + } + }) + + expect(status).toContain('still leaks') + expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build') + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) + }) + + it('never compiles on a platform that does not leak', () => { + for (const platform of ['darwin', 'win32']) { + const fixture = writeRelayFixture() + const calls = [] + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform, + rebuild: () => calls.push('rebuild'), + verify: () => 'isolated' + }) + expect(status).toBe('skipped:not-linux') + expect(calls).toEqual([]) + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) + } + }) + + it('leaves an already patched install alone', () => { + const fixture = writeRelayFixture() + patchNodePtyMasterCloexecSource(fixture.root) + const calls = [] + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => calls.push('rebuild'), + verify: () => 'isolated' + }) + + expect(status).toBe('already-patched') + expect(calls).toEqual([]) + }) + + it('will not rebuild an install that has no compiled addon to fall back on', () => { + const fixture = writeRelayFixture({ build: false }) + const calls = [] + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => calls.push('rebuild'), + verify: () => 'isolated' + }) + + expect(status).toBe('skipped:no-compiled-build') + expect(calls).toEqual([]) + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) + }) + + it('discards a backup stranded by an interrupted rebuild', () => { + const fixture = writeRelayFixture() + mkdirSync(fixture.backupDir, { recursive: true }) + writeFileSync(join(fixture.backupDir, 'pty.node'), 'stranded-build') + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => writeBuild(fixture, 'patched-build'), + verify: () => 'isolated' + }) + + expect(status).toBe('patched') + expect(existsSync(fixture.backupDir)).toBe(false) + expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build') + }) +}) + +function writeRelayFixture({ version = '1.1.0', source = STOCK_SOURCE, build = true } = {}) { + const root = mkdtempSync(join(projectDir, '.node-pty-cloexec-patch-test-')) + cleanupDirs.push(root) + const nodePtyDir = join(root, 'node_modules', 'node-pty') + const sourcePath = join(nodePtyDir, 'src', 'unix', 'pty.cc') + const buildPath = join(nodePtyDir, 'build', 'Release', 'pty.node') + mkdirSync(join(nodePtyDir, 'src', 'unix'), { recursive: true }) + writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version })) + writeFileSync(sourcePath, source) + const fixture = { + root, + sourcePath, + buildPath, + backupDir: join(nodePtyDir, '.orca-cloexec-prepatch-release'), + skipMarkerPath: join(root, SKIP_MARKER_FILENAME) + } + if (build) { + writeBuild(fixture, 'stock-build') + } + return fixture +} + +function writeBuild(fixture, contents) { + mkdirSync(resolve(fixture.buildPath, '..'), { recursive: true }) + writeFileSync(fixture.buildPath, contents) +} diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 54c64a80b20..fc65af35c0a 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -727,6 +727,31 @@ function uploadStageNamespaceIfSupported( const NODE_PTY_VERSION = '1.1.0' const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' +const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs' +const NODE_PTY_CLOEXEC_STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' +/** + * Whether the tree the patch left behind still leaks the pty master into every later child. + * `fixed` is the only outcome a shared cache entry may be published from. + */ +type NodePtyMasterCloexecOutcome = 'fixed' | 'unfixed' +/** + * The statuses that leave a non-leaking tree. Deliberately an allowlist, not a `failed:` denylist: + * the script's `skipped:` family is mixed. `skipped:not-linux` is a platform that never leaks, but + * `skipped:earlier-attempt-failed`, `skipped:no-compiled-build`, `skipped:unexpected-source` and + * the two `skipped:` forms all mean the patch was refused and the leaky build is still on + * disk -- indistinguishable from `failed:` as far as what gets published. + */ +const NODE_PTY_CLOEXEC_FIXED_STATUSES: ReadonlySet = new Set([ + 'patched', + // The rebuild ran from patched source; only the isolation check could not observe the result. + // An unobservable check is not a failed patch, and treating it as one would disable the shared + // cache on every host without `lsof`. + 'patched-unverified', + 'already-patched', + // Unreachable while the platform gate below short-circuits first, but it is the one `skipped:` + // that means "nothing to fix" rather than "would not fix it". + 'skipped:not-linux' +]) // Exported for the relay-native-dependency-coverage test, which asserts every // native addon the relay bundle imports is either installed here or explicitly // declared as degrading without it. @@ -1213,10 +1238,35 @@ async function installNativeDeps( } } + // Why this precedes promotion: the patch renames `node-pty/build/Release`, runs `npm rebuild` + // and rolls back inside `node_modules`, and promotion turns that directory into a symlink to a + // published -- and by contract immutable -- shared cache entry. Patching afterwards would write + // through the link, and `.deps-complete` would already have published an unpatched tree that + // every later host links and skips. + const cloexec = probe.available + ? await applyNodePtyMasterCloexecPatch( + conn, + remoteDir, + platform, + hostPlatform, + nodePath, + signal + ) + : 'unfixed' + // Why promotion is gated on the probe and not on npm's exit code: an entry is shared, so the // only evidence worth publishing is this host having loaded both addons out of that tree. + // Why it is gated on the patch too: a refused or rolled-back patch leaves the pre-patch leaky + // build in place, and the cache key hashes this patch's bytes -- so publishing it would hand + // every later host on the machine a tree that links, probes loadable, and skips patching. if (probe.available && cacheContext && cache) { - await promoteRelayNativeDepsCache(conn, cacheContext, cache.key) + if (cloexec === 'fixed') { + await promoteRelayNativeDepsCache(conn, cacheContext, cache.key) + } else { + console.warn( + `[ssh-relay][NPTY-CLOEXEC-UNSHARED] keeping the native deps at ${remoteDir} (${platform}) private; the tree still leaks the pty master, so it is not publishable as ${cache.key}` + ) + } } // MISSING is non-fatal by design: the relay still serves fs/git/preflight; only native-backed ops fail on hosts that can't build the addons. @@ -1227,6 +1277,76 @@ async function installNativeDeps( } } +/** + * Re-apply the pty-master FD_CLOEXEC patch the app gets from pnpm to the host's npm copy (#17915). + * + * Why it is safe to rebuild under a live relay: this only runs from installNativeDeps, so only on a + * freshly created directory or a locked repair, and a relay already serving PTYs has pty.node mapped + * -- replacing the file on disk does not touch the running process. It keeps the build it started + * with and picks up the patched one when it restarts. + * + * Why it is bounded: the remote script attempts the compile at most once per relay directory, and + * the directory is content-hashed over the relay manifest -- so at most one compile per bundle. + * + * Why a shared cache entry never reaches here: the caller returns as soon as a linked tree probes + * loadable, so this only ever rewrites a `node_modules` the relay directory still owns privately. + * + * Returns whether the tree that is left behind still leaks, which is what decides publishability. + * The script exits 0 on every outcome by design, so the status line is the only evidence there is. + */ +async function applyNodePtyMasterCloexecPatch( + conn: SshConnection, + remoteDir: string, + platform: RelayPlatform, + hostPlatform: RemoteHostPlatform, + nodePath: string, + signal?: AbortSignal +): Promise { + // Linux is the only relay platform that takes forkpty()'s no-O_CLOEXEC path; macOS and Windows + // ship prebuilds, so forcing a rebuild there would add a first compile to fix nothing. + if (isWindowsRemoteHost(hostPlatform) || !platform.startsWith('linux')) { + return 'fixed' + } + try { + const command = commandWithNodePath( + hostPlatform, + nodePath, + remoteDir, + `${shellEscape(nodePath)} ${shellEscape(NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME)} 2>&1` + ) + const output = await execHostCommand(conn, hostPlatform, command, { + timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, + signal + }) + const status = + output + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.startsWith(NODE_PTY_CLOEXEC_STATUS_PREFIX)) + ?.slice(NODE_PTY_CLOEXEC_STATUS_PREFIX.length) ?? 'no-status' + if (!NODE_PTY_CLOEXEC_FIXED_STATUSES.has(status)) { + // Warn, not log: the script exits 0 on a refusal too, so this line is the only thing that + // says the relay directory will leak a master into every child for its whole life. + console.warn( + `[ssh-relay][NPTY-CLOEXEC-UNFIXED] pty master still leaks at ${remoteDir} (${platform}): ${status}` + ) + return 'unfixed' + } + console.log(`[ssh-relay][NPTY-CLOEXEC] ${remoteDir} (${platform}): ${status}`) + return 'fixed' + } catch (err) { + signal?.throwIfAborted() + // Never fatal: the script restores the working build itself, and a leaky relay beats none. An + // interrupted rebuild leaves node-pty unloadable, which the existing repair path reinstalls. + console.warn( + `[ssh-relay][NPTY-CLOEXEC-FAIL] pty master cloexec patch failed at ${remoteDir} (${platform}): ${(err as Error).message}` + ) + // An exec that never answered cannot say which build is on disk, and a tree nobody can vouch + // for is exactly the one not to share. + return 'unfixed' + } +} + /** * Drop a shared-cache symlink before anything writes into `node_modules`. * diff --git a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts index 61e6133b9a5..cafc82960f3 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts @@ -85,6 +85,9 @@ import { } from './ssh-relay-native-deps-cache-commands' // Everything after the probe on a healthy install: stderr cleanup, stage cleanup, launch. +// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a +// freshly installed node-pty loads (#17915). +const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' const LAUNCH_TAIL: ExecResponse[] = ['', 'DEAD', '', 'READY'] describe('relay native-deps cache on the deploy path', () => { @@ -165,6 +168,7 @@ describe('relay native-deps cache on the deploy path', () => { '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, RELAY_NATIVE_CACHE_PROMOTED, ...LAUNCH_TAIL ]) @@ -214,6 +218,7 @@ describe('relay native-deps cache on the deploy path', () => { '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, '', // publication is attempted and answers nothing ...LAUNCH_TAIL ]) @@ -235,6 +240,7 @@ describe('relay native-deps cache on the deploy path', () => { '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, '', // promotion attempt (the entry already exists, so it is declined) ...LAUNCH_TAIL ]) @@ -264,6 +270,7 @@ describe('relay native-deps cache on the deploy path', () => { '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, 'DEAD', '', // publish the per-launch credential 'READY' diff --git a/src/main/ssh/ssh-relay-native-deps-cache.ts b/src/main/ssh/ssh-relay-native-deps-cache.ts index 934aaff63a6..8400a467a91 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache.ts @@ -23,6 +23,12 @@ * Windows is deliberately excluded. node-pty's npm tarball ships win32 prebuilts, so there is no * compile to avoid there, and `node-pty-1.1.0-console-list-agent-patch.cjs` mutates the installed * tree in place — which rule 1 forbids for a shared one. + * + * Linux's `node-pty-1.1.0-master-cloexec-patch.cjs` also mutates in place, but it stays inside rule + * 1: the deploy path runs it before promotion, and returns early on a linked entry, so it only ever + * touches a private tree. Its bytes are in the key, so a patched build never links a pre-patch + * entry -- and a tree whose patch was refused or rolled back is not promoted at all, because under + * that same key it would publish the leak to every later host on the machine. */ import { createHash } from 'node:crypto' import { RELAY_REMOTE_DIR } from './relay-protocol' diff --git a/src/main/ssh/ssh-relay-native-deps-install-fixture.ts b/src/main/ssh/ssh-relay-native-deps-install-fixture.ts index 922b8bedf61..5cd20a03438 100644 --- a/src/main/ssh/ssh-relay-native-deps-install-fixture.ts +++ b/src/main/ssh/ssh-relay-native-deps-install-fixture.ts @@ -15,6 +15,9 @@ export type SftpWriteCapture = { type SftpCallback = (err: Error | null, resolved?: string) => void const NO_SUCH_SFTP_FILE = Object.assign(new Error('No such file'), { code: 2 }) +// Stdout of the relay-side pty-master cloexec patch; kept as a literal so the fixture states the +// wire token it is standing in for rather than importing the module under test. +const NODE_PTY_CLOEXEC_STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' export function makeMockConnection(capture: SftpWriteCapture): SshConnection { // Why: production attaches/removes real listeners (including prependOnceListener), so the fake must be an emitter. @@ -196,6 +199,9 @@ export function makeExecResponses(opts: { } // Publication is gated on the probe: only a tree this host actually loaded is shared. if (loadable) { + // The cloexec patch runs first, and publication is gated on its status, so `patched` is what + // makes the promote exec below reachable at all. + slots.push(`${NODE_PTY_CLOEXEC_STATUS_PREFIX}patched\n`) slots.push('') // promote the private tree into the shared native-deps cache } slots.push('', 'DEAD', '', 'READY') // clean stage root, launch, credential, readiness diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 16144fb2cf3..01625816170 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -630,6 +630,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + 'ORCA-NPTY-CLOEXEC:patched\n', // pty-master cloexec patch on the loadable node-pty 'DEAD', '', // publish the per-launch credential 'READY' diff --git a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts index df946dfe346..3939c954f65 100644 --- a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts @@ -85,6 +85,9 @@ import { type SftpWriteCapture } from './ssh-relay-native-deps-install-fixture' +// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a +// freshly installed node-pty loads (#17915). +const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' const NODE_PTY_RESET = "rm -rf 'node_modules/node-pty'" const WATCHER_RESET = "rm -rf 'node_modules/@parcel/watcher'" @@ -225,6 +228,7 @@ describe('native-deps repair probe verdicts', () => { '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, 'DEAD', '', // publish the per-launch credential 'READY' @@ -281,6 +285,7 @@ describe('native-deps repair probe verdicts', () => { '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, 'DEAD', '', // publish the per-launch credential 'READY' @@ -324,6 +329,7 @@ describe('native-deps repair probe verdicts', () => { '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, 'DEAD', '', // publish the per-launch credential 'READY' diff --git a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts index 12a51ce6bdd..8981b6319a8 100644 --- a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts +++ b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts @@ -103,6 +103,9 @@ const ABI_MISMATCH: TerminalUnavailableCause = { // The relay dir is complete but node-pty will not load, which is exactly what the spawn-time cause // describes. @parcel/watcher is healthy, so only node-pty is reset and rebuilt. +// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a +// freshly installed node-pty loads (#17915). +const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' const NODE_PTY_BROKEN = 'ORCA-NATIVE-DEPS-MISSING:node-pty\nMISSING' function repairSucceedsResponses(): ExecResponse[] { @@ -116,6 +119,7 @@ function repairSucceedsResponses(): ExecResponse[] { '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', // node-pty loads again '', // rm -f probe stderr + NPTY_CLOEXEC_PATCHED, 'DEAD', '', // publish the per-launch credential 'READY' diff --git a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts new file mode 100644 index 00000000000..66af01fa43c --- /dev/null +++ b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts @@ -0,0 +1,336 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, + execCommand: vi.fn() +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ + ...(await importOriginal()), + createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' +})) + +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'` +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand } from './ssh-relay-deploy-helpers' +import { parseUnameToRelayPlatform } from './relay-protocol' +import { + makeExecResponses, + makeStagedFirstInstallExecPrefix, + makeMockConnection, + type ExecResponse, + type SftpWriteCapture +} from './ssh-relay-native-deps-install-fixture' +import { RELAY_NATIVE_CACHE_LINKED } from './ssh-relay-native-deps-cache-commands' +import { RELAY_ARTIFACTS } from '../../shared/relay-artifacts' +import { + computeRelayNativeDepsCacheKey, + RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN +} from './ssh-relay-native-deps-cache' + +const PATCH_ASSET = 'node-pty-1.1.0-master-cloexec-patch.cjs' + +/** + * The relay installs stock node-pty from npm, so the app's pnpm patch never reaches it and every + * later child of the relay inherits a live pty master (#17915). The compile that closes it sits on + * the connect path, so what these specs pin is the blast radius, not the patch itself. + */ +describe('relay pty-master close-on-exec patch on the install path', () => { + const sftpCapture: SftpWriteCapture = { + paths: [], + contents: {}, + execCallCountAtWrite: {} + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset().mockResolvedValue('') + sftpCapture.paths.length = 0 + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + }) + + function feed(execResponses: ExecResponse[]): void { + const mockExec = vi.mocked(execCommand) + for (const response of execResponses) { + if (typeof response === 'string') { + mockExec.mockResolvedValueOnce(response) + } else { + mockExec.mockRejectedValueOnce(new Error(response.reject)) + } + } + } + + function firstInstall(cacheAnswer: string, tail: ExecResponse[]): ExecResponse[] { + const prefix = makeStagedFirstInstallExecPrefix() + // The prefix's last slot is the shared native-deps cache probe. + prefix[prefix.length - 1] = cacheAnswer + return [...prefix, ...tail] + } + + function patchCommands(): string[] { + return vi + .mocked(execCommand) + .mock.calls.map(([, command]) => command) + .filter((command) => command.includes(PATCH_ASSET)) + } + + /** Whether this deploy elected itself publisher of the shared entry. */ + function promoted(): boolean { + return vi + .mocked(execCommand) + .mock.calls.some(([, command]) => command.includes('mkdir "$cache"')) + } + + /** + * A cache-miss first install whose patch reports `status`. The promote slot is fed either way, + * so a run that wrongly promotes reads a valid response rather than falling off the end -- the + * assertion has to be the absence of the command itself, not a downstream crash. + */ + function firstInstallReporting(status: string): ExecResponse[] { + return [ + ...makeStagedFirstInstallExecPrefix(), + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + `ORCA-NPTY-CLOEXEC:${status}\n`, + '', // promote into the shared native-deps cache, if this deploy still gets that far + '', // clean stage root + 'DEAD', + '', // publish the per-launch credential + 'READY' + ] + } + + it('runs the patch on a Linux relay once node-pty is proven loadable', async () => { + const conn = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) + + await deployAndLaunchRelay(conn) + + expect(patchCommands()).toHaveLength(1) + expect(patchCommands()[0]).toContain("'/usr/bin/node'") + }) + + it('patches the private tree before it is published to the shared native-deps cache', async () => { + // Promotion moves `node_modules` into `~/.orca-remote/native/` and leaves a symlink + // behind, and a published entry is immutable by contract. Patching afterwards would rename, + // rebuild and roll back inside a tree every other relay on the host links -- and the + // `.deps-complete` written by promotion would have published an unpatched tree that every + // later host links and skips. The ordering is invisible in review, so pin it. + const conn = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) + + await deployAndLaunchRelay(conn) + + const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) + const patchAt = commands.findIndex((command) => command.includes(PATCH_ASSET)) + const promoteAt = commands.findIndex((command) => command.includes('mkdir "$cache"')) + expect(patchAt).toBeGreaterThan(-1) + expect(promoteAt).toBeGreaterThan(-1) + expect(patchAt).toBeLessThan(promoteAt) + }) + + it('does not publish a tree whose patch failed and rolled back', async () => { + // The script rolls `pty.cc` and `build/Release` back to the pre-patch, still-leaky build and + // reports `failed:` with exit 0, so nothing throws. Publishing that tree would be worse than + // the leak this PR closes: the key hashes the patch's bytes, so every later host on the + // machine links the entry, probes it loadable, and skips patching. Stay private instead. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const conn = makeMockConnection(sftpCapture) + feed(firstInstallReporting('failed:npm rebuild node-pty failed: gyp ERR! not found: make')) + + await deployAndLaunchRelay(conn) + + expect(patchCommands()).toHaveLength(1) + expect(promoted()).toBe(false) + expect(warn.mock.calls.map((args) => String(args[0] ?? '')).join('\n')).toContain( + '[ssh-relay][NPTY-CLOEXEC-UNSHARED]' + ) + } finally { + warn.mockRestore() + } + }) + + it('does not publish a tree the patch refused to touch', async () => { + // `skipped:` is not one verdict. Every form except `skipped:not-linux` means the patch was + // declined and the leaky build is still on disk, which is indistinguishable from `failed:` + // as far as what would get published. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const conn = makeMockConnection(sftpCapture) + feed(firstInstallReporting('skipped:earlier-attempt-failed')) + + await deployAndLaunchRelay(conn) + + expect(promoted()).toBe(false) + // A refusal exits 0, so the warn is the only signal that this host stayed leaky. + expect(warn.mock.calls.map((args) => String(args[0] ?? '')).join('\n')).toContain( + '[ssh-relay][NPTY-CLOEXEC-UNFIXED]' + ) + } finally { + warn.mockRestore() + } + }) + + it('still publishes a tree that was patched but whose isolation check could not run', async () => { + // `patched-unverified` rebuilt from patched source; only the check that watches a later child + // could not observe the result. An unobservable check is not a failed patch, and refusing to + // publish here would disable the shared cache on every host without `lsof`. + const conn = makeMockConnection(sftpCapture) + feed(firstInstallReporting('patched-unverified')) + + await deployAndLaunchRelay(conn) + + expect(promoted()).toBe(true) + }) + + it('never patches through a symlink into an entry another relay already published', async () => { + // A linked entry was built under a key that hashes this patch's bytes, so it is already + // patched; re-running the patch would rebuild inside the shared tree. + const conn = makeMockConnection(sftpCapture) + feed( + firstInstall(RELAY_NATIVE_CACHE_LINKED, [ + '', // chmod prebuilds, through the symlink + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + '', // clean stage root + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + ) + + await deployAndLaunchRelay(conn) + + expect(patchCommands()).toEqual([]) + }) + + it('leaves an unloadable node-pty alone rather than rebuilding it blind', async () => { + // A relay that could not build node-pty has nothing to fall back to, and the existing + // reinstall path owns that repair. + const conn = makeMockConnection(sftpCapture) + feed( + makeExecResponses({ + npmInstall: 'ok', + probe: 'missing', + repairProbe: 'missing' + }) + ) + + await deployAndLaunchRelay(conn) + + expect(patchCommands()).toEqual([]) + }) + + it('never adds a compile to a macOS relay, which does not leak the master', async () => { + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('darwin-arm64') + const conn = makeMockConnection(sftpCapture) + feed([ + ...makeStagedFirstInstallExecPrefix(), + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + '', // promote into the shared native-deps cache + '', // clean stage root + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await deployAndLaunchRelay(conn) + + expect(patchCommands()).toEqual([]) + }) + + it('connects anyway when the patch command fails outright', async () => { + const conn = makeMockConnection(sftpCapture) + const responses = makeExecResponses({ npmInstall: 'ok', probe: 'ok' }) + const patchSlot = responses.findIndex( + (response) => typeof response === 'string' && response.includes('ORCA-NPTY-CLOEXEC:') + ) + expect(patchSlot).toBeGreaterThan(-1) + responses[patchSlot] = { reject: 'no such file or directory' } + feed(responses) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + }) +}) + +describe('the shipped patch is part of the shared native-deps cache key', () => { + it('mints a new entry, so a pre-fix unpatched tree is never linked by a patched build', () => { + const artifact = RELAY_ARTIFACTS.find((entry) => entry.filename === PATCH_ASSET) + expect(artifact).toBeDefined() + // A windowsOnly artifact never reaches a Linux relay dir, so it would drop out of the key. + expect(artifact?.windowsOnly).toBeFalsy() + expect(RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN.test(PATCH_ASSET)).toBe(true) + + const deps = { 'node-pty': '1.1.0' } + expect( + computeRelayNativeDepsCacheKey({ + platform: 'linux-x64', + deps, + patchSources: [{ filename: PATCH_ASSET, contents: 'patch bytes' }] + }) + ).not.toBe(computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps })) + }) +}) diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts index 10ebce2dac4..81142743cd1 100644 --- a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts +++ b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts @@ -105,6 +105,9 @@ const RELAY_SUFFIX = '.orca-remote/relay-0.1.0+testhash' const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_SUFFIX}` const SFTP_RELAY_DIR = `${SFTP_HOME}/${RELAY_SUFFIX}` const MARKER_PATTERN = /\.sftp-namespace-[0-9a-f]{32}/ +// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a +// freshly installed node-pty loads (#17915). +const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' const STAGE_OWNER = '.sftp-namespace-00000000000000000000000000000000' const STAGE_RESERVED = `__ORCA_UPLOAD_STAGE_SLOT__${STAGE_OWNER}:slot-0` const STAGE_PROMOTED = `__ORCA_UPLOAD_STAGE_PROMOTION__${STAGE_OWNER}:PROMOTED` @@ -256,6 +259,7 @@ const POSIX_FIRST_INSTALL = [ '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, '', // promote into the shared native-deps cache '', // clean stage root 'DEAD', @@ -275,6 +279,7 @@ const POSIX_SYSTEM_SSH_FIRST_INSTALL = [ '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, '', // promote into the shared native-deps cache '', // clean stage root 'DEAD', @@ -293,6 +298,7 @@ const POSIX_REPAIR = [ '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, 'DEAD', '', // publish the per-launch credential 'READY' @@ -703,6 +709,7 @@ describe('relay repair writes on a split SFTP namespace', () => { '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, 'DEAD', '', // remote credential generation 'READY' @@ -733,6 +740,7 @@ describe('relay repair writes on a split SFTP namespace', () => { '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, 'DEAD', '', // remote credential generation 'READY' diff --git a/src/shared/relay-artifacts.ts b/src/shared/relay-artifacts.ts index 18c88135a62..273f6e059b8 100644 --- a/src/shared/relay-artifacts.ts +++ b/src/shared/relay-artifacts.ts @@ -51,6 +51,11 @@ export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [ // title request with no title and no error. { filename: 'wsl-transcript-fs-process-entry.js' }, { filename: 'node-pty-1.1.0-console-list-agent-patch.cjs', windowsOnly: true }, + // Only Linux relays run it, but it ships everywhere: the manifest's only + // platform axis is Windows, and a second one would buy nothing but a fork in + // the hash. Its presence is what moves a host to a fresh relay directory, and + // therefore to a re-install that can apply it. + { filename: 'node-pty-1.1.0-master-cloexec-patch.cjs' }, // Optional because only a Windows build machine can compile it. Without it the // relay reads the process table through a PowerShell scan instead -- slower, // but correct, so a relay built anywhere else is still shippable. diff --git a/src/shared/relay-optional-artifacts.test.ts b/src/shared/relay-optional-artifacts.test.ts index 74f3530b2d1..0b8b750dbf9 100644 --- a/src/shared/relay-optional-artifacts.test.ts +++ b/src/shared/relay-optional-artifacts.test.ts @@ -31,4 +31,12 @@ describe('optional relay artifacts', () => { expect(relayArtifactFilenames(true)).toContain('relay.js') expect(relayArtifactFilenames(true)).toContain('node-pty-1.1.0-console-list-agent-patch.cjs') }) + + it('ships the pty-master cloexec patch to every platform', () => { + // Only Linux runs it, but its bytes are what change the relay content hash, and therefore what + // moves an upgrading host to a fresh directory whose install can apply it (#17915). + for (const isWindows of [true, false]) { + expect(relayArtifactFilenames(isWindows)).toContain('node-pty-1.1.0-master-cloexec-patch.cjs') + } + }) }) From f37d2fec9711c9945600527a95cd605157ea7c6d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:08:01 -0700 Subject: [PATCH 92/94] fix(linux): land the reviewed Linux packaging stack on main (#18100) * fix(linux): give the CLI one entrypoint by extracting the AppImage once * refactor(linux): trim AppImage CLI registration seams * test(cli): assert registration lock serialization * fix(linux): fence AppImage terminal shim mounts * fix(linux): accept extracted AppImage runtimes with APPDIR only * docs(linux): make headless AppImage extraction runnable * refactor(linux): import bundled launcher directly * fix(linux): reclaim superseded AppImage payloads and packaged symlinks Pruning removed 3215 of 3216 files from a superseded generation and always stranded resources/app.asar, leaking ~105 MB per version update. Electron's asar shim reports a *.asar file as a directory, so the recursive remove tried to rmdir a real file and failed with ENOTEMPTY; the .catch(() => {}) hid it. Reproduced end to end on Ubuntu 24.04: 519M -> 623M across one update, and 519M again once the payload is actually reclaimed. removeExtractedAppImagePayload holds process.noAsar for the removal, counted so overlapping removals cannot hand the shim back early, and the prune site now warns with the path instead of swallowing the rejection. All three removal sites use it -- staging cleanup and displaced roots leaked the same way. Also reclaim symlinks left by a packaged deb/rpm install, which the extracted-cache-only rule turned into a hard conflict on a deb -> AppImage migration, and name the remedy in the conflict error. * fix(linux): bound the CLI registration lock wait `retries: 1000` caps the attempt count, not elapsed time, so at up to 1s per attempt an IPC-driven registration could hang ~16 minutes against a wedged holder with no feedback. A legitimate holder is bounded by the extraction timeout, so wait that plus slack and then fail with a message naming the lock file, rather than hanging. `maxRetryTime` is forwarded verbatim to the `retry` package by proper-lockfile. * fix(linux): stop re-extracting the AppImage on inode metadata churn The extracted-payload cache key hashed ctime alongside dev/ino/size/mtime. ctime moves on any inode metadata write -- `chmod +x`, which every AppImage user is told to run, plus `chown`, an ACL or SELinux relabel, and a backup restore -- none of which alter a byte of the payload. Measured on Ubuntu 24.04: `chmod +x` leaves dev, ino, size and mtime identical and moves ctime alone, so the key changed and the next launch paid a full ~519 MB re-extraction and a multi-second stall to rebuild a payload it already had, then pruned the old generation. Key on content identity instead. An in-place content change moves mtime and almost always size; a replacement moves the inode. The existing replace-in-place test still passes. * fix(linux): stop CLI commands from falling through to Chromium startup * refactor(cli): remove redundant command membership check * test(cli): cover command-named project selectors * fix(cli): redirect the open-url command before startup * test(linux): cover AUR serve wrapper flags * fix(linux): tighten CLI launch detection * fix(linux): respect CLI flag value boundaries * fix(linux): strip injected Chromium switches from CLI args * fix(linux): report a missing display instead of dying in uv_close * refactor(linux): read display locks without a preflight race * fix(linux): preserve unverified external displays * chore: format reliability gate manifest * test(packaging): split runtime resource checks * fix(linux): fail serve when no display is available * fix(linux): do not treat a lockless X socket as a dead display An X server writes its lock beside its socket and both survive a crash (verified against Xvfb under SIGKILL), so a socket with no lock was never left by a crashed server. It is an endpoint published from elsewhere: a container bind-mounting only /tmp/.X11-unix, WSLg, or a foreign PID namespace. Declaring those dead made the desktop gate exit(1) on displays that work, with no workaround, and the serve gate refuse to start. Liveness now splits by ownership. A foreign DISPLAY trusts a lockless socket; Orca's own :99 does not, because removeStaleDisplayArtifacts unlinks the lock before the socket and so manufactures that state itself -- adopting it would resurrect the orphan-socket bug and stop the cleanup from self-healing. The stale-lock rejection is unchanged. Also correct four doc statements this behaviour falsified. * fix(linux): fail closed when a stale socket blocks the Xvfb rebind Readiness only checked that /tmp/.X11-unix/X99 exists. A stale socket we could not unlink still exists after our own Xvfb refused to bind, so Orca set DISPLAY to a dead server and Chromium died in Ozone init. Measured on Ubuntu 24.04 against the pre-fix build: with a leftover :99 socket and no lock, serve exits 139 (SIGSEGV), the socket inode is unchanged before and after, and no lock is recreated -- it neither cleaned up nor respawned. To a user that is a crash, not a misconfiguration. This is reachable in the documented topology, where orca-xvfb.service has no User= and runs as root while serve runs as User=orca: /tmp is sticky, so the orca uid cannot unlink a root-owned socket, rmSync fails, and Xvfb exits with the display already active. Readiness now requires the display to actually be live -- our socket plus a lock naming a running process -- so the same state reports an unusable display and exits 1 with the existing diagnosis. * fix(linux): recognise abstract X sockets and inherited Wayland fds Two display setups this gate could not prove were refused outright, and on the desktop path that is app.exit(1) with no workaround. An X server may bind only the abstract namespace (`@/tmp/.X11-unix/X0`), which leaves no filesystem socket to stat. Abstract addresses are kernel-owned and vanish the moment the owner exits, so an entry in /proc/net/unix is proof of a live server -- no lock file needed and no stale entry possible. Verified on Ubuntu 24.04, where 139 such addresses were present. WAYLAND_SOCKET is an already-connected fd handed over by the compositor, so there is no path to stat and WAYLAND_DISPLAY may be unset entirely. Its presence is the display. Both are consulted only after the filesystem-socket check fails, so no existing verdict changes. * fix(linux): never treat Orca's own display number as a foreign endpoint Recognising a lockless X socket as live is correct for an endpoint published from elsewhere -- a container bind mount, WSLg -- because an X server writes its lock beside its socket and both survive a crash. It is wrong for VIRTUAL_DISPLAY_NUMBER, because Orca's own teardown unlinks the lock before the socket and so manufactures that exact state. The managed branch was already strict, but a caller that sets DISPLAY=:99 explicitly takes the foreign path and skipped it, accepting a dead display left by Orca's own interrupted cleanup. Route the managed number through the strict probe on both paths. Found by an adversarial audit of the asymmetry introduced earlier in this branch; the documented systemd topology is unaffected because its Xvfb writes a real lock. * test(linux): add a packaged-artifact contract for the CLI launch paths * test(linux): avoid buffered serve readiness detection * test(linux): signal AppImage serve owner directly * test(linux): tolerate readiness timeout boundary * test(linux): add startup margin to shutdown oracle * ci(linux): give package contracts timeout headroom * fix(ci): route all Linux packaging contract changes * test(linux): poll shutdown readiness without tail leaks * test(linux): bound shutdown cleanup grace * test(linux): assert on CLI output, not the harness's own control lines run-cli-case.sh echoes `RESULT status=N case=`, and the two cases named *-skills asserted `expectOutput: 'skills'`. That substring was satisfied by the case name in the harness's own line, so 2 of 8 cases asserted nothing about the command -- gutting `skills` entirely would still have gone green. Control lines are now excluded before matching, and both cases assert the rendered help header, which only real help output produces. Verified on an Ubuntu 24.04 host: 8/8 still pass against a stack-tip AppImage. Also register the gate in reliability-gates.jsonc, which #15085 added a CI Docker gate without. Red/green is recorded from a stock release AppImage failing 4 of 8, three of them at status 133 (SIGTRAP). * fix(linux): require static AppImage runtimes (#17319) * test(linux): reject a wrong-architecture native binary at packaging time Cross-building the arm64 slice on an x64 host silently packed an x86-64 `pty.node` -- the rebuild logged "Forcing native rebuild for linux-arm64" and shipped the host's binary anyway. Every gate here inspects symbol versions, which are perfectly valid on the wrong architecture, so nothing noticed. Observed on a Raspberry Pi 5: the packaged app loaded, then failed with "Failed to load native module: pty.node", and the launch contract reported 3 of 8 cases crashed rather than naming the cause. Swapping in the aarch64 `pty.node` took the same build to 8/8. Compare ELF `e_machine` against the slice being packaged and fail with the offending path. Checked before the glibc pass, because a wrong-architecture binary's symbol versions are valid but meaningless and would send the reader down the wrong path. Release CI builds arm64 on a native runner, so this guards local and future cross-builds rather than a shipped artifact. * test(linux): judge per-arch vendored binaries against their own path The first CI run of the architecture gate failed the x64 package job on `@parcel/watcher-linux-arm64-glibc/watcher.node`. That binary is arm64 on purpose: the package ships every architecture and its loader picks the match, so its presence in an x64 build is correct. Judge a binary against the architecture its own path names, falling back to the slice when the path names none. That keeps the case this gate exists for -- `bin/linux-arm64-*/node-pty.node` holding an x86-64 binary, which is what shipped to a Raspberry Pi 5 -- while letting multi-arch dependencies through. Dry-run over the real dependency tree flags nothing for either target arch. * fix(linux): move deb/rpm update installation outside Orca (#17318) * fix(linux): complete deb/rpm package metadata * fix(linux): preserve CLI link during package upgrades * docs(linux): document local RPM build prerequisites * fix(linux): move deb/rpm update installation outside Orca * fix(updater): preserve Linux recovery across stale events * fix(updater): fence stale downloaded events by active target * fix(updater): preserve active Linux package recovery * test(linux): keep workflow order assertion in scope * test(updater): assert stale recovery stays silent * fix(updater): preserve Linux package recovery after checks * refactor(updater): keep Linux marker message with status * fix(linux): describe the right manual update path for deb/rpm hosts A remote host installed from .deb or .rpm now reports manual-service-update-required, and the guidance told the operator to "update through the service manager that starts this server" -- which is correct for unsupported-headless-serve but wrong for a package install, where nothing about the remedy involves the service manager. Say both, keyed on how the host was installed. * docs(linux): document orcad update restart safety * docs(linux): scope restart census omissions * docs(linux): use absolute service CLI launcher * fix(serve): validate in-process serve options before startup (#17683) * fix(linux): stop offering updates a distro-managed install cannot apply (#17918) Closes #17702. The resources/package-type marker is authoritative but never checked against the host, so any repackager that unpacks Orca's .deb -- AUR, Nix, a container rebuild -- inherits `deb` verbatim. Install feasibility was then computed after a ~165 MB download, so those users got check -> download -> a card promising an install command -> a dead end. Validate the marker against the host: a deb/rpm marker with no matching package manager in the trusted directories means a package manager owns this install. This reuses the exact lists and resolver that buildLinuxPackageInstallCommand already loops over, so a false positive is impossible by construction -- any host flagged here would have failed with no-package-manager after the download anyway. The gate only moves that verdict earlier. Verified across Debian 12, Ubuntu 24.04, Arch, Fedora 40 and openSUSE Leap: no false positive on a real deb host, correct on every repackaging host. The release is still reported, because the user does want to know 1.4.194 exists and to update through their distro; only the download path is closed. `externallyManaged` is an additive optional field on the existing `available` status, so older paired clients decode it unchanged. downloadUpdate() refuses authoritatively, since main owns this verdict rather than the card, and unwinds any pinned-build state first -- a Linux pinned jump resolves to 'release', and stranding isPinnedBuildActive would silently kill every background check for the rest of the process. Note the fix the issue suggests cannot work: electron-updater builds a PacmanUpdater whose doDownloadUpdate looks for a .pacman asset Orca does not publish, then dereferences undefined. * style(cli): restore prettier wrapping on install error copy * test(linux): re-pin the child-process ratchets and the batch-shim allowlist after the merge --- .github/workflows/pr.yml | 35 +- config/docker/cli-launch-contract/Dockerfile | 34 + .../cli-launch-contract/run-cli-case.sh | 84 ++ config/docker/headless-pairing/Dockerfile | 1 - .../docker/headless-serve-shutdown/Dockerfile | 3 +- .../run-appimage-desktop-startup-case.sh | 265 ++++++ .../run-signal-case.sh | 92 +- config/electron-builder.config.cjs | 59 +- config/reliability-gates.jsonc | 132 ++- config/scripts/build-linux-local.mjs | 65 ++ config/scripts/build-linux-local.test.mjs | 85 ++ .../scripts/electron-builder-config.test.mjs | 337 +------ ...lectron-builder-runtime-resources.test.mjs | 308 +++++++ .../headless-serve-shutdown-workflow.test.mjs | 144 ++- .../linux-package-maintainer-scripts.test.mjs | 18 + .../orcad-operations-restart-safety.test.mjs | 42 + config/scripts/pr-code-change-scope.mjs | 7 + config/scripts/pr-code-change-scope.test.mjs | 22 + .../scripts/pr-workflow-parallelism.test.mjs | 13 +- .../run-headless-serve-shutdown-docker.mjs | 54 +- .../run-linux-cli-launch-contract-docker.mjs | 264 ++++++ .../static-appimage-package-contract.cjs | 260 ++++++ .../static-appimage-package-contract.test.mjs | 225 +++++ config/scripts/verify-cli-bin.mjs | 19 +- config/scripts/verify-linux-glibc-floor.cjs | 99 +++ .../scripts/verify-linux-glibc-floor.test.mjs | 87 ++ .../windows-cmd-shim-spawn-boundary.test.mjs | 4 +- config/tsconfig.cli.json | 2 + docs/reference/headless-linux-server.md | 56 +- docs/reference/linux-glibc-compatibility.md | 8 + docs/reference/orcad-operations.md | 66 +- docs/reference/ssh-execution-boundary.md | 2 +- package.json | 3 +- resources/linux/bin/orca-ide | 1 + resources/linux/packaging/after-remove.sh | 6 + src/cli/args.test.ts | 20 + src/cli/args.ts | 75 +- src/cli/cli-command-name-parity.test.ts | 25 + src/cli/cli-version.test.ts | 36 + src/cli/cli-version.ts | 14 + src/cli/command-suggestion.ts | 27 +- src/cli/handlers/core.ts | 51 +- src/cli/index.ts | 12 + src/cli/runtime/launch.test.ts | 144 ++- src/cli/runtime/launch.ts | 45 +- .../serve-signal-exit-diagnostic.test.ts | 30 + src/cli/runtime/serve-update-supervisor.ts | 17 +- src/cli/serve-electron-flag-parity.test.ts | 17 +- src/main/cli/cli-command-installation.ts | 49 +- src/main/cli/cli-installer.test.ts | 50 ++ src/main/cli/packaged-cli-assets.test.ts | 57 ++ src/main/linux-package-downloaded-status.ts | 88 ++ .../linux-package-install-command.test.ts | 51 ++ src/main/linux-package-install-command.ts | 10 + .../linux-package-install-diagnostic.test.ts | 271 +----- src/main/linux-package-install-diagnostic.ts | 146 +-- .../linux-package-update-recovery.test.ts | 121 +-- src/main/linux-package-update-recovery.ts | 79 +- src/main/linux-update-package-type.test.ts | 299 +++++-- src/main/linux-update-package-type.ts | 115 ++- .../startup/appimage-cli-redirect.test.ts | 211 ----- src/main/startup/appimage-cli-redirect.ts | 209 ----- src/main/startup/cli-command-names.ts | 73 ++ src/main/startup/cli-launch-redirect.test.ts | 357 ++++++++ src/main/startup/cli-launch-redirect.ts | 245 ++++++ .../startup/ensure-virtual-display.test.ts | 410 ++++++++- src/main/startup/ensure-virtual-display.ts | 217 ++++- src/main/startup/main-process-preflight.ts | 39 +- .../startup/main-process-runtime-launch.ts | 2 +- src/main/startup/main-process-serve.ts | 40 +- .../packaged-cli-entry-redirect.test.ts | 157 ---- .../startup/packaged-cli-entry-redirect.ts | 128 --- ...serve-mode-argv-cli-redirect-order.test.ts | 59 +- src/main/startup/serve-mode-argv.test.ts | 21 + src/main/startup/serve-mode-argv.ts | 15 +- src/main/startup/serve-options.test.ts | 161 ++++ src/main/startup/serve-options.ts | 134 +++ .../startup/serve-signal-handlers.test.ts | 4 +- src/main/startup/serve-signal-handlers.ts | 5 +- ...single-instance-lock-exit.electron.test.ts | 48 +- src/main/updater-events.test.ts | 274 +++++- src/main/updater-events.ts | 114 ++- src/main/updater-fallback.ts | 9 +- ...ter-linux-package-recovery-actions.test.ts | 193 +++- src/main/updater-mac-install.ts | 59 +- src/main/updater-test-harness.ts | 25 +- src/main/updater.fallback.test.ts | 17 + .../updater.headless-serve-install.test.ts | 6 + .../updater.install-failure-cause.test.ts | 7 + .../updater.linux-externally-managed.test.ts | 155 ++++ ...updater.linux-root-package-install.test.ts | 828 ++++-------------- src/main/updater.mac-install.test.ts | 10 +- src/main/updater.quit-and-install.test.ts | 1 + src/main/updater.startup-scheduling.test.ts | 1 + src/main/updater/updater-build-selection.ts | 4 +- src/main/updater/updater-check-failure.ts | 15 +- src/main/updater/updater-check-state.ts | 20 +- src/main/updater/updater-download-install.ts | 34 +- src/main/updater/updater-install-execution.ts | 107 +-- src/main/updater/updater-install-support.ts | 7 +- src/main/updater/updater-menu-checks.ts | 2 +- src/main/updater/updater-package-recovery.ts | 202 +---- src/main/updater/updater-remote-status.ts | 9 +- src/main/updater/updater-setup.ts | 6 +- src/main/updater/updater-state.ts | 6 - .../LinuxPackageInstallRecoveryCard.test.tsx | 361 +++----- .../LinuxPackageInstallRecoveryCard.tsx | 129 +-- .../components/UpdateCard.error-card.test.tsx | 104 ++- .../src/components/UpdateCard.test.ts | 31 + src/renderer/src/components/UpdateCard.tsx | 18 +- .../src/components/UpdateErrorCardContent.tsx | 2 +- .../UpdateAvailableCardContent.tsx | 58 +- .../update-card/UpdateCardStateContent.tsx | 11 +- .../update-card/update-card-error-model.ts | 28 +- .../update-card/update-card-visibility.ts | 12 +- .../GeneralUpdateSettingsSection.test.tsx | 37 + .../settings/GeneralUpdateSettingsSection.tsx | 60 +- .../settings/RemoteServerUpdateStatus.tsx | 2 +- .../status-bar/UpdateStatusSegment.tsx | 10 +- src/renderer/src/i18n/locales/en.json | 23 +- .../child-process-import-allowlist.txt | 2 - .../windows-console-visibility-allowlist.txt | 2 - .../child-process-import-boundary.test.ts | 2 +- .../windows-console-visibility.test.ts | 2 +- src/shared/cli-argument-boundary.test.ts | 33 + src/shared/cli-argument-boundary.ts | 96 ++ src/shared/edit-distance.ts | 23 + src/shared/serve-option-validation.test.ts | 72 ++ src/shared/serve-option-validation.ts | 89 ++ src/shared/update-status-types.ts | 15 +- 130 files changed, 7056 insertions(+), 3563 deletions(-) create mode 100644 config/docker/cli-launch-contract/Dockerfile create mode 100755 config/docker/cli-launch-contract/run-cli-case.sh create mode 100755 config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh create mode 100644 config/scripts/build-linux-local.mjs create mode 100644 config/scripts/build-linux-local.test.mjs create mode 100644 config/scripts/electron-builder-runtime-resources.test.mjs create mode 100644 config/scripts/linux-package-maintainer-scripts.test.mjs create mode 100644 config/scripts/orcad-operations-restart-safety.test.mjs create mode 100755 config/scripts/run-linux-cli-launch-contract-docker.mjs create mode 100644 config/scripts/static-appimage-package-contract.cjs create mode 100644 config/scripts/static-appimage-package-contract.test.mjs create mode 100644 src/cli/cli-command-name-parity.test.ts create mode 100644 src/cli/cli-version.test.ts create mode 100644 src/cli/cli-version.ts create mode 100644 src/main/linux-package-downloaded-status.ts delete mode 100644 src/main/startup/appimage-cli-redirect.test.ts delete mode 100644 src/main/startup/appimage-cli-redirect.ts create mode 100644 src/main/startup/cli-command-names.ts create mode 100644 src/main/startup/cli-launch-redirect.test.ts create mode 100644 src/main/startup/cli-launch-redirect.ts delete mode 100644 src/main/startup/packaged-cli-entry-redirect.test.ts delete mode 100644 src/main/startup/packaged-cli-entry-redirect.ts create mode 100644 src/main/startup/serve-options.test.ts create mode 100644 src/main/startup/serve-options.ts create mode 100644 src/main/updater.linux-externally-managed.test.ts create mode 100644 src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx create mode 100644 src/shared/cli-argument-boundary.test.ts create mode 100644 src/shared/cli-argument-boundary.ts create mode 100644 src/shared/edit-distance.ts create mode 100644 src/shared/serve-option-validation.test.ts create mode 100644 src/shared/serve-option-validation.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 80d3d42a8bb..c17ad60d5d3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -623,6 +623,8 @@ jobs: needs: [code_paths] if: needs.code_paths.outputs.package == 'true' runs-on: ubuntu-latest + # Let the serial Docker gates reach their own deadlines and report cleanup failures. + timeout-minutes: 90 steps: - name: Checkout @@ -678,14 +680,45 @@ jobs: - name: Build native components run: pnpm run build:native + - name: Install Linux package tooling + run: sudo apt-get update && sudo apt-get install -y cpio rpm + - name: Package unpacked app env: ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' - run: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage --x64 --publish never + run: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish never + + - name: Verify root-package marker payloads + run: | + set -euo pipefail + version="$(node -p "require('./package.json').version")" + deb="dist/orca-ide_${version}_amd64.deb" + rpm="dist/orca-ide-${version}.x86_64.rpm" + test -s "$deb" + test -s "$rpm" + deb_marker="$(dpkg-deb --fsys-tarfile "$deb" | tar -xOf - ./opt/Orca/resources/package-type)" + rpm_marker="$(rpm2cpio "$rpm" | cpio --quiet --extract --to-stdout ./opt/Orca/resources/package-type)" + [[ "$deb_marker" == deb ]] || { echo "Expected deb marker, got: $deb_marker"; exit 1; } + [[ "$rpm_marker" == rpm ]] || { echo "Expected rpm marker, got: $rpm_marker"; exit 1; } - name: Verify headless serve signal shutdown run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage + - name: Verify extracted launcher serve signal shutdown + run: >- + node config/scripts/run-headless-serve-shutdown-docker.mjs + --appimage dist/orca-linux.AppImage --entrypoint launcher + + - name: Verify AppImage CLI registration and serve signal shutdown + run: >- + node config/scripts/run-headless-serve-shutdown-docker.mjs + --appimage dist/orca-linux.AppImage --entrypoint appimage + --signal-target serving-electron --int-delivery pid + + # A default container reproduces the hostile AppImage launch environment. + - name: Verify Linux CLI launch contract + run: node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage + - name: Smoke packaged CLI run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked diff --git a/config/docker/cli-launch-contract/Dockerfile b/config/docker/cli-launch-contract/Dockerfile new file mode 100644 index 00000000000..f6a618a8ece --- /dev/null +++ b/config/docker/cli-launch-contract/Dockerfile @@ -0,0 +1,34 @@ +ARG BASE_IMAGE=ubuntu:24.04 +FROM ${BASE_IMAGE} + +ARG LIBASOUND_PACKAGE=libasound2t64 + +ENV DEBIAN_FRONTEND=noninteractive + +# Install Electron's link-time libraries without adding a display server or FUSE. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + coreutils \ + "${LIBASOUND_PACKAGE}" \ + libatk-bridge2.0-0 \ + libatspi2.0-0 \ + libdrm2 \ + libgbm1 \ + libgtk-3-0 \ + libnss3 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxkbcommon0 \ + libxrandr2 \ + procps \ + util-linux \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --create-home --shell /bin/bash orca + +COPY run-cli-case.sh /usr/local/bin/run-cli-case + +ENTRYPOINT ["/usr/local/bin/run-cli-case"] diff --git a/config/docker/cli-launch-contract/run-cli-case.sh b/config/docker/cli-launch-contract/run-cli-case.sh new file mode 100755 index 00000000000..3293601f22f --- /dev/null +++ b/config/docker/cli-launch-contract/run-cli-case.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Print a parseable verdict; the host script owns expected statuses. +set -uo pipefail + +case_name=${1:?launch case is required} +extracted_root=${ORCA_TEST_EXTRACTED_ROOT:-/artifacts/squashfs-root} +launcher="$extracted_root/resources/bin/orca-ide" +command_timeout_seconds=${ORCA_TEST_COMMAND_TIMEOUT_SECONDS:-60} + +if ((EUID == 0)); then + # Reproduce extracted AppImage sandbox ownership as an unprivileged user. + exec runuser --user orca --preserve-environment -- "$0" "$@" +fi + +# Guard the restricted-userns precondition instead of accepting a false pass. +if [[ "$case_name" == *-userns-* ]]; then + if unshare -Ur true 2>/dev/null; then + echo "PRECONDITION_FAILED user namespaces are available; this case needs them restricted" + exit 90 + fi +fi +if [[ "$case_name" == nofuse-* && -e /dev/fuse ]]; then + echo "PRECONDITION_FAILED /dev/fuse is present; this case needs it absent" + exit 90 +fi + +unset DISPLAY WAYLAND_DISPLAY XDG_RUNTIME_DIR +if [[ "$case_name" == stale-display-* ]]; then + DISPLAY=:77 + export DISPLAY +fi + +case "$case_name" in + # The bundled launcher must stay in Electron's node mode. + nofuse-userns-bundled-help) command=("$launcher" --help) ;; + nofuse-userns-bundled-version) command=("$launcher" --version) ;; + nofuse-userns-bundled-status) command=("$launcher" status) ;; + nofuse-userns-bundled-skills) command=("$launcher" skills --help) ;; + nofuse-userns-bundled-worktree) command=("$launcher" worktree list) ;; + # Direct binaries must hand off before Ozone initializes. + nofuse-nosandbox-direct-binary-skills) + command=("$extracted_root/orca-ide" --no-sandbox skills --help) + ;; + nofuse-nosandbox-direct-binary-gui) + command=("$extracted_root/orca-ide" --no-sandbox) + ;; + stale-display-nosandbox-direct-binary-gui) + command=("$extracted_root/orca-ide" --no-sandbox) + ;; + *) + echo "UNKNOWN_CASE $case_name" + exit 91 + ;; +esac + +output=$(timeout --foreground --signal=TERM --kill-after=5s "${command_timeout_seconds}s" "${command[@]}" 2>&1) +status=$? + +if ((status == 124)); then + echo "TIMED_OUT seconds=$command_timeout_seconds case=$case_name" + printf '%s\n' "$output" | tail -30 + exit 94 +fi + +if [[ "$case_name" == nofuse-userns-bundled-version ]]; then + version_file="$extracted_root/resources/app.asar.unpacked/out/package.json" + expected_version=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$version_file") + if [[ -z "$expected_version" || "$output" != "$expected_version" ]]; then + output="VERSION_MISMATCH expected=${expected_version:-missing} got=$output" + status=93 + fi +fi + +# Shell signal exits are reported as 128 plus the signal number. +if ((status >= 128)); then + echo "CRASHED status=$status case=$case_name" + printf '%s\n' "$output" | tail -30 + exit 92 +fi + +echo "RESULT status=$status case=$case_name" +# Preserve the help header used by output assertions. +printf '%s\n' "$output" | head -200 +exit 0 diff --git a/config/docker/headless-pairing/Dockerfile b/config/docker/headless-pairing/Dockerfile index 8feafcc6e82..03664f68b0d 100644 --- a/config/docker/headless-pairing/Dockerfile +++ b/config/docker/headless-pairing/Dockerfile @@ -28,7 +28,6 @@ RUN apt-get update \ util-linux \ xauth \ xvfb \ - zlib1g-dev \ && rm -rf /var/lib/apt/lists/* RUN useradd --create-home --shell /bin/bash orca diff --git a/config/docker/headless-serve-shutdown/Dockerfile b/config/docker/headless-serve-shutdown/Dockerfile index 669bb02b00a..13b1ed2b69f 100644 --- a/config/docker/headless-serve-shutdown/Dockerfile +++ b/config/docker/headless-serve-shutdown/Dockerfile @@ -22,16 +22,15 @@ RUN apt-get update \ libxkbcommon0 \ libxrandr2 \ libxss1 \ - p7zip-full \ procps \ util-linux \ xauth \ xvfb \ - zlib1g-dev \ && rm -rf /var/lib/apt/lists/* RUN useradd --create-home --shell /bin/bash orca COPY run-signal-case.sh /usr/local/bin/run-signal-case +COPY run-appimage-desktop-startup-case.sh /usr/local/bin/run-appimage-desktop-startup-case ENTRYPOINT ["/usr/local/bin/run-signal-case"] diff --git a/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh b/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh new file mode 100755 index 00000000000..59a6bef0e5c --- /dev/null +++ b/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +set -euo pipefail + +appimage=${1:-/input/orca.AppImage} +startup_timeout_seconds=90 +if [[ $# -gt 1 ]]; then + echo "usage: run-appimage-desktop-startup-case.sh [appimage]" >&2 + exit 64 +fi + +if ((EUID == 0)); then + if ! state_dir=$(mktemp -d /tmp/orca-appimage-startup.XXXXXX); then + echo 'FAIL: unable to create the AppImage startup state directory' >&2 + exit 1 + fi + if ! chown orca:orca "$state_dir"; then + echo "FAIL: unable to hand the AppImage startup state directory to orca: $state_dir" >&2 + rm -rf -- "$state_dir" || true + exit 1 + fi + exec runuser --user orca --preserve-environment -- env \ + ORCA_STARTUP_STATE_DIR="$state_dir" \ + ORCA_STARTUP_STATE_DIR_CLEANUP=1 \ + "$0" "$@" +fi + +remove_state_dir_on_exit=${ORCA_STARTUP_STATE_DIR_CLEANUP:-0} +if [[ -n "${ORCA_STARTUP_STATE_DIR:-}" ]]; then + state_dir=$ORCA_STARTUP_STATE_DIR +else + if ! state_dir=$(mktemp -d /tmp/orca-appimage-startup.XXXXXX); then + echo 'FAIL: unable to create the AppImage startup state directory' >&2 + exit 1 + fi + remove_state_dir_on_exit=1 +fi +stdout_log="$state_dir/stdout.log" +stderr_log="$state_dir/stderr.log" +launcher_pid= +launcher_start_ticks= +launcher_pgid= +launcher_status= +launcher_waited=false +tree_pids=() +declare -A tree_start_ticks=() + +read_start_ticks() { + local pid=$1 + [[ -r "/proc/$pid/stat" ]] || return 1 + awk '{print $22}' "/proc/$pid/stat" +} + +identity_alive() { + local pid=$1 + local expected_ticks=$2 + [[ -n "$expected_ticks" ]] || return 1 + [[ -r "/proc/$pid/stat" ]] || return 1 + [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "$expected_ticks" ]] || return 1 + local process_state + process_state=$(ps -o stat= -p "$pid" 2>/dev/null | tr -d '[:space:]' || true) + [[ -n "$process_state" && "$process_state" != Z* ]] +} + +collect_process_tree() { + tree_pids=() + tree_start_ticks=() + [[ -n "$launcher_pid" ]] || return + [[ -n "$launcher_start_ticks" ]] || return + tree_pids+=("$launcher_pid") + tree_start_ticks["$launcher_pid"]="$launcher_start_ticks" + local -a frontier=("$launcher_pid") + while ((${#frontier[@]})); do + local parent=${frontier[0]} + frontier=("${frontier[@]:1}") + while read -r child; do + [[ -n "$child" ]] || continue + [[ -z "${tree_start_ticks[$child]+present}" ]] || continue + local child_ticks + child_ticks=$(read_start_ticks "$child" 2>/dev/null || true) + [[ -n "$child_ticks" ]] || continue + tree_pids+=("$child") + tree_start_ticks["$child"]="$child_ticks" + frontier+=("$child") + done < <(ps -eo pid=,ppid= | awk -v parent="$parent" '$2 == parent {print $1}') + done +} + +process_is_xvfb() { + local pid=$1 + local command_name + command_name=$(ps -o comm= -p "$pid" 2>/dev/null || true) + [[ "$command_name" == Xvfb ]] && return 0 + local command_line + command_line=$(ps -o args= -p "$pid" 2>/dev/null || true) + [[ "$command_line" =~ (^|[[:space:]/])Xvfb([[:space:]]|$) ]] +} + +signal_process_group() { + local signal=$1 + identity_alive "$launcher_pid" "$launcher_start_ticks" || return 0 + [[ "$launcher_pgid" =~ ^[0-9]+$ ]] || return 0 + [[ "$launcher_pgid" != "$(ps -o pgid= -p "$$" | tr -d ' ')" ]] || return 0 + kill -s "$signal" -- "-$launcher_pgid" 2>/dev/null || true +} + +signal_owned_processes() { + local signal=$1 + local index pid ticks + for ((index = ${#tree_pids[@]} - 1; index >= 0; index--)); do + pid=${tree_pids[index]} + ticks=${tree_start_ticks[$pid]-} + if identity_alive "$pid" "$ticks"; then + kill -s "$signal" "$pid" 2>/dev/null || true + fi + done +} + +wait_for_owned_exit() { + local timeout_seconds=$1 + local deadline=$((SECONDS + timeout_seconds)) + local pid ticks alive + while ((SECONDS < deadline)); do + alive=0 + for pid in "${tree_pids[@]}"; do + ticks=${tree_start_ticks[$pid]-} + if identity_alive "$pid" "$ticks"; then + alive=1 + break + fi + done + if ((alive == 0)); then + return 0 + fi + sleep 0.2 + done + return 1 +} + +dump_logs() { + echo "--- desktop startup stdout ---" >&2 + cat "$stdout_log" >&2 2>/dev/null || true + echo "--- desktop startup stderr ---" >&2 + cat "$stderr_log" >&2 2>/dev/null || true +} + +cleanup_state_dir() { + [[ "$remove_state_dir_on_exit" == 1 ]] || return 0 + [[ "$state_dir" =~ ^/tmp/orca-appimage-startup\.[^/]+$ ]] || return 0 + [[ -d "$state_dir" && ! -L "$state_dir" && -O "$state_dir" ]] || return 0 + rm -rf -- "$state_dir" +} + +capture_launcher_status() { + [[ "$launcher_waited" == false ]] || return 0 + [[ -n "$launcher_pid" ]] || return 1 + if wait "$launcher_pid"; then + launcher_status=0 + else + launcher_status=$? + fi + launcher_waited=true +} + +report_launcher_exit() { + local reason=$1 + local observed_status=unknown + local exit_status=1 + if capture_launcher_status; then + observed_status=$launcher_status + if ((launcher_status != 0)); then + exit_status=$launcher_status + fi + fi + echo "FAIL: desktop launcher exited before ${reason} (status=${observed_status})" >&2 + exit "$exit_status" +} + +cleanup() { + local status=$? + trap - EXIT + signal_process_group TERM || true + signal_owned_processes TERM || true + if ! wait_for_owned_exit 10; then + signal_process_group KILL || true + signal_owned_processes KILL || true + wait_for_owned_exit 5 || status=1 + fi + capture_launcher_status || true + if ((status != 0)); then + dump_logs + else + if ! cleanup_state_dir; then + status=1 + dump_logs + fi + fi + exit "$status" +} +trap cleanup EXIT + +mkdir -p "$state_dir/home" "$state_dir/config" "$state_dir/cache" "$state_dir/runtime" +chmod 700 "$state_dir/runtime" +export HOME="$state_dir/home" +export XDG_CONFIG_HOME="$state_dir/config" +export XDG_CACHE_HOME="$state_dir/cache" +export XDG_RUNTIME_DIR="$state_dir/runtime" +export LIBGL_ALWAYS_SOFTWARE=1 +export ORCA_STARTUP_DIAGNOSTICS=1 +ulimit -c 0 + +[[ -r "$appimage" ]] || { echo "FAIL: AppImage is not readable: $appimage" >&2; exit 1; } +[[ -x "$appimage" ]] || { echo "FAIL: AppImage is not executable: $appimage" >&2; exit 1; } + +setsid --wait dbus-run-session -- xvfb-run -a "$appimage" --appimage-extract-and-run --no-sandbox \ + >"$stdout_log" 2>"$stderr_log" & +launcher_pid=$! +launcher_start_ticks=$(read_start_ticks "$launcher_pid" 2>/dev/null || true) +launcher_pgid=$(ps -o pgid= -p "$launcher_pid" 2>/dev/null | tr -d ' ' || true) +if [[ -z "$launcher_start_ticks" ]]; then + report_launcher_exit 'its identity could be recorded' +fi + +marker_seen=false +deadline=$((SECONDS + startup_timeout_seconds)) +while ((SECONDS < deadline)); do + if grep -Eq '^\[startup\] updater-setup-done t=[0-9]+$' "$stderr_log"; then + marker_seen=true + break + fi + if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then + report_launcher_exit 'the updater-setup-done marker' + fi + sleep 0.2 +done +if [[ "$marker_seen" != true ]]; then + if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then + report_launcher_exit 'the updater-setup-done marker' + fi + echo "FAIL: desktop AppImage did not emit updater-setup-done within ${startup_timeout_seconds}s" >&2 + exit 1 +fi +if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then + echo "FAIL: desktop launcher identity changed after startup marker" >&2 + exit 1 +fi + +collect_process_tree +xvfb_pids=() +for pid in "${tree_pids[@]}"; do + if process_is_xvfb "$pid"; then + xvfb_pids+=("$pid") + fi +done +if ((${#xvfb_pids[@]} == 0)); then + echo "FAIL: no launcher-owned Xvfb process was found after startup" >&2 + exit 1 +fi +for pid in "${xvfb_pids[@]}"; do + if ! identity_alive "$pid" "${tree_start_ticks[$pid]-}"; then + echo "FAIL: launcher-owned Xvfb identity changed before cleanup" >&2 + exit 1 + fi +done + +echo "Desktop AppImage startup validation passed (launcher=${launcher_pid}, xvfb=${xvfb_pids[*]})." diff --git a/config/docker/headless-serve-shutdown/run-signal-case.sh b/config/docker/headless-serve-shutdown/run-signal-case.sh index 3cbf594ae1e..2d561629170 100755 --- a/config/docker/headless-serve-shutdown/run-signal-case.sh +++ b/config/docker/headless-serve-shutdown/run-signal-case.sh @@ -6,7 +6,9 @@ app_root=${ORCA_TEST_APP_ROOT:-/artifacts/root} signal_target_kind=${ORCA_SIGNAL_TARGET:-app} entrypoint_kind=${ORCA_TEST_ENTRYPOINT:-app} int_delivery=${ORCA_INT_DELIVERY:-foreground-process-group} -startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-90} +# Packaged Electron startup can approach 90s on a cold CI runner; leave room +# for the readiness line to reach the log before the observer deadline. +startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-180} if ((EUID == 0)); then exec runuser --user orca --preserve-environment -- "$0" "$@" @@ -41,8 +43,8 @@ chmod 700 "$XDG_RUNTIME_DIR" case "$entrypoint_kind" in app) entrypoint=("$app_root/AppRun" --no-sandbox) ;; + appimage) entrypoint=(/input/orca.AppImage --appimage-extract-and-run --no-sandbox) ;; launcher) - export ELECTRON_DISABLE_SANDBOX=1 entrypoint=("$app_root/resources/bin/orca-ide") ;; *) echo "unsupported entrypoint: $entrypoint_kind" >&2; exit 64 ;; @@ -53,18 +55,50 @@ setsid env -u DISPLAY "${entrypoint[@]}" serve --port 0 --pairing-address 127.0. app_pid=$! app_start_ticks=$(awk '{print $22}' "/proc/$app_pid/stat") -# The inner shell expands its positional parameters. -# shellcheck disable=SC2016 -ready_line=$(timeout "$startup_timeout_seconds" bash -c ' - tail --pid="$1" -n +1 -F "$2" 2>/dev/null \ - | jq --unbuffered -nc '\''first(inputs | select(.type == "orca_server_ready" and .schemaVersion == 1))'\'' -' bash "$app_pid" "$stdout_log" || true) +# jq's `inputs` waits for EOF even when wrapped in `first`, so a tail -F +# observer can outlive the timeout and leak into the next signal case. Poll +# finite snapshots instead; each parser invocation has a definite EOF. +read_ready_line() { + sed -u -n 's/^[^{]*//p' "$stdout_log" \ + | jq --unbuffered -Rnc 'first(inputs | fromjson? | select(.type == "orca_server_ready" and .schemaVersion == 1))' +} + +ready_line='' +startup_deadline=$((SECONDS + startup_timeout_seconds)) +while (( SECONDS < startup_deadline )); do + ready_line=$(read_ready_line) + [[ -n "$ready_line" ]] && break + kill -0 "$app_pid" 2>/dev/null || break + sleep 1 +done +# A readiness event can land as the final poll races the write. +if [[ -z "$ready_line" ]]; then + ready_line=$(read_ready_line) +fi if [[ -z "$ready_line" ]]; then cat "$stdout_log" "$stderr_log" >&2 - echo "FAIL: AppRun exited or timed out before orca_server_ready" >&2 + echo "FAIL: entrypoint exited or timed out before orca_server_ready" >&2 exit 1 fi +registered_cli_verified=false +if [[ "$entrypoint_kind" == appimage ]]; then + registered_cli="$HOME/.local/bin/orca-ide" + expected_target="$XDG_CACHE_HOME/orca/appimage/launcher/orca-ide" + actual_target=$(readlink "$registered_cli" 2>/dev/null || true) + if [[ "$actual_target" != "$expected_target" ]]; then + echo "FAIL: registered CLI target is ${actual_target:-missing}; expected $expected_target" >&2 + exit 1 + fi + if ! registered_help=$("$registered_cli" --help 2>&1) \ + || [[ "$registered_help" != *'Usage: orca '* ]]; then + echo "FAIL: registered CLI did not execute the packaged help command" >&2 + printf '%s\n' "$registered_help" >&2 + exit 1 + fi + registered_cli_verified=true +fi + bound_endpoint=$(jq -r '.boundEndpoint' <<<"$ready_line") bound_port=${bound_endpoint##*:} listener_before=$(ss -H -ltnp "sport = :$bound_port" || true) @@ -72,6 +106,7 @@ if [[ -z "$listener_before" ]]; then echo "FAIL: ready listener has no socket owner at $bound_endpoint" >&2 exit 1 fi +listener_before_pids=$(grep -oE 'pid=[0-9]+' <<<"$listener_before" | cut -d= -f2 || true) tree_pids=() declare -A tree_start_ticks @@ -104,8 +139,15 @@ fi signal_target_pid=$app_pid if [[ "$signal_target_kind" == serving-electron ]]; then - signal_target_pid=$(awk '/\/orca-ide .* --serve / {print $1; exit}' <<<"$tree_snapshot") + # The ready socket identifies the serving Electron even when AppImage's + # extraction wrapper rewrites the command line before it reaches Chromium. + signal_target_pid=$(head -n1 <<<"$listener_before_pids") [[ -n "$signal_target_pid" ]] || { echo "FAIL: serving Electron process not found" >&2; exit 1; } + if [[ -z "${tree_start_ticks[$signal_target_pid]+present}" ]]; then + echo "FAIL: ready listener PID $signal_target_pid is outside the entrypoint process tree" >&2 + echo "listener: $listener_before" >&2 + exit 1 + fi elif [[ "$signal_target_kind" != app ]]; then echo "unsupported signal target: $signal_target_kind" >&2 exit 64 @@ -138,17 +180,25 @@ fi kill "$watchdog_pid" 2>/dev/null || true wait "$watchdog_pid" 2>/dev/null || true -listener_after=$(ss -H -ltnp "sport = :$bound_port" || true) -survivors=() -for pid in "${tree_pids[@]}"; do - if [[ -r "/proc/$pid/stat" ]] \ - && [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "${tree_start_ticks[$pid]}" ]] \ - && ps -o stat= -p "$pid" 2>/dev/null | grep -qv '^Z'; then - survivors+=("$pid") +# Crashpad can exit just after Electron; poll all owned shutdown state for up to 5s. +for shutdown_poll in {0..50}; do + listener_after=$(ss -H -ltnp "sport = :$bound_port" || true) + survivors=() + for pid in "${tree_pids[@]}"; do + if [[ -r "/proc/$pid/stat" ]] \ + && [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "${tree_start_ticks[$pid]}" ]] \ + && ps -o stat= -p "$pid" 2>/dev/null | grep -qv '^Z'; then + survivors+=("$pid") + fi + done + owned_residue=$(ps -eo pid=,ppid=,stat=,args= | awk -v state="$state_dir" \ + '($0 ~ state || $0 ~ /\/artifacts\/root\/orca-ide/ || $0 ~ /[X]vfb :99 /) && $0 !~ /awk -v state=/ {print}' || true) + if [[ -z "$listener_after" && -z "$owned_residue" ]] \ + && ((${#survivors[@]} == 0)); then + break fi + ((shutdown_poll < 50)) && sleep 0.1 done -owned_residue=$(ps -eo pid=,ppid=,stat=,args= | awk -v state="$state_dir" \ - '($0 ~ state || $0 ~ /\/artifacts\/root\/orca-ide/ || $0 ~ /[X]vfb :99 /) && $0 !~ /awk -v state=/ {print}' || true) canary_alive=false if kill -0 "$canary_pid" 2>/dev/null \ @@ -170,16 +220,18 @@ jq -nc \ --argjson signalTargetPid "$signal_target_pid" \ --arg endpoint "$bound_endpoint" \ --arg listenerBefore "$listener_before" \ + --arg listenerBeforePids "$listener_before_pids" \ --arg listenerAfter "$listener_after" \ --arg xvfbPids "$xvfb_pids" \ --arg treeBefore "$tree_snapshot" \ --argjson waitStatus "$wait_status" \ --argjson fatalEvidence "$fatal_evidence" \ --argjson canaryAlive "$canary_alive" \ + --argjson registeredCliVerified "$registered_cli_verified" \ --arg survivors "${survivors[*]:-}" \ --arg residue "$owned_residue" \ --arg corePattern "$(cat /proc/sys/kernel/core_pattern)" \ - '{signal:$signal,signalDelivery:$signalDelivery,entrypointKind:$entrypointKind,signalTargetKind:$signalTargetKind,appPid:$appPid,signalTargetPid:$signalTargetPid,boundEndpoint:$endpoint,listenerBefore:$listenerBefore,listenerAfter:$listenerAfter,xvfbPids:$xvfbPids,treeBefore:$treeBefore,waitStatus:$waitStatus,fatalEvidence:$fatalEvidence,canaryAlive:$canaryAlive,survivingTreePids:$survivors,ownedResidue:$residue,corePattern:$corePattern}' + '{signal:$signal,signalDelivery:$signalDelivery,entrypointKind:$entrypointKind,signalTargetKind:$signalTargetKind,appPid:$appPid,signalTargetPid:$signalTargetPid,boundEndpoint:$endpoint,listenerBefore:$listenerBefore,listenerBeforePids:$listenerBeforePids,listenerAfter:$listenerAfter,xvfbPids:$xvfbPids,treeBefore:$treeBefore,waitStatus:$waitStatus,fatalEvidence:$fatalEvidence,canaryAlive:$canaryAlive,registeredCliVerified:$registeredCliVerified,survivingTreePids:$survivors,ownedResidue:$residue,corePattern:$corePattern}' if ((wait_status != 0)) || [[ -n "$listener_after" ]] || [[ "$fatal_evidence" != false ]] \ || [[ "$canary_alive" != true ]] || ((${#survivors[@]})) || [[ -n "$owned_residue" ]]; then diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 13633ed8e01..06d41bad344 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -1,4 +1,4 @@ -const { chmodSync, existsSync, readdirSync } = require('node:fs') +const { chmodSync, existsSync, readdirSync, readFileSync, writeFileSync } = require('node:fs') const { execFileSync } = require('node:child_process') const { join, resolve } = require('node:path') const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs') @@ -18,6 +18,7 @@ const { verifyPackagedNodePtyJobOwnership } = require('./scripts/verify-packaged-node-pty-job-ownership.cjs') const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') +const { verifyStaticAppImagePackage } = require('./scripts/static-appimage-package-contract.cjs') // Why: dev-channel builds must carry the *release* identity — same bundle id, // Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to @@ -104,6 +105,29 @@ const winSpeechNativeResource = { from: 'node_modules/sherpa-onnx-win-x64', to: 'node_modules/sherpa-onnx-win-x64' } +// electron-builder replaces these defaults when `depends` is configured; retain +// Electron's loader requirements alongside Orca's headless-host dependencies. +const debElectronRuntimeDependencies = [ + 'libgtk-3-0', + 'libnotify4', + 'libnss3', + 'libxss1', + 'libxtst6', + 'xdg-utils', + 'libatspi2.0-0', + 'libuuid1', + 'libsecret-1-0' +] +const rpmElectronRuntimeDependencies = [ + 'gtk3', + 'libnotify', + 'nss', + 'libXScrnSaver', + '(libXtst or libXtst6)', + 'xdg-utils', + 'at-spi2-core', + '(libuuid or libuuid1)' +] // Why mirrored, not imported: this config is CJS loaded by electron-builder outside the TS build. // Keep in sync with isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts and with @@ -115,6 +139,7 @@ module.exports = { appId, productName: 'Orca', protocols: [{ name: 'Orca', schemes: ['orca'] }], + toolsets: { appimage: '1.0.3' }, ...(devChannelBuildVersion ? { extraMetadata: { version: devChannelBuildVersion } } : localBuildVersion @@ -235,12 +260,21 @@ module.exports = { 'node_modules/zod/**', 'node_modules/yaml/**' ], + artifactBuildCompleted: ({ file, arch }) => { + if (file.endsWith('.AppImage')) { + verifyStaticAppImagePackage(file, arch) + } + }, afterPack: async (context) => { // Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node // requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902). // Fail packaging if any bundled native binary exceeds the supported floor. if (context.electronPlatformName === 'linux') { - verifyLinuxGlibcFloor(context.appOutDir) + // Why the arch is passed: symbol-version checks pass happily on a wrong-architecture binary, + // so a cross-built slice could ship the host's pty.node and only fail at runtime. + verifyLinuxGlibcFloor(context.appOutDir, { + targetArch: { 1: 'x64', 3: 'arm64' }[context.arch] + }) } const resourcesDir = context.electronPlatformName === 'darwin' @@ -254,6 +288,10 @@ module.exports = { if (!existsSync(resourcesDir)) { throw new Error(`Missing packaged resources directory: ${resourcesDir}`) } + // FpmTarget replaces this with deb/rpm while building those artifacts from the shared app tree. + if (context.electronPlatformName === 'linux') { + writeFileSync(join(resourcesDir, 'package-type'), 'AppImage') + } if (context.electronPlatformName === 'darwin') { const architectureByEnum = { 1: 'x64', 3: 'arm64' } const architecture = architectureByEnum[context.arch] @@ -273,6 +311,7 @@ module.exports = { } writeMacBuildCompatibility(resourcesDir, { version, commit, architecture }) } + stampPackagedCliVersion(resourcesDir, context.packager.appInfo.version) prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch) verifyPackagedMainRuntimeDeps(resourcesDir) // Why: boot the packaged daemon-entry under plain Node, but only for the @@ -522,7 +561,8 @@ module.exports = { }, featureWallResources ], - target: ['AppImage', 'deb'], + // Keep local artifacts aligned with the release pipeline. + target: ['AppImage', 'deb', 'rpm'], maintainer: 'stablyai', category: 'Utility' }, @@ -536,6 +576,7 @@ module.exports = { // Linux host — Chromium needs a display server even for offscreen rendering, // and serve starts Xvfb itself when present (see ensure-virtual-display.ts). depends: [ + ...debElectronRuntimeDependencies, 'python3', 'python3-gi', 'gir1.2-atspi-2.0', @@ -557,9 +598,9 @@ module.exports = { // Why: see deb depends. RPM distros ship Xvfb as xorg-x11-server-Xvfb (there // is no `xvfb` package), so the name differs from the deb here. depends: [ + ...rpmElectronRuntimeDependencies, 'python3', 'python3-gobject', - 'at-spi2-core', 'xdotool', 'xclip', 'xorg-x11-server-Xvfb' @@ -584,6 +625,16 @@ module.exports = { } } +// Stamp the effective channel version where node-mode CLI code can read it. +function stampPackagedCliVersion(resourcesDir, version) { + const packageJsonPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json') + if (!existsSync(packageJsonPath)) { + throw new Error(`Missing unpacked CLI package boundary: ${packageJsonPath}`) + } + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + writeFileSync(packageJsonPath, `${JSON.stringify({ ...packageJson, version }, null, 2)}\n`) +} + function chmodUnixCliLaunchers(resourcesDir, electronPlatformName) { if (electronPlatformName === 'win32') { return diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0c2337ba36f..83de3128ecf 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -16701,16 +16701,17 @@ "providers": ["local-daemon"], "coveredPlatforms": ["linux"], "coveredProviders": ["local-daemon"], - "coverageNotes": "An Ubuntu 26.04 amd64 container extracts the packaged AppImage into disposable HOME and XDG directories, leaves APPDIR unset to preserve extracted-AppRun direct serve mode, waits for structured serve readiness, then exercises terminal-style foreground-process-group SIGINT and the documented systemd KillMode=mixed main-PID SIGTERM in separate containers. Local evidence runs under Rosetta on an arm64 Docker host; native amd64 PR CI repeats the same foreground AppRun identity contract.", + "coverageNotes": "An Ubuntu 26.04 amd64 container first launches the original AppImage through dbus-run-session and xvfb-run with startup diagnostics, then extracts the packaged AppImage into disposable HOME and XDG directories, leaves APPDIR unset to preserve extracted-AppRun direct serve mode, waits for structured serve readiness, and exercises terminal-style foreground-process-group SIGINT plus the documented systemd KillMode=mixed main-PID SIGTERM in separate containers. Local evidence runs under Rosetta on an arm64 Docker host; native amd64 PR CI repeats the same startup and foreground-AppRun identity contracts.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/14109", "https://linear.app/stably/issue/STA-4051" ], "invariant": "After packaged foreground headless serve publishes structured readiness, one SIGINT or SIGTERM exits successfully without an Electron fatal trap or core evidence, releases the exact listener and owned Xvfb/process tree, and leaves an unrelated process identity untouched.", - "oracle": "For each signal, start a fresh unprivileged Ubuntu 26.04 container with disposable profile and runtime directories, a random loopback port, DISPLAY unset, software GL, and the extracted AppImage in a fresh session. Wait for orca_server_ready schema version 1, record the listener owner, process tree, owned Xvfb, and unrelated canary identities, deliver SIGINT to the foreground process group or the documented KillMode=mixed graceful SIGTERM to the AppRun PID, then require wait status zero, no Failed to shutdown, SIGTRAP, core, listener, recorded descendant, profile/AppImage/Xvfb residue, or changed canary identity. The 30-second bounds are failure deadlines, never success conditions.", + "oracle": "First start the original, readable-and-executable AppImage once in a fresh restricted Ubuntu 26.04 container through dbus-run-session -- xvfb-run -a --appimage-extract-and-run with ORCA_STARTUP_DIAGNOSTICS=1, and require the exact updater-setup-done marker within 90 seconds while fencing the launcher and owned Xvfb by PID start ticks. For each signal, start a separate unprivileged container with disposable profile and runtime directories, a random loopback port, DISPLAY unset, software GL, and the extracted AppImage in a fresh session. Wait for orca_server_ready schema version 1, record the listener owner, process tree, owned Xvfb, and unrelated canary identities, deliver SIGINT to the foreground process group or the documented KillMode=mixed graceful SIGTERM to the AppRun PID, then require wait status zero, no Failed to shutdown, SIGTRAP, core, listener, recorded descendant, profile/AppImage/Xvfb residue, or changed canary identity. The 30-second bounds are failure deadlines, never success conditions.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/ensure-virtual-display.test.ts config/scripts/headless-serve-shutdown-workflow.test.mjs --reporter=dot", "shellcheck config/docker/headless-serve-shutdown/run-signal-case.sh", + "shellcheck config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh", "node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage", "node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --platform linux/amd64" ], @@ -16718,7 +16719,8 @@ "src/main/startup/ensure-virtual-display.test.ts", "config/scripts/headless-serve-shutdown-workflow.test.mjs", "config/scripts/run-headless-serve-shutdown-docker.mjs", - "config/docker/headless-serve-shutdown/run-signal-case.sh" + "config/docker/headless-serve-shutdown/run-signal-case.sh", + "config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh" ], "assertionRefs": [ { @@ -16735,15 +16737,19 @@ "file": "config/scripts/headless-serve-shutdown-workflow.test.mjs", "assertions": [ "PR CI builds an x64 AppImage before invoking the packaged shutdown oracle", + "the original AppImage desktop startup oracle is wired before extraction and signal cases", + "the bound AppImage is readable and executable before desktop launch and extraction", "the documented systemd unit uses KillMode=mixed so graceful TERM targets Orca before its owned Xvfb" ] }, { "file": "config/scripts/run-headless-serve-shutdown-docker.mjs", "assertions": [ + "the original AppImage startup runs through dbus-run-session and xvfb-run with a bounded diagnostics marker", "SIGINT and SIGTERM run in separate disposable containers", "both signal failures are reported before the oracle exits", "the exact AppImage SHA-256, entrypoint, and signal target are published", + "the read-only AppImage bind is checked for read and execute permissions before extraction", "the launcher exec overlay isolates the related STA-4017 signal boundary" ] }, @@ -16754,6 +16760,14 @@ "SIGTERM reaches the exact AppRun PID under the documented systemd KillMode=mixed policy", "target and descendant identities are fenced by PID start ticks before signaling and residue checks" ] + }, + { + "file": "config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh", + "assertions": [ + "the original AppImage emits the exact updater-setup-done startup marker within 90 seconds", + "launcher and owned Xvfb identities are fenced by PID start ticks", + "cleanup sends bounded TERM then KILL signals and preserves failure logs" + ] } ], "evidenceRuns": [ @@ -16774,11 +16788,20 @@ "result": "passed", "durationSeconds": 48, "summary": "The extracted candidate AppRun passed process-group SIGINT and systemd-mixed main-PID SIGTERM under Ubuntu 26.04 amd64 emulation with status zero, no fatal evidence, full listener/Xvfb/tree cleanup, and an unchanged canary identity." + }, + { + "date": "2026-08-31", + "runner": "ci", + "platform": "linux", + "command": "node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --platform linux/amd64", + "result": "passed", + "durationSeconds": 1336, + "summary": "Native-amd64 PR package job https://github.com/stablyai/orca/actions/runs/33360129768/job/99389831915 built AppImage SHA-256 999d43bfe123e87a77fe917a5f46be1efd5c45a5205f99f02998a75136d8a793 and ran the restricted original-AppImage startup oracle before each of the three signal matrices. Each startup reached the exact updater-setup-done marker with stable launcher/Xvfb PID-start-tick identities and bounded TERM/KILL cleanup; SIGINT and SIGTERM then returned wait status 0 with no fatal evidence, listener, descendant, Xvfb, or canary residue. This is CI evidence only; no fresh local Docker oracle is claimed." } ], "runtimeBudget": { - "p95Seconds": 240, - "scope": "two fresh Ubuntu 26.04 containers, one per foreground signal" + "p95Seconds": 1800, + "scope": "three AppImage startup/extraction matrices, each with fresh Ubuntu 26.04 INT and TERM containers" }, "flakeHistory": { "status": "not-started", @@ -16805,6 +16828,105 @@ ], "demotionRule": "Keep experimental or demote if either signal traps, returns nonzero, retains its listener/Xvfb/run-owned process identity, touches the unrelated canary, or the focused gate flakes without an identified product or harness defect." }, + { + "id": "runtime.linux-cli-launch-contract", + "title": "Packaged Linux CLI commands run without FUSE, user namespaces, or a display", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "appimage-cli-entrypoint", + "surfaces": [ + "packaged Linux AppImage", + "bundled CLI launcher", + "extracted direct binary", + "desktop launch diagnosis" + ], + "platforms": ["linux"], + "providers": ["local-daemon"], + "coveredPlatforms": ["linux"], + "coveredProviders": ["local-daemon"], + "coverageNotes": "A restricted Ubuntu container stages the extracted AppImage payload with no /dev/fuse and with unprivileged user namespaces denied, then runs eight CLI cases across the bundled launcher and the extracted direct binary. x64 only, because PR CI builds only --x64.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/13719", + "https://github.com/stablyai/orca/issues/14229" + ], + "invariant": "On a host without FUSE and without unprivileged user namespaces, every packaged CLI entrypoint either completes its command or reports a diagnosis, and never terminates on a signal.", + "oracle": "Build the image, stage the AppImage payload, and run each case in the restricted container. Assert the preconditions first: unshare -Ur must fail and /dev/fuse must be absent, so a relaxed runner fails the job rather than silently skipping. For each case require the exact expected exit status and an expected substring of the command's own output, with the harness RESULT/CRASHED/PRECONDITION_FAILED control lines excluded so a case name can never satisfy its own assertion. Any status of 128 or above is a crash and fails immediately. The per-case timeout is a failure deadline, never a success condition.", + "commands": [ + "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage", + "shellcheck config/docker/cli-launch-contract/run-cli-case.sh" + ], + "testFiles": [ + "config/scripts/run-linux-cli-launch-contract-docker.mjs", + "config/docker/cli-launch-contract/run-cli-case.sh" + ], + "assertionRefs": [ + { + "file": "config/scripts/run-linux-cli-launch-contract-docker.mjs", + "assertions": [ + "the bundled launcher serves --help, --version, status, skills --help, and worktree list without Chromium", + "a direct binary launch reaching JavaScript runs the command instead of booting a GUI", + "a desktop launch with no display reports the missing-display diagnosis instead of trapping", + "a stale DISPLAY is diagnosed rather than trusted", + "expected output is matched against the command's own output, not the harness control lines" + ] + }, + { + "file": "config/docker/cli-launch-contract/run-cli-case.sh", + "assertions": [ + "the container refuses to run unless unprivileged user namespaces are denied and /dev/fuse is absent", + "an exit status of 128 or above is reported as a crash rather than compared to the expected status" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-31", + "runner": "ci", + "platform": "linux", + "command": "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage", + "result": "passed", + "durationSeconds": 40, + "summary": "PR package job https://github.com/stablyai/orca/actions/runs/33360129768/job/99389831915 ran all eight cases to ok on ubuntu-latest. The unshare and /dev/fuse preconditions held under moby's default seccomp profile rather than tripping." + }, + { + "date": "2026-09-01", + "runner": "local", + "platform": "linux", + "command": "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage", + "result": "passed", + "durationSeconds": 60, + "summary": "All eight cases passed on Ubuntu 24.04 amd64 hardware against an AppImage built from the stack tip, invoked against a copy of that artifact outside dist/." + } + ], + "runtimeBudget": { + "p95Seconds": 300, + "scope": "eight CLI launch cases in one restricted Ubuntu container" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "The harness is new; soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The same harness run against a stock release AppImage failed four of eight cases: nofuse-userns-bundled-version at status 93, and all three direct-binary cases crashed at status 133 (SIGTRAP, the uv_close abort of #13719 and #14229). The stack-tip AppImage passed all eight. Corroborated at artifact level: the stack-tip runtime is a static-pie ELF with no PT_INTERP, the stock runtime is dynamically linked. Caveat: the two skills cases previously asserted a substring that the harness's own RESULT line contained, so they passed independently of command output; both now assert the rendered help header, and the green runs above predate that change." + }, + "performanceBudget": { + "required": false, + "evidence": "The gate is CI-only and adds no product code path." + }, + "promotionCriteria": [ + "Collect 30 consecutive CI passes or 14 days without an unexplained flake.", + "Extend the matrix to arm64 once PR CI builds that architecture.", + "Re-run red/green against a stock AppImage after any change to the launcher entrypoint." + ], + "knownGaps": [ + "The preconditions depend on moby's default seccomp profile denying unshare(CLONE_NEWUSER) and on /dev/fuse being absent. A runner with a relaxed profile or a mounted /dev/fuse trips PRECONDITION_FAILED and fails the job rather than skipping.", + "x64 only: PR CI builds only --x64, so the arm64 launcher path is unexercised.", + "The harness covers CLI entrypoints only; it does not exercise a full desktop session." + ], + "demotionRule": "Keep experimental or demote if any case terminates on a signal, the preconditions stop holding on the CI runner, or an assertion can be satisfied by anything other than the command's own output." + }, { "id": "ssh-managed-hooks.node18-runtime-compatibility", "title": "SSH managed-hook companions load and install hooks on Node 18", diff --git a/config/scripts/build-linux-local.mjs b/config/scripts/build-linux-local.mjs new file mode 100644 index 00000000000..2328f00bede --- /dev/null +++ b/config/scripts/build-linux-local.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' + +const SUPPORTED_ARCHES = new Set(['x64', 'arm64']) + +/** Select the local Linux package architecture without relying on builder defaults. */ +export function resolveLinuxBuildArch({ + platform = process.platform, + hostArch = process.arch, + requestedArch = process.env.ORCA_LINUX_BUILD_ARCH +} = {}) { + const arch = requestedArch ?? (platform === 'linux' ? hostArch : 'x64') + if (!SUPPORTED_ARCHES.has(arch)) { + throw new Error( + `Unsupported Linux build architecture: ${arch}. Use ORCA_LINUX_BUILD_ARCH=x64|arm64.` + ) + } + return arch +} + +export function buildLinuxElectronBuilderArgs(arch, extraArgs = []) { + if (!SUPPORTED_ARCHES.has(arch)) { + throw new Error(`Unsupported Linux build architecture: ${arch}`) + } + return [ + 'exec', + 'electron-builder', + '--config', + 'config/electron-builder.config.cjs', + '--linux', + 'AppImage', + 'deb', + 'rpm', + `--${arch}`, + ...extraArgs + ] +} + +export function runLocalLinuxBuild({ + arch = resolveLinuxBuildArch(), + extraArgs = [], + environment = process.env, + execFile = execFileSync, + platform = process.platform, + cwd = resolve(import.meta.dirname, '../..') +} = {}) { + const env = { ...environment } + if (arch === 'arm64') { + env.ORCA_LINUX_ARM64_RELEASE = '1' + } else { + delete env.ORCA_LINUX_ARM64_RELEASE + } + const pnpm = platform === 'win32' ? 'pnpm.cmd' : 'pnpm' + execFile(pnpm, buildLinuxElectronBuilderArgs(arch, extraArgs), { + cwd, + env, + stdio: 'inherit' + }) +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + runLocalLinuxBuild({ extraArgs: process.argv.slice(2) }) +} diff --git a/config/scripts/build-linux-local.test.mjs b/config/scripts/build-linux-local.test.mjs new file mode 100644 index 00000000000..684c83f3265 --- /dev/null +++ b/config/scripts/build-linux-local.test.mjs @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + buildLinuxElectronBuilderArgs, + resolveLinuxBuildArch, + runLocalLinuxBuild +} from './build-linux-local.mjs' + +describe('local Linux build target', () => { + it('is the package script used by the local Linux build', () => { + const packageJson = JSON.parse( + readFileSync(resolve(import.meta.dirname, '../../package.json'), 'utf8') + ) + expect(packageJson.scripts['build:linux']).toContain( + 'node config/scripts/build-linux-local.mjs' + ) + }) + + it('follows a native Linux host architecture', () => { + expect(resolveLinuxBuildArch({ platform: 'linux', hostArch: 'arm64' })).toBe('arm64') + expect(resolveLinuxBuildArch({ platform: 'linux', hostArch: 'x64' })).toBe('x64') + }) + + it('defaults cross-platform Linux builds to x64 and allows an explicit override', () => { + expect(resolveLinuxBuildArch({ platform: 'darwin', hostArch: 'arm64' })).toBe('x64') + expect( + resolveLinuxBuildArch({ platform: 'darwin', hostArch: 'arm64', requestedArch: 'arm64' }) + ).toBe('arm64') + }) + + it('rejects unsupported architectures', () => { + expect(() => resolveLinuxBuildArch({ platform: 'linux', hostArch: 'ia32' })).toThrow( + 'Unsupported Linux build architecture' + ) + expect(() => buildLinuxElectronBuilderArgs('ia32')).toThrow( + 'Unsupported Linux build architecture' + ) + }) + + it('passes an explicit target and matching artifact-name environment', () => { + const execFile = vi.fn() + runLocalLinuxBuild({ + arch: 'arm64', + environment: { PATH: '/bin', ORCA_LINUX_ARM64_RELEASE: undefined }, + execFile, + platform: 'linux', + cwd: '/workspace' + }) + expect(execFile).toHaveBeenCalledWith( + 'pnpm', + buildLinuxElectronBuilderArgs('arm64'), + expect.objectContaining({ + cwd: '/workspace', + env: expect.objectContaining({ ORCA_LINUX_ARM64_RELEASE: '1' }), + stdio: 'inherit' + }) + ) + + expect(buildLinuxElectronBuilderArgs('x64')).toEqual( + expect.arrayContaining(['--linux', 'AppImage', 'deb', 'rpm', '--x64']) + ) + + runLocalLinuxBuild({ + arch: 'x64', + environment: { PATH: '/bin', ORCA_LINUX_ARM64_RELEASE: '1' }, + execFile, + platform: 'linux', + cwd: '/workspace' + }) + expect(execFile).toHaveBeenLastCalledWith( + 'pnpm', + buildLinuxElectronBuilderArgs('x64'), + expect.objectContaining({ + env: expect.not.objectContaining({ ORCA_LINUX_ARM64_RELEASE: expect.anything() }) + }) + ) + }) + + it('uses the Windows pnpm command name when cross-host packaging', () => { + const execFile = vi.fn() + runLocalLinuxBuild({ arch: 'x64', execFile, platform: 'win32', cwd: 'C:\\workspace' }) + expect(execFile.mock.calls[0]?.[0]).toBe('pnpm.cmd') + }) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 347cae8fcfc..3f055ce222d 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -1,4 +1,4 @@ -import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,17 +10,8 @@ const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main') const require = createRequire(import.meta.url) const electronBuilderConfig = require('../electron-builder.config.cjs') const { FileMatcher } = require('app-builder-lib/out/fileMatcher') +const FpmTarget = require('app-builder-lib/out/targets/FpmTarget').default const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs') -const { - createPackagedRuntimeNodeModuleResources, - findAsarEntry, - prunePackagedNodePty, - prunePackagedParcelWatcher, - prunePackagedSherpaOnnx, - prunePackagedRuntimeTypeAndSourceMapArtifacts, - prunePackagedZodSources, - verifyPackagedMainRuntimeDeps -} = require('../packaged-runtime-node-modules.cjs') describe('electron-builder config', () => { it('keeps the packaged app identity aligned with local-build validation', () => { @@ -280,8 +271,9 @@ describe('electron-builder config', () => { expect(electronBuilderConfig.linux.desktop.entry.StartupWMClass).toBe('orca') }) - it('uses AppImage and deb as local Linux targets without changing existing artifact names', () => { - expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb']) + it('uses the release artifact set as local Linux targets without changing existing names', () => { + expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb', 'rpm']) + expect(electronBuilderConfig.toolsets).toEqual({ appimage: '1.0.3' }) expect(electronBuilderConfig.appImage.artifactName).toBe('orca-linux.${ext}') expect(electronBuilderConfig.deb.artifactName).toBe('orca-ide_${version}_${arch}.${ext}') expect(electronBuilderConfig.rpm).toMatchObject({ @@ -290,6 +282,33 @@ describe('electron-builder config', () => { }) }) + it('retains electron-builder runtime dependencies in deb and rpm packages', () => { + for (const target of ['deb', 'rpm']) { + const dependencies = electronBuilderConfig[target].depends + expect(dependencies).toEqual( + expect.arrayContaining(FpmTarget.prototype.getDefaultDepends(target)) + ) + expect(new Set(dependencies).size).toBe(dependencies.length) + } + }) + + it('validates each AppImage before electron-builder publishes it', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-appimage-')) + try { + const appImage = join(root, 'orca-linux.AppImage') + await writeFile(appImage, 'not an ELF') + await chmod(appImage, 0o755) + + expect(() => + electronBuilderConfig.artifactBuildCompleted({ file: appImage, arch: 1 }) + ).toThrow(/ELF header is outside/) + expect(() => + electronBuilderConfig.artifactBuildCompleted({ file: join(root, 'orca-ide.deb') }) + ).not.toThrow() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) it('uses a distinct AppImage name for Linux arm64 release uploads', () => { const configPath = require.resolve('../electron-builder.config.cjs') const original = process.env.ORCA_LINUX_ARM64_RELEASE @@ -367,286 +386,6 @@ describe('electron-builder config', () => { expect(electronBuilderConfig.npmRebuild).toBe(true) }) - it('verifies packaged main runtime deps from Windows-style asar entries', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-')) - try { - await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8') - await mkdir(join(resourcesDir, 'node_modules', 'yaml'), { recursive: true }) - await mkdir(join(resourcesDir, 'node_modules', 'zod'), { recursive: true }) - - const sources = new Map([ - ['out\\main\\index.js', 'const z = require("zod")'], - ['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")'] - ]) - const asar = { - listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`), - extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8') - } - - expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow() - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - }) - - it('normalizes host-specific asar entry separators', () => { - expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe( - '\\out\\main\\index.js' - ) - expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js') - }) - - it('prunes non-target node-pty architecture outputs from packaged runtime resources', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-')) - try { - const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') - const prebuildsDir = join(nodePtyDir, 'prebuilds') - const binDir = join(nodePtyDir, 'bin') - await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true }) - await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true }) - await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true }) - await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true }) - await mkdir(join(binDir, 'darwin-arm64-148'), { recursive: true }) - await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true }) - await mkdir(join(nodePtyDir, 'third_party', 'conpty'), { - recursive: true - }) - await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true }) - - prunePackagedNodePty(resourcesDir, 'darwin', 3) - - await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64']) - await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148']) - await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([]) - await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([]) - expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow( - 'Unsupported packaged runtime architecture: 4' - ) - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - }) - - it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => { - for (const [arch, electronArch] of [ - ['x64', 1], - ['arm64', 3] - ]) { - const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`)) - try { - const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') - const releaseDir = join(nodePtyDir, 'build', 'Release') - const conptyRoot = join(nodePtyDir, 'third_party', 'conpty', '0.1.0') - await mkdir(releaseDir, { recursive: true }) - await writeFile(join(releaseDir, 'conpty.node'), 'native addon placeholder', 'utf8') - for (const sourceArch of ['x64', 'arm64']) { - const sourceDir = join(conptyRoot, `win10-${sourceArch}`) - await mkdir(sourceDir, { recursive: true }) - await writeFile(join(sourceDir, 'conpty.dll'), `dll payload ${sourceArch}`, 'utf8') - await writeFile( - join(sourceDir, 'OpenConsole.exe'), - `console payload ${sourceArch}`, - 'utf8' - ) - } - - prunePackagedNodePty(resourcesDir, 'win32', electronArch) - - await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe( - `dll payload ${arch}` - ) - await expect(readFile(join(releaseDir, 'conpty', 'OpenConsole.exe'), 'utf8')).resolves.toBe( - `console payload ${arch}` - ) - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - } - }) - - it('includes external main dependencies in the packaged runtime closure', () => { - // Why: the main process imports '@parcel/watcher' for filesystem change - // events; if it is absent from the packaged closure the serve host silently - // stops propagating file changes to clients (regression guard for #4851). - const packaged = createPackagedRuntimeNodeModuleResources() - const packagedTargets = packaged.map((resource) => resource.to) - expect(packagedTargets).toContain(join('node_modules', '@parcel', 'watcher')) - expect( - packagedTargets.some((target) => - target.startsWith(join('node_modules', '@parcel', 'watcher-')) - ) - ).toBe(true) - expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile')) - }) - - it('prunes non-target @parcel/watcher architecture subpackages', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-')) - try { - const parcelDir = join(resourcesDir, 'node_modules', '@parcel') - await mkdir(join(parcelDir, 'watcher'), { recursive: true }) - await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true }) - await mkdir(join(parcelDir, 'watcher-darwin-x64'), { recursive: true }) - await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true }) - await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true }) - await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true }) - - prunePackagedParcelWatcher(resourcesDir, 'linux', 'arm64') - - await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([ - 'watcher', - 'watcher-linux-arm64-glibc' - ]) - expect(() => prunePackagedParcelWatcher(resourcesDir, 'linux', 'universal')).toThrow( - 'Unsupported packaged runtime architecture: universal' - ) - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - }) - - it('leaves unrelated @parcel/* runtime deps untouched when pruning the watcher', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-unrelated-')) - try { - const parcelDir = join(resourcesDir, 'node_modules', '@parcel') - await mkdir(join(parcelDir, 'watcher'), { recursive: true }) - await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true }) - await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true }) - // A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage. - await mkdir(join(parcelDir, 'transformer-js'), { recursive: true }) - - prunePackagedParcelWatcher(resourcesDir, 'linux', 1) - - await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([ - 'transformer-js', - 'watcher', - 'watcher-linux-x64-glibc' - ]) - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - }) - - it('prunes type declaration artifacts from packaged runtime node_modules', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-type-prune-')) - try { - const packageDir = join(resourcesDir, 'node_modules', 'example-package') - await mkdir(join(packageDir, 'dist'), { recursive: true }) - await writeFile(join(packageDir, 'dist', 'index.cjs'), 'module.exports = {}', 'utf8') - await writeFile(join(packageDir, 'dist', 'index.d.ts'), 'export type Value = string', 'utf8') - await writeFile(join(packageDir, 'dist', 'index.d.cts'), 'export type Value = string', 'utf8') - await writeFile(join(packageDir, 'dist', 'index.d.mts'), 'export type Value = string', 'utf8') - await writeFile(join(packageDir, 'dist', 'index.d.cts.map'), '{}', 'utf8') - await writeFile(join(packageDir, 'dist', 'index.d.mts.map'), '{}', 'utf8') - - prunePackagedRuntimeTypeAndSourceMapArtifacts(resourcesDir) - - await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs']) - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - }) - - it('prunes duplicate darwin sherpa-onnx runtime dylib aliases', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-sherpa-prune-')) - try { - const packageDir = join(resourcesDir, 'node_modules', 'sherpa-onnx-darwin-arm64') - await mkdir(packageDir, { recursive: true }) - await writeFile(join(packageDir, 'sherpa-onnx.node'), '', 'utf8') - await writeFile(join(packageDir, 'libonnxruntime.1.23.2.dylib'), '', 'utf8') - await writeFile(join(packageDir, 'libonnxruntime.dylib'), '', 'utf8') - - prunePackagedSherpaOnnx(resourcesDir, 'darwin') - - await expect(readdir(packageDir).then((entries) => entries.sort())).resolves.toEqual([ - 'libonnxruntime.1.23.2.dylib', - 'sherpa-onnx.node' - ]) - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - }) - - it('prunes zod TypeScript sources from packaged runtime resources', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-zod-prune-')) - try { - const packageDir = join(resourcesDir, 'node_modules', 'zod') - await mkdir(join(packageDir, 'src'), { recursive: true }) - await writeFile(join(packageDir, 'index.cjs'), 'module.exports = {}', 'utf8') - await writeFile(join(packageDir, 'src', 'index.ts'), 'export const value = true', 'utf8') - - prunePackagedZodSources(resourcesDir) - - await expect(readdir(packageDir)).resolves.toEqual(['index.cjs']) - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - }) - - it('fails when the packaged resources directory is missing', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-')) - try { - await expect( - electronBuilderConfig.afterPack({ - appOutDir: root, - electronPlatformName: 'win32' - }) - ).rejects.toThrow(/Missing packaged resources directory/) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) - - it.skipIf(process.platform === 'win32')( - 'marks packaged Unix CLI launchers executable', - async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-')) - try { - const resourcesDir = join(root, 'linux-unpacked', 'resources') - const launcherPath = join(resourcesDir, 'bin', 'orca-ide') - await mkdir(join(resourcesDir, 'bin'), { recursive: true }) - await cp( - join(process.cwd(), 'resources', 'plugins', 'launch'), - join(resourcesDir, 'plugins', 'launch'), - { recursive: true } - ) - await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true }) - // Why: afterPack now fails hard when the unpacked daemon entry is - // missing, so the fixture must carry one like a real package layout. - const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') - await mkdir(unpackedMainDir, { recursive: true }) - await writeFile( - join(unpackedMainDir, 'daemon-entry.js'), - 'console.error("Usage: daemon-entry "); process.exit(1)\n', - 'utf8' - ) - const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') - await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true }) - await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8') - await writeFile( - join(unpackedCliDir, 'index.js'), - [ - 'const args = process.argv.slice(2)', - "if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))", - "else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)", - 'else console.log(JSON.stringify({ executed: false }))' - ].join('\n'), - 'utf8' - ) - await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) - - await electronBuilderConfig.afterPack({ - appOutDir: join(root, 'linux-unpacked'), - electronPlatformName: 'linux', - arch: 1 - }) - - expect((await stat(launcherPath)).mode & 0o111).not.toBe(0) - } finally { - await rm(root, { recursive: true, force: true }) - } - } - ) - // Why: the .deb/.rpm update-recovery path keys entirely off the resources/package-type marker that // app-builder-lib's FpmTarget writes. If packaging silently stops shipping an fpm target, or adds // one the recovery path does not cover, getLinuxRootPackageType() returns null, autoInstallOnAppQuit @@ -680,5 +419,17 @@ describe('electron-builder config', () => { expect(source).toContain(`value === '${target}'`) } }) + + it('keeps the pinned FpmTarget overwrite for configured deb and rpm artifacts', async () => { + const source = await readFile( + require.resolve('app-builder-lib/out/targets/FpmTarget'), + 'utf8' + ) + + expect(source).toContain('path.join(resourceDir, "package-type"), target') + for (const target of RECOVERABLE_TARGETS) { + expect(electronBuilderConfig[target]).toBeDefined() + } + }) }) }) diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs new file mode 100644 index 00000000000..d2407776fa7 --- /dev/null +++ b/config/scripts/electron-builder-runtime-resources.test.mjs @@ -0,0 +1,308 @@ +import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const electronBuilderConfig = require('../electron-builder.config.cjs') +const { + createPackagedRuntimeNodeModuleResources, + findAsarEntry, + prunePackagedNodePty, + prunePackagedParcelWatcher, + prunePackagedSherpaOnnx, + prunePackagedRuntimeTypeAndSourceMapArtifacts, + prunePackagedZodSources, + verifyPackagedMainRuntimeDeps +} = require('../packaged-runtime-node-modules.cjs') + +describe('packaged runtime resources', () => { + it('verifies packaged main runtime deps from Windows-style asar entries', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-')) + try { + await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8') + await mkdir(join(resourcesDir, 'node_modules', 'yaml'), { recursive: true }) + await mkdir(join(resourcesDir, 'node_modules', 'zod'), { recursive: true }) + + const sources = new Map([ + ['out\\main\\index.js', 'const z = require("zod")'], + ['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")'] + ]) + const asar = { + listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`), + extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8') + } + + expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow() + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('normalizes host-specific asar entry separators', () => { + expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe( + '\\out\\main\\index.js' + ) + expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js') + }) + + it('prunes non-target node-pty architecture outputs from packaged runtime resources', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-')) + try { + const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') + const prebuildsDir = join(nodePtyDir, 'prebuilds') + const binDir = join(nodePtyDir, 'bin') + await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true }) + await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true }) + await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true }) + await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true }) + await mkdir(join(binDir, 'darwin-arm64-148'), { recursive: true }) + await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true }) + await mkdir(join(nodePtyDir, 'third_party', 'conpty'), { + recursive: true + }) + await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true }) + + prunePackagedNodePty(resourcesDir, 'darwin', 3) + + await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64']) + await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148']) + await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([]) + await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([]) + expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow( + 'Unsupported packaged runtime architecture: 4' + ) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => { + for (const [arch, electronArch] of [ + ['x64', 1], + ['arm64', 3] + ]) { + const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`)) + try { + const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') + const releaseDir = join(nodePtyDir, 'build', 'Release') + const conptyRoot = join(nodePtyDir, 'third_party', 'conpty', '0.1.0') + await mkdir(releaseDir, { recursive: true }) + await writeFile(join(releaseDir, 'conpty.node'), 'native addon placeholder', 'utf8') + for (const sourceArch of ['x64', 'arm64']) { + const sourceDir = join(conptyRoot, `win10-${sourceArch}`) + await mkdir(sourceDir, { recursive: true }) + await writeFile(join(sourceDir, 'conpty.dll'), `dll payload ${sourceArch}`, 'utf8') + await writeFile( + join(sourceDir, 'OpenConsole.exe'), + `console payload ${sourceArch}`, + 'utf8' + ) + } + + prunePackagedNodePty(resourcesDir, 'win32', electronArch) + + await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe( + `dll payload ${arch}` + ) + await expect(readFile(join(releaseDir, 'conpty', 'OpenConsole.exe'), 'utf8')).resolves.toBe( + `console payload ${arch}` + ) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + } + }) + + it('includes external main dependencies in the packaged runtime closure', () => { + // Why: the main process imports '@parcel/watcher' for filesystem change + // events; if it is absent from the packaged closure the serve host silently + // stops propagating file changes to clients (regression guard for #4851). + const packaged = createPackagedRuntimeNodeModuleResources() + const packagedTargets = packaged.map((resource) => resource.to) + expect(packagedTargets).toContain(join('node_modules', '@parcel', 'watcher')) + expect( + packagedTargets.some((target) => + target.startsWith(join('node_modules', '@parcel', 'watcher-')) + ) + ).toBe(true) + expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile')) + }) + + it('prunes non-target @parcel/watcher architecture subpackages', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-')) + try { + const parcelDir = join(resourcesDir, 'node_modules', '@parcel') + await mkdir(join(parcelDir, 'watcher'), { recursive: true }) + await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true }) + await mkdir(join(parcelDir, 'watcher-darwin-x64'), { recursive: true }) + await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true }) + await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true }) + await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true }) + + prunePackagedParcelWatcher(resourcesDir, 'linux', 'arm64') + + await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([ + 'watcher', + 'watcher-linux-arm64-glibc' + ]) + expect(() => prunePackagedParcelWatcher(resourcesDir, 'linux', 'universal')).toThrow( + 'Unsupported packaged runtime architecture: universal' + ) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('leaves unrelated @parcel/* runtime deps untouched when pruning the watcher', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-unrelated-')) + try { + const parcelDir = join(resourcesDir, 'node_modules', '@parcel') + await mkdir(join(parcelDir, 'watcher'), { recursive: true }) + await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true }) + await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true }) + // A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage. + await mkdir(join(parcelDir, 'transformer-js'), { recursive: true }) + + prunePackagedParcelWatcher(resourcesDir, 'linux', 1) + + await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([ + 'transformer-js', + 'watcher', + 'watcher-linux-x64-glibc' + ]) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('prunes type declaration artifacts from packaged runtime node_modules', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-type-prune-')) + try { + const packageDir = join(resourcesDir, 'node_modules', 'example-package') + await mkdir(join(packageDir, 'dist'), { recursive: true }) + await writeFile(join(packageDir, 'dist', 'index.cjs'), 'module.exports = {}', 'utf8') + await writeFile(join(packageDir, 'dist', 'index.d.ts'), 'export type Value = string', 'utf8') + await writeFile(join(packageDir, 'dist', 'index.d.cts'), 'export type Value = string', 'utf8') + await writeFile(join(packageDir, 'dist', 'index.d.mts.map'), '{}', 'utf8') + + prunePackagedRuntimeTypeAndSourceMapArtifacts(resourcesDir) + + await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs']) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('prunes duplicate darwin sherpa-onnx runtime dylib aliases', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-sherpa-prune-')) + try { + const packageDir = join(resourcesDir, 'node_modules', 'sherpa-onnx-darwin-arm64') + await mkdir(packageDir, { recursive: true }) + await writeFile(join(packageDir, 'sherpa-onnx.node'), '', 'utf8') + await writeFile(join(packageDir, 'libonnxruntime.1.23.2.dylib'), '', 'utf8') + await writeFile(join(packageDir, 'libonnxruntime.dylib'), '', 'utf8') + + prunePackagedSherpaOnnx(resourcesDir, 'darwin') + + await expect(readdir(packageDir).then((entries) => entries.sort())).resolves.toEqual([ + 'libonnxruntime.1.23.2.dylib', + 'sherpa-onnx.node' + ]) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('prunes zod TypeScript sources from packaged runtime resources', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-zod-prune-')) + try { + const packageDir = join(resourcesDir, 'node_modules', 'zod') + await mkdir(join(packageDir, 'src'), { recursive: true }) + await writeFile(join(packageDir, 'index.cjs'), 'module.exports = {}', 'utf8') + await writeFile(join(packageDir, 'src', 'index.ts'), 'export const value = true', 'utf8') + + prunePackagedZodSources(resourcesDir) + + await expect(readdir(packageDir)).resolves.toEqual(['index.cjs']) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('fails when the packaged resources directory is missing', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-')) + try { + await expect( + electronBuilderConfig.afterPack({ + appOutDir: root, + electronPlatformName: 'win32' + }) + ).rejects.toThrow(/Missing packaged resources directory/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it.skipIf(process.platform === 'win32')( + 'marks packaged Unix CLI launchers executable', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-')) + try { + const resourcesDir = join(root, 'linux-unpacked', 'resources') + const launcherPath = join(resourcesDir, 'bin', 'orca-ide') + await mkdir(join(resourcesDir, 'bin'), { recursive: true }) + await cp( + join(process.cwd(), 'resources', 'plugins', 'launch'), + join(resourcesDir, 'plugins', 'launch'), + { recursive: true } + ) + await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true }) + // Why: afterPack now fails hard when the unpacked daemon entry is + // missing, so the fixture must carry one like a real package layout. + const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') + await mkdir(unpackedMainDir, { recursive: true }) + await writeFile( + join(unpackedMainDir, 'daemon-entry.js'), + 'console.error("Usage: daemon-entry "); process.exit(1)\n', + 'utf8' + ) + await writeFile( + join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'), + `${JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs', private: true })}\n`, + 'utf8' + ) + const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') + await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true }) + await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8') + await writeFile( + join(unpackedCliDir, 'index.js'), + [ + 'const args = process.argv.slice(2)', + "if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))", + "else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)", + 'else console.log(JSON.stringify({ executed: false }))' + ].join('\n'), + 'utf8' + ) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) + + await electronBuilderConfig.afterPack({ + appOutDir: join(root, 'linux-unpacked'), + electronPlatformName: 'linux', + arch: 1, + packager: { appInfo: { version: '9.9.9' } } + }) + + expect((await stat(launcherPath)).mode & 0o111).not.toBe(0) + await expect( + readFile(join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'), 'utf8') + ).resolves.toContain('"version": "9.9.9"') + await expect(readFile(join(resourcesDir, 'package-type'), 'utf8')).resolves.toBe('AppImage') + } finally { + await rm(root, { recursive: true, force: true }) + } + } + ) +}) diff --git a/config/scripts/headless-serve-shutdown-workflow.test.mjs b/config/scripts/headless-serve-shutdown-workflow.test.mjs index 6590596e41c..90a3f73c77d 100644 --- a/config/scripts/headless-serve-shutdown-workflow.test.mjs +++ b/config/scripts/headless-serve-shutdown-workflow.test.mjs @@ -5,6 +5,17 @@ import { describe, expect, it } from 'vitest' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const headlessLinuxGuide = readFileSync('docs/reference/headless-linux-server.md', 'utf8') +const signalCase = readFileSync('config/docker/headless-serve-shutdown/run-signal-case.sh', 'utf8') +const shutdownDockerRunner = readFileSync( + 'config/scripts/run-headless-serve-shutdown-docker.mjs', + 'utf8' +) +const shutdownDockerfile = readFileSync('config/docker/headless-serve-shutdown/Dockerfile', 'utf8') +const desktopStartupOracle = readFileSync( + 'config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh', + 'utf8' +) +const headlessLinuxProse = headlessLinuxGuide.replace(/\s+/g, ' ') function readSystemdUnitBlocks(doc, unitName) { const escapedUnitName = unitName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') @@ -40,16 +51,112 @@ describe('headless serve shutdown PR gate', () => { ).toThrow('Missing closing code fence for orca-serve.service') }) - it('packages an x64 AppImage before running the Docker signal oracle', () => { + it('packages Linux artifacts before running the Docker signal oracle', () => { const steps = workflow.jobs.package.steps const packageStep = steps.find((step) => step.name === 'Package unpacked app') + const markerStep = steps.find((step) => step.name === 'Verify root-package marker payloads') const shutdownStep = steps.find((step) => step.name === 'Verify headless serve signal shutdown') + const launcherShutdownStep = steps.find( + (step) => step.name === 'Verify extracted launcher serve signal shutdown' + ) + const appImageShutdownStep = steps.find( + (step) => step.name === 'Verify AppImage CLI registration and serve signal shutdown' + ) - expect(packageStep.run).toContain('--linux AppImage --x64 --publish never') + expect(workflow.jobs.package['timeout-minutes']).toBe(90) + expect(packageStep.run).toContain('--linux AppImage deb rpm --x64 --publish never') + expect(markerStep.run).toContain('dpkg-deb --fsys-tarfile') + expect(markerStep.run).toContain('rpm2cpio') + expect(steps.indexOf(markerStep)).toBeGreaterThan(steps.indexOf(packageStep)) expect(shutdownStep.run).toBe( 'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage' ) + expect(launcherShutdownStep.run).toContain( + 'node config/scripts/run-headless-serve-shutdown-docker.mjs' + ) + expect(launcherShutdownStep.run).toContain('--entrypoint launcher') + expect(appImageShutdownStep.run).toContain('--entrypoint appimage') + expect(appImageShutdownStep.run).toContain('--signal-target serving-electron') + expect(appImageShutdownStep.run).toContain('--int-delivery pid') expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(packageStep)) + expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(markerStep)) + expect(steps.indexOf(launcherShutdownStep)).toBeGreaterThan(steps.indexOf(shutdownStep)) + expect(steps.indexOf(appImageShutdownStep)).toBeGreaterThan(steps.indexOf(launcherShutdownStep)) + }) + + it('keeps readiness polling finite and leak-free', () => { + expect(signalCase).toContain('read_ready_line()') + expect(signalCase).toContain("sed -u -n 's/^[^{]*//p'") + expect(signalCase).toContain('startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-180}') + expect(signalCase).toContain('startup_deadline=$((SECONDS + startup_timeout_seconds))') + expect(signalCase).toContain('while (( SECONDS < startup_deadline )); do') + expect(signalCase).toContain('kill -0 "$app_pid" 2>/dev/null || break') + expect(signalCase).toContain( + "jq's `inputs` waits for EOF even when wrapped in `first`, so a tail -F" + ) + expect(signalCase).not.toContain('tail --pid=') + }) + + it('gives owned shutdown state a bounded cleanup grace', () => { + expect(signalCase).toContain('for shutdown_poll in {0..50}; do') + expect(signalCase).toContain('[[ -z "$listener_after" && -z "$owned_residue" ]]') + expect(signalCase).toContain('((${#survivors[@]} == 0))') + expect(signalCase).toContain('((shutdown_poll < 50)) && sleep 0.1') + }) + + it('checks that a serving-electron signal target owns the ready socket', () => { + const ssRecord = + 'LISTEN 0 128 127.0.0.1:41235 0.0.0.0:* users:(("orca-ide",pid=23,fd=7),("orca-ide",pid=25,fd=8))' + expect([...ssRecord.matchAll(/pid=([0-9]+)/g)].map((match) => match[1])).toEqual(['23', '25']) + expect(signalCase).toContain( + 'listener_before_pids=$(grep -oE \'pid=[0-9]+\' <<<"$listener_before" | cut -d= -f2 || true)' + ) + expect(signalCase).toContain('signal_target_pid=$(head -n1 <<<"$listener_before_pids")') + expect(signalCase).toContain('outside the entrypoint process tree') + }) + + it('runs the original AppImage desktop startup oracle before extraction and signals', () => { + expect(shutdownDockerfile).toContain( + 'COPY run-appimage-desktop-startup-case.sh /usr/local/bin/run-appimage-desktop-startup-case' + ) + const startupCall = shutdownDockerRunner.indexOf( + 'runDesktopStartupOracle({ image, appImage, platform })' + ) + const extractionCall = shutdownDockerRunner.indexOf( + "'timeout --kill-after=10s 120s /input/orca.AppImage --appimage-extract" + ) + const signalLoop = shutdownDockerRunner.indexOf("for (const signal of ['INT', 'TERM'])") + expect(startupCall).toBeGreaterThan(-1) + expect(extractionCall).toBeGreaterThan(startupCall) + expect(signalLoop).toBeGreaterThan(startupCall) + expect(shutdownDockerRunner).toContain("'/usr/local/bin/run-appimage-desktop-startup-case'") + }) + + it('preserves startup logs when the launcher exits before its marker', () => { + expect(desktopStartupOracle).toContain('signal_process_group TERM || true') + expect(desktopStartupOracle).toContain('signal_process_group KILL || true') + expect(desktopStartupOracle).toContain('cat "$stdout_log" >&2 2>/dev/null || true') + expect(desktopStartupOracle).toContain('cat "$stderr_log" >&2 2>/dev/null || true') + expect(desktopStartupOracle).toContain( + 'FAIL: desktop launcher exited before ${reason} (status=${observed_status})' + ) + expect(desktopStartupOracle).toContain('ORCA_STARTUP_STATE_DIR_CLEANUP=1') + expect(desktopStartupOracle).toContain( + '[[ "$state_dir" =~ ^/tmp/orca-appimage-startup\\.[^/]+$ ]] || return 0' + ) + }) + + it('requires the bound AppImage to be executable before launch and extraction', () => { + expect(desktopStartupOracle).toContain( + '[[ -x "$appimage" ]] || { echo "FAIL: AppImage is not executable: $appimage" >&2; exit 1; }' + ) + expect(shutdownDockerRunner).toContain( + '\'test -r /input/orca.AppImage && test -x /input/orca.AppImage || { echo "FAIL: AppImage bind must be readable and executable" >&2; exit 1; }\'' + ) + }) + + it('gives the original AppImage enough bounded extraction space', () => { + expect(shutdownDockerRunner).toContain("'/tmp:rw,nosuid,nodev,exec,size=1g'") }) it('keeps owned Xvfb alive during the documented systemd graceful stop', () => { @@ -63,4 +170,37 @@ describe('headless serve shutdown PR gate', () => { expect(managedXvfbUnits).toHaveLength(1) expect(managedXvfbUnits[0]).not.toMatch(/^KillMode=/m) }) + + it('distinguishes persisted state from live work during a service restart', () => { + expect(headlessLinuxProse).toContain( + 'Every `systemctl stop` or `restart` therefore ends live terminals and agent processes' + ) + expect(headlessLinuxProse).toContain( + 'These guarantees do not preserve live processes. The service restart kills every terminal and agent in its cgroup' + ) + expect(headlessLinuxProse).toContain( + 'A separately paired runtime is outside that boundary; local execution and SSH hosts reached through this runtime are not. An affected or unknown omission, missing scope, failed request or lost connection is `unverifiable`' + ) + expect(headlessLinuxGuide).toContain( + 'sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json' + ) + expect(headlessLinuxGuide).not.toContain('sudo -Hu orca orca-ide terminal list --json') + expect(headlessLinuxGuide).not.toContain('Two facts make this safe and predictable') + }) + + it('uses the registered CLI name from ordinary Linux shells', () => { + const commandRule = + 'The registered Linux CLI command is `orca-ide`, not `orca`, to avoid shadowing the GNOME Orca screen reader.' + const substitutionRule = + "From an ordinary shell outside that service user's managed environment, substitute `orca-ide` for `orca` in commands below." + const censusCommand = '`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`' + + expect(headlessLinuxProse).toContain(commandRule) + expect(headlessLinuxProse).toContain(substitutionRule) + expect(headlessLinuxProse).toContain(censusCommand) + expect(headlessLinuxGuide).toContain('best-effort dispatcher at `$HOME/.local/bin/orca`') + expect(headlessLinuxProse.indexOf(substitutionRule)).toBeLessThan( + headlessLinuxProse.indexOf(censusCommand) + ) + }) }) diff --git a/config/scripts/linux-package-maintainer-scripts.test.mjs b/config/scripts/linux-package-maintainer-scripts.test.mjs new file mode 100644 index 00000000000..f315f2fd2ee --- /dev/null +++ b/config/scripts/linux-package-maintainer-scripts.test.mjs @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +describe('Linux package maintainer scripts', () => { + it('keeps upgrades from removing the installed CLI', () => { + const script = readFileSync( + new URL('../../resources/linux/packaging/after-remove.sh', import.meta.url), + 'utf8' + ) + const unlinkStart = script.indexOf('link="/usr/bin/orca-ide"') + const upgradeGuard = script.slice(0, unlinkStart) + + expect(unlinkStart).toBeGreaterThan(-1) + expect(upgradeGuard).toContain('case "${1-}" in') + expect(upgradeGuard).toContain('0 | remove | purge) ;;') + expect(upgradeGuard).toContain('*) exit 0 ;;') + }) +}) diff --git a/config/scripts/orcad-operations-restart-safety.test.mjs b/config/scripts/orcad-operations-restart-safety.test.mjs new file mode 100644 index 00000000000..60f9cb05524 --- /dev/null +++ b/config/scripts/orcad-operations-restart-safety.test.mjs @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const operationsGuide = readFileSync('docs/reference/orcad-operations.md', 'utf8') +const operationsProse = operationsGuide.replace(/\s+/g, ' ') + +describe('orcad operations restart safety', () => { + it('distinguishes PID-scoped preservation from systemd cgroup teardown', () => { + expect(operationsProse).toContain( + 'This makes a PID-scoped update, rollback or restart non-destructive to live work' + ) + expect(operationsProse).toContain( + 'The successor adopts the current endpoint and routes supported previous protocol versions through legacy adapters' + ) + expect(operationsProse).toContain('`KillMode=mixed` does **not** preserve them') + expect(operationsProse).toContain( + '`KillMode=process` leaves service-owned processes unmanaged and is not a supported preservation mechanism' + ) + }) + + it('fails closed before cgroup-wide maintenance', () => { + expect(operationsProse).toContain( + 'A safe empty census is untruncated, has an explicit `hostScope`, covers every execution host affected by the stop, and lists no terminals on those hosts' + ) + expect(operationsProse).toContain( + "Every `omittedHostIds` entry must be explicitly accounted for outside the target service's execution boundary" + ) + expect(operationsProse).toContain( + '`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`' + ) + expect(operationsGuide).not.toContain('sudo -Hu orca orca-ide terminal list --json') + expect(operationsProse).toContain( + 'A separately paired runtime is outside that boundary; local execution and SSH hosts reached through this runtime are not. An affected or unknown omission, missing scope, truncation, a failed request or lost contact makes the result `unverifiable`' + ) + expect(operationsProse).toContain('Orca does not yet provide an atomic census-and-stop fence') + }) + + it('does not refer to the unavailable shipping design', () => { + expect(operationsGuide).not.toContain('docs/design/shipping-orcad.html') + }) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index c296ad9e893..67d4f565afa 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -167,6 +167,7 @@ const SHARED_PACKAGE_PREFIXES = [ 'config/scripts/smoke-packaged', 'config/scripts/install-electron-package-binary', 'config/scripts/verify-packaged', + 'config/scripts/verify-skills-cli-runtime', 'config/scripts/verify-linux-glibc', 'config/scripts/run-electron-vite', 'skills/', @@ -180,6 +181,12 @@ const SHARED_PACKAGE_PREFIXES = [ const LINUX_PACKAGE_PREFIXES = [ ...SHARED_PACKAGE_PREFIXES, + 'config/docker/cli-launch-contract/', + 'config/docker/headless-pairing/', + 'config/docker/headless-serve-shutdown/', + 'config/scripts/run-linux-cli-launch-contract', + 'config/scripts/run-headless-linux-pairing-docker', + 'config/scripts/static-appimage-package-contract', 'native/computer-use-linux/', 'resources/linux/', 'config/scripts/run-headless-serve' diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index f9411eed956..9a1c9e649b6 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -181,6 +181,28 @@ describe('per-job path classification', () => { expectClassification(['native/computer-use-macos/Package.swift'], {}) }) + it('runs Linux packaging when an artifact contract changes', () => { + for (const file of [ + 'config/docker/cli-launch-contract/Dockerfile', + 'config/docker/cli-launch-contract/run-cli-case.sh', + 'config/docker/headless-pairing/Dockerfile', + 'config/docker/headless-pairing/run-appimage-case.sh', + 'config/docker/headless-serve-shutdown/Dockerfile', + 'config/scripts/run-linux-cli-launch-contract-docker.mjs', + 'config/scripts/run-headless-linux-pairing-docker.mjs', + 'config/scripts/static-appimage-package-contract.cjs' + ]) { + expectClassification([file], { package: true }) + } + }) + + it('runs both package jobs when the shared skills runtime verifier changes', () => { + expectClassification(['config/scripts/verify-skills-cli-runtime.cjs'], { + package: true, + package_windows: true + }) + }) + it('runs shell contracts when live-shell inputs change', () => { expectClassification(['src/main/daemon/shell-ready.ts'], { shell_contracts: true, diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index c69b04d663f..92d4fe4c26b 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -102,8 +102,13 @@ describe('PR workflow parallelism', () => { .split(/\s+/) .filter((token) => !['apt-get', 'install', 'sudo', ''].includes(token)) .filter((token) => !token.startsWith('-')) - const jobsInstallingPackages = Object.entries(workflow.jobs) - .filter(([, job]) => (job.steps ?? []).some((step) => aptPackages(step).length > 0)) + const requiredShells = ['zsh', 'fish'] + const jobsInstallingShells = Object.entries(workflow.jobs) + .filter(([, job]) => + (job.steps ?? []).some((step) => + aptPackages(step).some((packageName) => requiredShells.includes(packageName)) + ) + ) .map(([name]) => name) expect(shellStep).toBeDefined() @@ -111,11 +116,11 @@ describe('PR workflow parallelism', () => { expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1') // Why the whole workflow, not just the general shards: any other lane installing // these shells would silently start running the real-shell tests twice. - expect(jobsInstallingPackages).toEqual(['shell_contracts']) + expect(jobsInstallingShells).toEqual(['shell_contracts']) // Why each shell is asserted: the live tests skip themselves when the binary is // missing, so a dropped package silently empties this lane instead of failing it. const shellPackages = workflow.jobs.shell_contracts.steps.flatMap(aptPackages) - for (const shell of ['zsh', 'fish']) { + for (const shell of requiredShells) { expect(shellPackages).toContain(shell) } expect(shellInstall.with['native-runtime']).toBe('node') diff --git a/config/scripts/run-headless-serve-shutdown-docker.mjs b/config/scripts/run-headless-serve-shutdown-docker.mjs index f3852624854..184713c41a0 100755 --- a/config/scripts/run-headless-serve-shutdown-docker.mjs +++ b/config/scripts/run-headless-serve-shutdown-docker.mjs @@ -17,7 +17,7 @@ if (!appImageArg) { if (!['app', 'serving-electron'].includes(signalTarget)) { fail(`Unsupported --signal-target: ${signalTarget}`) } -if (!['app', 'launcher'].includes(entrypoint)) { +if (!['app', 'appimage', 'launcher'].includes(entrypoint)) { fail(`Unsupported --entrypoint: ${entrypoint}`) } if (!['pid', 'foreground-process-group'].includes(intDelivery)) { @@ -54,11 +54,19 @@ try { shutdownDockerDirectory ]) docker(['volume', 'create', artifactVolume]) + runDesktopStartupOracle({ image, appImage, platform }) docker([ 'run', '--rm', '--platform', platform, + '--network', + 'none', + '--read-only', + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', '--entrypoint', 'bash', '-v', @@ -68,11 +76,17 @@ try { image, '-lc', [ - '7z x /input/orca.AppImage -o/artifacts/root -y >/dev/null', + 'trap \'status=$?; if [ "$status" -ne 0 ]; then cat /artifacts/appimage-help.log /artifacts/appimage-extract.log 2>/dev/null || true; fi; exit "$status"\' EXIT', + 'test -r /input/orca.AppImage && test -x /input/orca.AppImage || { echo "FAIL: AppImage bind must be readable and executable" >&2; exit 1; }', + 'timeout --kill-after=5s 15s /input/orca.AppImage --appimage-help > /artifacts/appimage-help.log 2>&1', + 'cd /artifacts', + 'timeout --kill-after=10s 120s /input/orca.AppImage --appimage-extract > /artifacts/appimage-extract.log 2>&1', + 'mv squashfs-root root', launcherExecOverlay ? "sed -i 's/^ELECTRON_RUN_AS_NODE=1 /export ELECTRON_RUN_AS_NODE=1\\nexec /' /artifacts/root/resources/bin/orca-ide" : ':', - 'chmod -R a+rX /artifacts/root' + 'chmod -R a+rX /artifacts/root', + 'rm /artifacts/appimage-help.log /artifacts/appimage-extract.log' ].join(' && ') ]) @@ -108,6 +122,8 @@ try { '-e', `ORCA_INT_DELIVERY=${intDelivery}`, '-v', + `${appImage}:/input/orca.AppImage:ro`, + '-v', `${artifactVolume}:/artifacts:ro`, image, signal @@ -129,6 +145,38 @@ try { docker(['image', 'rm', image], { allowFailure: true }) } +function runDesktopStartupOracle({ image, appImage, platform }) { + console.log('Running original AppImage desktop startup oracle...') + docker([ + 'run', + '--rm', + '--init', + '--platform', + platform, + '--network', + 'none', + '--read-only', + '--tmpfs', + '/tmp:rw,nosuid,nodev,exec,size=1g', + '--shm-size', + '256m', + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', + '--user', + 'orca', + '--entrypoint', + '/usr/local/bin/run-appimage-desktop-startup-case', + '-e', + 'ORCA_STARTUP_DIAGNOSTICS=1', + '-v', + `${appImage}:/input/orca.AppImage:ro`, + image, + '/input/orca.AppImage' + ]) +} + function valueAfter(flag) { const index = args.indexOf(flag) return index === -1 ? null : (args[index + 1] ?? null) diff --git a/config/scripts/run-linux-cli-launch-contract-docker.mjs b/config/scripts/run-linux-cli-launch-contract-docker.mjs new file mode 100755 index 00000000000..901e0877e85 --- /dev/null +++ b/config/scripts/run-linux-cli-launch-contract-docker.mjs @@ -0,0 +1,264 @@ +#!/usr/bin/env node +// Exercise packaged CLI paths under the hostile Linux conditions from #11609/#12530/#13719/#14229. +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' + +const commandArgs = process.argv.slice(2) +const appImageArg = valueAfter('--appimage') +const appImage = appImageArg ? resolve(appImageArg) : null +const platform = valueAfter('--platform') +const dockerPlatformArgs = platform ? ['--platform', platform] : [] + +const suffix = `${process.pid}-${Date.now()}` +const artifactVolume = `orca-cli-contract-artifact-${suffix}` +const tagArchitecture = platform?.split('/')[1] ?? process.arch +const tag = `orca-cli-launch-contract:ubuntu-24.04-${tagArchitecture}-${suffix}` +const base = 'ubuntu@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90' +const containers = new Set() +let artifactVolumeCreated = false +const CASE_TIMEOUT_MS = 90_000 +const BUILD_TIMEOUT_MS = 10 * 60_000 +const STAGING_TIMEOUT_MS = 5 * 60_000 +const DOCKER_TIMEOUT_MS = 2 * 60_000 +const CLEANUP_TIMEOUT_MS = 30_000 + +// Exact statuses reject silent no-op launches as well as crashes. +const CASES = [ + { + name: 'nofuse-userns-bundled-help', + expectStatus: 0, + expectOutput: 'Usage: orca ', + why: 'The bundled launcher must run with no FUSE, no display, and userns restricted (#11609, #12530).' + }, + { + name: 'nofuse-userns-bundled-version', + expectStatus: 0, + expectOutput: /^\d+\.\d+\.\d+/m, + why: 'A deployment must be able to read the installed version without a display (#13719).' + }, + { + name: 'nofuse-userns-bundled-status', + // No runtime is running; the CLI must report that itself. + expectStatus: 1, + expectOutput: 'appRunning', + why: 'A command that needs the runtime must report its absence, not abort.' + }, + { + name: 'nofuse-userns-bundled-skills', + expectStatus: 0, + // Why: the rendered help header, not a bare 'skills' — the case name contains that word. + expectOutput: 'Usage: orca skills', + why: 'skills is a pure-text command that must never need Chromium (#14229).' + }, + { + name: 'nofuse-userns-bundled-worktree', + expectStatus: 1, + expectOutput: "Orca is not running. Run 'orca open' first.", + why: 'A runtime-dependent command must report the missing runtime, not abort.' + }, + { + name: 'nofuse-nosandbox-direct-binary-skills', + expectStatus: 0, + expectOutput: 'Usage: orca skills', + why: 'A direct binary launch that reaches JavaScript must run the command, not boot a GUI (#14229).' + }, + { + name: 'nofuse-nosandbox-direct-binary-gui', + // A missing display is an expected diagnosis, not a crash. + expectStatus: 1, + expectOutput: 'needs a usable display server', + why: 'A desktop launch with no display must diagnose it instead of dying in uv_close (#13719).' + }, + { + name: 'stale-display-nosandbox-direct-binary-gui', + expectStatus: 1, + expectOutput: 'needs a usable display server', + why: 'A stale DISPLAY value must diagnose the unreachable endpoint instead of dying in uv_close (#13719).' + } +] + +try { + if (!appImage) { + fail( + 'Usage: run-linux-cli-launch-contract-docker.mjs --appimage /path/to/orca-linux.AppImage [--platform linux/amd64|linux/arm64]' + ) + } + if (commandArgs.includes('--platform') && !platform) { + fail('Missing value for --platform') + } + if (platform !== null && platform !== 'linux/amd64' && platform !== 'linux/arm64') { + fail(`Unsupported --platform: ${platform}`) + } + if (!existsSync(appImage)) { + fail(`AppImage not found: ${appImage}`) + } + docker(['volume', 'create', artifactVolume], { timeoutMs: DOCKER_TIMEOUT_MS }) + artifactVolumeCreated = true + buildImage() + stageArtifacts() + runContract() + console.log('\nLinux CLI launch contract passed.') +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +} finally { + for (const container of containers) { + docker(['rm', '-f', container], { allowFailure: true, timeoutMs: CLEANUP_TIMEOUT_MS }) + } + if (artifactVolumeCreated) { + docker(['volume', 'rm', artifactVolume], { + allowFailure: true, + timeoutMs: CLEANUP_TIMEOUT_MS + }) + } + docker(['image', 'rm', tag], { allowFailure: true, timeoutMs: CLEANUP_TIMEOUT_MS }) +} + +function runContract() { + const failures = [] + for (const testCase of CASES) { + const output = runCase(testCase.name) + const statusMatch = /^RESULT status=(\d+)/m.exec(output) + if (!statusMatch) { + failures.push(`${testCase.name}: ${firstLine(output)}\n ${testCase.why}`) + console.log(` FAIL ${testCase.name} — ${firstLine(output)}`) + continue + } + const status = Number(statusMatch[1]) + // Why: the harness echoes `RESULT status=N case=`, so a case whose name contains the + // expected substring would assert against the harness's own line instead of the CLI's output. + const commandOutput = output + .split('\n') + .filter((line) => !/^(?:RESULT|CRASHED|PRECONDITION_FAILED) /.test(line)) + .join('\n') + const matchesOutput = + typeof testCase.expectOutput === 'string' + ? commandOutput.includes(testCase.expectOutput) + : testCase.expectOutput.test(commandOutput) + if (status !== testCase.expectStatus || !matchesOutput) { + failures.push( + `${testCase.name}: expected status ${testCase.expectStatus} and ${testCase.expectOutput}, ` + + `got status ${status}\n ${testCase.why}` + ) + console.log(` FAIL ${testCase.name} — status ${status}`) + continue + } + console.log(` ok ${testCase.name} (status ${status})`) + } + if (failures.length > 0) { + fail(`Linux CLI launch contract failed:\n - ${failures.join('\n - ')}`) + } +} + +function runCase(caseName) { + const container = `orca-cli-contract-${caseName}-${suffix}` + containers.add(container) + // FUSE and extra capabilities would invalidate the test conditions. + return docker( + [ + 'run', + ...dockerPlatformArgs, + '--name', + container, + '--rm', + '-v', + `${artifactVolume}:/artifacts`, + tag, + caseName + ], + { allowFailure: true, capture: true, timeoutMs: CASE_TIMEOUT_MS } + ) +} + +function buildImage() { + console.log(`Building ${tag}…`) + docker( + [ + 'build', + ...dockerPlatformArgs, + '--build-arg', + `BASE_IMAGE=${base}`, + '-f', + 'config/docker/cli-launch-contract/Dockerfile', + '-t', + tag, + 'config/docker/cli-launch-contract' + ], + { timeoutMs: BUILD_TIMEOUT_MS } + ) +} + +// Extract unprivileged so chrome-sandbox is not root-owned setuid. +function stageArtifacts() { + console.log('Staging the AppImage payload…') + const container = `orca-cli-contract-stage-${suffix}` + containers.add(container) + docker( + [ + 'run', + ...dockerPlatformArgs, + '--name', + container, + '--rm', + '-v', + `${artifactVolume}:/artifacts`, + '-v', + `${appImage}:/input/orca-linux.AppImage:ro`, + '--entrypoint', + 'bash', + tag, + '-lc', + [ + 'set -euo pipefail', + 'cp /input/orca-linux.AppImage /artifacts/orca-linux.AppImage', + 'chmod +x /artifacts/orca-linux.AppImage', + 'chown -R orca:orca /artifacts', + // Use the AppImage runtime's no-FUSE extraction path. + 'cd /artifacts && runuser --user orca -- ./orca-linux.AppImage --appimage-extract >/dev/null', + 'test -x /artifacts/squashfs-root/resources/bin/orca-ide' + ].join(' && ') + ], + { timeoutMs: STAGING_TIMEOUT_MS } + ) +} + +function docker(args, options = {}) { + try { + const output = execFileSync('docker', args, { + encoding: 'utf8', + stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + timeout: options.timeoutMs ?? DOCKER_TIMEOUT_MS, + killSignal: 'SIGTERM' + }) + return output ?? '' + } catch (error) { + const timedOut = error instanceof Error && 'code' in error && error.code === 'ETIMEDOUT' + if (timedOut) { + const message = `docker ${args.join(' ')} timed out after ${options.timeoutMs ?? DOCKER_TIMEOUT_MS}ms` + if (!options.allowFailure) { + fail(message) + } + return message + } + if (!options.allowFailure) { + fail( + `docker ${args.join(' ')} failed: ${error instanceof Error ? error.message : String(error)}` + ) + } + return `${error?.stdout ?? ''}${error?.stderr ?? ''}` + } +} + +function firstLine(value) { + return (value ?? '').trim().split('\n')[0] || '(no output)' +} + +function valueAfter(flag) { + const index = commandArgs.indexOf(flag) + return index === -1 ? null : (commandArgs[index + 1] ?? null) +} + +function fail(message) { + throw new Error(message) +} diff --git a/config/scripts/static-appimage-package-contract.cjs b/config/scripts/static-appimage-package-contract.cjs new file mode 100644 index 00000000000..8a11cecf880 --- /dev/null +++ b/config/scripts/static-appimage-package-contract.cjs @@ -0,0 +1,260 @@ +const { closeSync, fstatSync, openSync, readSync } = require('node:fs') +const { basename } = require('node:path') + +const EXPECTED_ARCHITECTURE_BY_FILENAME = new Map([ + ['orca-linux.AppImage', 'x64'], + ['orca-linux-arm64.AppImage', 'arm64'] +]) +const APPIMAGE_MAGIC = Buffer.from([0x41, 0x49, 0x02]) +const RUNTIME_SOURCE = Buffer.from('https://github.com/AppImage/type2-runtime') +const TARGET_ARCHITECTURE_BY_ENUM = new Map([ + [1, 'x64'], + [3, 'arm64'] +]) +const RUNTIME_ARCHITECTURE_BY_MACHINE = new Map([ + [0x3e, 'x64'], + [0xb7, 'arm64'] +]) +const ELF_HEADER_BYTES = 64 +const PROGRAM_HEADER_BYTES = 56 +const DYNAMIC_ENTRY_BYTES = 16 +const MAX_PROGRAM_HEADERS = 128 +const MAX_LOAD_BYTES = 16 * 1024 * 1024 +const MAX_DYNAMIC_BYTES = 1024 * 1024 + +function verifyStaticAppImagePackage(filePath, targetArch) { + const filename = basename(filePath) + const filenameArchitecture = EXPECTED_ARCHITECTURE_BY_FILENAME.get(filename) + if (!filenameArchitecture) { + invalid( + filename, + `unsupported artifact name; expected ${[...EXPECTED_ARCHITECTURE_BY_FILENAME.keys()].join(' or ')}` + ) + } + const targetArchitecture = normalizeTargetArchitecture(targetArch, filename) + if (filenameArchitecture !== targetArchitecture) { + invalid( + filename, + `artifact filename targets ${filenameArchitecture}, but electron-builder target is ${targetArchitecture}` + ) + } + + const descriptor = openSync(filePath, 'r') + try { + const stats = fstatSync(descriptor, { bigint: true }) + if (process.platform !== 'win32' && (stats.mode & 0o111n) === 0n) { + invalid(filename, 'artifact is not executable') + } + const fileSize = stats.size + const header = readRange( + descriptor, + 0n, + BigInt(ELF_HEADER_BYTES), + fileSize, + filename, + 'ELF header' + ) + const { entry, machine } = verifyElfHeader(header, filename) + const runtimeArchitecture = RUNTIME_ARCHITECTURE_BY_MACHINE.get(machine) + if (runtimeArchitecture !== targetArchitecture) { + invalid( + filename, + `runtime architecture ${runtimeArchitecture ?? `machine 0x${machine.toString(16)}`} does not match electron-builder target ${targetArchitecture}` + ) + } + + const programHeaderOffset = header.readBigUInt64LE(32) + const programHeaderSize = header.readUInt16LE(54) + const programHeaderCount = header.readUInt16LE(56) + if (programHeaderSize !== PROGRAM_HEADER_BYTES) { + invalid(filename, `unexpected ELF program-header size ${programHeaderSize}`) + } + if (programHeaderCount === 0 || programHeaderCount > MAX_PROGRAM_HEADERS) { + invalid(filename, `invalid ELF program-header count ${programHeaderCount}`) + } + + const tableSize = BigInt(programHeaderSize * programHeaderCount) + const table = readRange( + descriptor, + programHeaderOffset, + tableSize, + fileSize, + filename, + 'ELF program-header table' + ) + const segments = parseProgramHeaders(table, programHeaderSize) + verifySegments(descriptor, segments, fileSize, filename, entry) + } finally { + closeSync(descriptor) + } +} + +function verifyElfHeader(header, filename) { + if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) { + invalid(filename, 'missing ELF magic') + } + if (header[4] !== 2 || header[5] !== 1 || header[6] !== 1) { + invalid(filename, 'runtime must be ELF64 little-endian version 1') + } + if (!header.subarray(8, 11).equals(APPIMAGE_MAGIC)) { + invalid(filename, 'missing type-2 AppImage marker') + } + if (header.readUInt16LE(16) !== 3) { + invalid(filename, 'runtime must be an ET_DYN static PIE') + } + const machine = header.readUInt16LE(18) + if (!RUNTIME_ARCHITECTURE_BY_MACHINE.has(machine)) { + invalid(filename, `unsupported ELF machine 0x${machine.toString(16)}`) + } + if (header.readUInt32LE(20) !== 1) { + invalid(filename, 'runtime has an unsupported ELF version') + } + if (header.readUInt16LE(52) !== ELF_HEADER_BYTES) { + invalid(filename, `unexpected ELF header size ${header.readUInt16LE(52)}`) + } + return { entry: header.readBigUInt64LE(24), machine } +} + +function parseProgramHeaders(table, entrySize) { + const segments = [] + for (let offset = 0; offset < table.length; offset += entrySize) { + segments.push({ + type: table.readUInt32LE(offset), + flags: table.readUInt32LE(offset + 4), + offset: table.readBigUInt64LE(offset + 8), + virtualAddress: table.readBigUInt64LE(offset + 16), + fileSize: table.readBigUInt64LE(offset + 32), + memorySize: table.readBigUInt64LE(offset + 40) + }) + } + return segments +} + +function verifySegments(descriptor, segments, fileSize, filename, entry) { + if (segments.some((segment) => segment.type === 3)) { + invalid(filename, 'runtime contains PT_INTERP') + } + + const loadSegments = segments.filter((segment) => segment.type === 1) + const totalLoadBytes = loadSegments.reduce((total, segment) => total + segment.fileSize, 0n) + if (loadSegments.length === 0 || totalLoadBytes > BigInt(MAX_LOAD_BYTES)) { + invalid(filename, `invalid or oversized PT_LOAD data (${totalLoadBytes} bytes)`) + } + if ( + !loadSegments.some( + (segment) => + segment.flags & 1 && + entry >= segment.virtualAddress && + entry - segment.virtualAddress < segment.memorySize + ) + ) { + invalid(filename, 'ELF entry point is outside an executable PT_LOAD segment') + } + let identifiesStaticRuntime = false + for (const segment of loadSegments) { + verifyFileBackedSegment(segment, fileSize, filename, 'PT_LOAD') + const data = readRange( + descriptor, + segment.offset, + segment.fileSize, + fileSize, + filename, + 'PT_LOAD data' + ) + identifiesStaticRuntime ||= data.includes(RUNTIME_SOURCE) + } + if (!identifiesStaticRuntime) { + invalid(filename, `runtime does not identify ${RUNTIME_SOURCE.toString()}`) + } + + for (const segment of segments.filter((entry) => entry.type === 2)) { + verifyDynamicSegment(descriptor, segment, fileSize, filename) + } +} + +function normalizeTargetArchitecture(targetArch, filename) { + const architecture = + typeof targetArch === 'number' ? TARGET_ARCHITECTURE_BY_ENUM.get(targetArch) : targetArch + if (architecture !== 'x64' && architecture !== 'arm64') { + invalid(filename, `unsupported electron-builder target architecture ${String(targetArch)}`) + } + return architecture +} + +function verifyFileBackedSegment(segment, fileSize, filename, label) { + if (segment.memorySize < segment.fileSize) { + invalid(filename, `${label} memory size is smaller than its file size`) + } + verifyRange(segment.offset, segment.fileSize, fileSize, filename, label) +} + +function verifyDynamicSegment(descriptor, segment, fileSize, filename) { + verifyFileBackedSegment(segment, fileSize, filename, 'PT_DYNAMIC') + if ( + segment.fileSize === 0n || + segment.fileSize > BigInt(MAX_DYNAMIC_BYTES) || + segment.fileSize % BigInt(DYNAMIC_ENTRY_BYTES) !== 0n + ) { + invalid(filename, `invalid PT_DYNAMIC size ${segment.fileSize}`) + } + const dynamic = readRange( + descriptor, + segment.offset, + segment.fileSize, + fileSize, + filename, + 'PT_DYNAMIC data' + ) + let terminated = false + for (let offset = 0; offset < dynamic.length; offset += DYNAMIC_ENTRY_BYTES) { + const tag = dynamic.readBigInt64LE(offset) + if (tag === 0n) { + terminated = true + break + } + if (tag === 1n) { + invalid(filename, 'runtime contains a DT_NEEDED dependency') + } + } + if (!terminated) { + invalid(filename, 'PT_DYNAMIC is missing DT_NULL') + } +} + +function readRange(descriptor, offset, size, fileSize, filename, label) { + verifyRange(offset, size, fileSize, filename, label) + const buffer = Buffer.alloc(Number(size)) + let bytesRead = 0 + while (bytesRead < buffer.length) { + const count = readSync( + descriptor, + buffer, + bytesRead, + buffer.length - bytesRead, + Number(offset) + bytesRead + ) + if (count === 0) { + throw new Error(`Unable to read complete ${label}`) + } + bytesRead += count + } + return buffer +} + +function verifyRange(offset, size, fileSize, filename, label) { + const maxSafeOffset = BigInt(Number.MAX_SAFE_INTEGER) + if ( + offset > fileSize || + size > fileSize - offset || + offset > maxSafeOffset || + size > maxSafeOffset - offset + ) { + invalid(filename, `${label} is outside the artifact`) + } +} + +function invalid(filename, reason) { + throw new Error(`Invalid static AppImage ${filename}: ${reason}`) +} + +module.exports = { verifyStaticAppImagePackage } diff --git a/config/scripts/static-appimage-package-contract.test.mjs b/config/scripts/static-appimage-package-contract.test.mjs new file mode 100644 index 00000000000..2addc675482 --- /dev/null +++ b/config/scripts/static-appimage-package-contract.test.mjs @@ -0,0 +1,225 @@ +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { verifyStaticAppImagePackage } = require('./static-appimage-package-contract.cjs') + +const RUNTIME_SOURCE = Buffer.from('https://github.com/AppImage/type2-runtime') +const LOAD_HEADER = 64 +const DYNAMIC_HEADER = 120 +const DYNAMIC_OFFSET = 320 +const FIXTURE_BYTES = 384 + +describe('static AppImage package contract', () => { + it.each([ + ['orca-linux.AppImage', 0x3e, 1], + ['orca-linux-arm64.AppImage', 0xb7, 'arm64'] + ])('accepts a dependency-free type-2 %s runtime', async (filename, machine, targetArch) => { + await withFixture(filename, createRuntime({ machine }), (path) => { + expect(() => verifyStaticAppImagePackage(path, targetArch)).not.toThrow() + }) + }) + + it.each([ + ['generic filename for an arm64 runtime and target', 'orca-linux.AppImage', 0xb7, 3], + ['arm64 filename for an x64 runtime and target', 'orca-linux-arm64.AppImage', 0x3e, 1], + ['generic x64 runtime for an arm64 target', 'orca-linux.AppImage', 0x3e, 3], + ['generic arm64 runtime for an x64 target', 'orca-linux.AppImage', 0xb7, 1], + ['arm64 artifact filename for an x64 target', 'orca-linux-arm64.AppImage', 0xb7, 1], + ['x64 runtime under an arm64 artifact filename', 'orca-linux-arm64.AppImage', 0x3e, 3] + ])('rejects %s', async (_label, filename, machine, targetArch) => { + await withFixture(filename, createRuntime({ machine }), (path) => { + expect(() => verifyStaticAppImagePackage(path, targetArch)).toThrow(/architecture|target/) + }) + }) + + it.each([undefined, 0, 'ia32'])( + 'rejects unsupported target architecture %s', + async (targetArch) => { + await withFixture('orca-linux.AppImage', createRuntime(), (path) => { + expect(() => verifyStaticAppImagePackage(path, targetArch)).toThrow(/target architecture/) + }) + } + ) + + it('accepts PT_DYNAMIC relocation metadata without dependencies', async () => { + const runtime = createRuntime() + runtime.writeBigInt64LE(7n, DYNAMIC_OFFSET) + await withFixture('orca-linux.AppImage', runtime, (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).not.toThrow() + }) + }) + + it('does not scan the appended AppImage payload as outer ELF data', async () => { + const payload = Buffer.concat([RUNTIME_SOURCE, Buffer.alloc(16, 1)]) + await withFixture('orca-linux.AppImage', Buffer.concat([createRuntime(), payload]), (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).not.toThrow() + }) + + const unidentifiedRuntime = createRuntime() + unidentifiedRuntime.fill(0, 192, 192 + RUNTIME_SOURCE.length) + await withFixture( + 'orca-linux.AppImage', + Buffer.concat([unidentifiedRuntime, payload]), + (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(/does not identify/) + } + ) + }) + + it('rejects artifact names outside the release contract before reading them', () => { + expect(() => verifyStaticAppImagePackage('/missing/orca-preview.AppImage')).toThrow( + 'unsupported artifact name' + ) + }) + + it.skipIf(process.platform === 'win32')( + 'rejects a readable but non-executable AppImage', + async () => { + await withFixture( + 'orca-linux.AppImage', + createRuntime(), + (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(/not executable/) + }, + { mode: 0o644 } + ) + } + ) + + it.each([ + [ + 'non-ELF64 runtimes', + (runtime) => { + runtime[4] = 1 + }, + /ELF64 little-endian/ + ], + [ + 'unsupported ELF versions', + (runtime) => runtime.writeUInt32LE(2, 20), + /unsupported ELF version/ + ], + [ + 'non-type-2 AppImages', + (runtime) => { + runtime[10] = 1 + }, + /type-2 AppImage marker/ + ], + ['non-PIE runtimes', (runtime) => runtime.writeUInt16LE(2, 16), /ET_DYN static PIE/], + [ + 'unsupported architectures', + (runtime) => runtime.writeUInt16LE(3, 18), + /unsupported ELF machine/ + ], + ['dynamic loaders', (runtime) => runtime.writeUInt32LE(3, DYNAMIC_HEADER), /PT_INTERP/], + [ + 'shared-library dependencies', + (runtime) => runtime.writeBigInt64LE(1n, DYNAMIC_OFFSET), + /DT_NEEDED/ + ], + [ + 'unidentified runtimes', + (runtime) => runtime.fill(0, 192, 192 + RUNTIME_SOURCE.length), + /does not identify/ + ], + [ + 'out-of-bounds load segments', + (runtime) => { + runtime.writeBigUInt64LE(1000n, LOAD_HEADER + 32) + runtime.writeBigUInt64LE(1000n, LOAD_HEADER + 40) + }, + /outside the artifact/ + ], + [ + 'oversized load claims', + (runtime) => { + runtime.writeBigUInt64LE(16n * 1024n * 1024n + 1n, LOAD_HEADER + 32) + runtime.writeBigUInt64LE(16n * 1024n * 1024n + 1n, LOAD_HEADER + 40) + }, + /oversized PT_LOAD/ + ], + [ + 'non-executable entry segments', + (runtime) => runtime.writeUInt32LE(4, LOAD_HEADER + 4), + /executable PT_LOAD/ + ], + [ + 'entry points outside load segments', + (runtime) => runtime.writeBigUInt64LE(4096n, 24), + /entry point/ + ] + ])('rejects %s', async (_label, mutate, expected) => { + const runtime = createRuntime() + mutate(runtime) + await withFixture('orca-linux.AppImage', runtime, (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(expected) + }) + }) +}) + +function createRuntime({ machine = 0x3e } = {}) { + const runtime = Buffer.alloc(FIXTURE_BYTES) + Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]).copy(runtime) + Buffer.from([0x41, 0x49, 0x02]).copy(runtime, 8) + runtime.writeUInt16LE(3, 16) + runtime.writeUInt16LE(machine, 18) + runtime.writeUInt32LE(1, 20) + runtime.writeBigUInt64LE(0n, 24) + runtime.writeBigUInt64LE(64n, 32) + runtime.writeUInt16LE(64, 52) + runtime.writeUInt16LE(56, 54) + runtime.writeUInt16LE(2, 56) + + writeProgramHeader(runtime, LOAD_HEADER, { + type: 1, + flags: 5, + offset: 0, + virtualAddress: 0, + size: FIXTURE_BYTES, + memorySize: FIXTURE_BYTES, + alignment: 4096 + }) + writeProgramHeader(runtime, DYNAMIC_HEADER, { + type: 2, + flags: 4, + offset: DYNAMIC_OFFSET, + virtualAddress: DYNAMIC_OFFSET, + size: 32, + memorySize: 32, + alignment: 8 + }) + RUNTIME_SOURCE.copy(runtime, 192) + return runtime +} + +function writeProgramHeader( + runtime, + headerOffset, + { type, flags, offset, virtualAddress, size, memorySize = size, alignment } +) { + runtime.writeUInt32LE(type, headerOffset) + runtime.writeUInt32LE(flags, headerOffset + 4) + runtime.writeBigUInt64LE(BigInt(offset), headerOffset + 8) + runtime.writeBigUInt64LE(BigInt(virtualAddress), headerOffset + 16) + runtime.writeBigUInt64LE(BigInt(offset), headerOffset + 24) + runtime.writeBigUInt64LE(BigInt(size), headerOffset + 32) + runtime.writeBigUInt64LE(BigInt(memorySize), headerOffset + 40) + runtime.writeBigUInt64LE(BigInt(alignment), headerOffset + 48) +} + +async function withFixture(filename, contents, check, { mode = 0o755 } = {}) { + const root = await mkdtemp(join(tmpdir(), 'orca-static-appimage-contract-')) + try { + const path = join(root, filename) + await writeFile(path, contents) + await chmod(path, mode) + await check(path) + } finally { + await rm(root, { recursive: true, force: true }) + } +} diff --git a/config/scripts/verify-cli-bin.mjs b/config/scripts/verify-cli-bin.mjs index a9fa71ce2e7..cdc56401262 100755 --- a/config/scripts/verify-cli-bin.mjs +++ b/config/scripts/verify-cli-bin.mjs @@ -5,15 +5,14 @@ import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'nod import path from 'node:path' import { pathToFileURL } from 'node:url' -const OUT_COMMONJS_PACKAGE_JSON = `${JSON.stringify( - { - name: 'orca-compiled-output', - type: 'commonjs', - private: true - }, - null, - 2 -)}\n` +// Electron packaging restamps the channel-specific version after compilation. +function buildOutPackageJson(version) { + return `${JSON.stringify( + { name: 'orca-compiled-output', type: 'commonjs', private: true, version }, + null, + 2 + )}\n` +} /** * Verifies the published CLI entrypoint and the module-type boundary for the @@ -49,7 +48,7 @@ export function verifyPackageCliBin({ const outPackageJsonPath = path.join(projectDir, 'out', 'package.json') if (fixPackageJson) { mkdirSync(path.dirname(outPackageJsonPath), { recursive: true }) - writeFileSync(outPackageJsonPath, OUT_COMMONJS_PACKAGE_JSON, 'utf8') + writeFileSync(outPackageJsonPath, buildOutPackageJson(packageJson.version), 'utf8') } let outPackageJson try { diff --git a/config/scripts/verify-linux-glibc-floor.cjs b/config/scripts/verify-linux-glibc-floor.cjs index 55ec8ca7724..3a138ed894b 100644 --- a/config/scripts/verify-linux-glibc-floor.cjs +++ b/config/scripts/verify-linux-glibc-floor.cjs @@ -164,6 +164,78 @@ function findMissingProviderDeps(importedSymbols, neededLibraries) { return missing } +// ELF e_machine values for the Linux slices we package. Names match electron-builder's Arch enum. +const ELF_MACHINE_BY_ARCH = Object.freeze({ x64: 0x3e, arm64: 0xb7 }) +const ARCH_BY_ELF_MACHINE = Object.freeze({ 0x3e: 'x64', 0xb7: 'arm64' }) + +/** + * ELF `e_machine`, or null when the file is not a readable little-endian ELF. + * + * Why this is checked at all: cross-building an arm64 package on an x64 host can silently pack an + * x86-64 `pty.node` into the arm64 slice — the rebuild logs a forced arm64 rebuild and still ships + * the host's binary. Every other gate here inspects symbol versions, which are perfectly valid on + * the wrong architecture, so nothing noticed. Observed on a Raspberry Pi 5: the app loaded, then + * failed with "Failed to load native module: pty.node". + */ +function readElfMachine(filePath) { + let fd + try { + fd = openSync(filePath, 'r') + const header = Buffer.alloc(20) + if (readSync(fd, header, 0, 20, 0) !== 20) { + return null + } + // EI_DATA (offset 5) must be ELFDATA2LSB for a little-endian e_machine read. + if (header[5] !== 1) { + return null + } + return header.readUInt16LE(18) + } catch { + return null + } finally { + if (fd !== undefined) { + closeSync(fd) + } + } +} + +// Arch tokens that appear in vendored per-architecture package/directory names. +const ARCH_TOKEN_PATTERN = /(?:^|[^a-z0-9])(arm64|aarch64|x64|x86_64)(?:[^a-z0-9]|$)/i +const ARCH_BY_TOKEN = Object.freeze({ arm64: 'arm64', aarch64: 'arm64', x64: 'x64', x86_64: 'x64' }) + +/** + * The architecture a path advertises, or null when it advertises none. + * + * Why this matters: some dependencies ship every architecture and let their loader pick + * (`@parcel/watcher-linux-arm64-glibc/watcher.node` is arm64 on purpose inside an x64 build). Those + * must be judged against the arch their own path declares, not against the slice. + */ +function declaredArchFromPath(filePath) { + const match = ARCH_TOKEN_PATTERN.exec(filePath) + return match ? ARCH_BY_TOKEN[match[1].toLowerCase()] : null +} + +function findArchViolation(filePath, targetArch) { + // A path that names an architecture is judged against that name, so a per-arch vendored package + // is fine while `bin/linux-arm64-.../node-pty.node` holding an x86-64 binary is still caught. + const declared = declaredArchFromPath(filePath) + const expectedArch = declared ?? targetArch + const expected = ELF_MACHINE_BY_ARCH[expectedArch] + if (expected === undefined) { + return null + } + const machine = readElfMachine(filePath) + if (machine === null || machine === expected) { + return null + } + return { + machine, + actual: ARCH_BY_ELF_MACHINE[machine] ?? `0x${machine.toString(16)}`, + expectedArch, + declared: declared !== null + } +} + function isElfFile(filePath) { let fd try { @@ -312,6 +384,7 @@ function readImportedSymbols(filePath, objdumpPath) { */ function verifyLinuxGlibcFloor(rootDir, options = {}) { const binaries = collectNativeBinaries(rootDir) + const targetArch = options.targetArch if (binaries.length === 0) { console.log(`[verify-linux-glibc-floor] OK — no bundled native binaries under ${rootDir}`) return @@ -327,6 +400,28 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) { ) } + // Why before the glibc pass: a wrong-architecture binary's symbol versions are valid but + // meaningless, so reporting a floor violation for it would send the reader down the wrong path. + const archOffenders = binaries + .map((filePath) => ({ filePath, violation: findArchViolation(filePath, targetArch) })) + .filter(({ violation }) => violation !== null) + if (archOffenders.length > 0) { + const detail = archOffenders + .map( + ({ filePath, violation }) => + ` ${relative(rootDir, filePath) || filePath} is ${violation.actual}, expected ` + + `${violation.expectedArch}${violation.declared ? ' (from its own path)' : ''}` + ) + .join('\n') + throw new Error( + `[verify-linux-glibc-floor] ${archOffenders.length} bundled native binar` + + `${archOffenders.length === 1 ? 'y is' : 'ies are'} built for the wrong architecture ` + + `(target ${targetArch}), so the app will fail to load them at runtime:\n${detail}\n` + + 'Cross-building a Linux slice can pack the host architecture despite a forced rebuild; ' + + 'build this slice on a native runner.' + ) + } + const offenders = [] for (const filePath of binaries) { const { versionNeeds, neededLibraries } = readDynamicInfo(filePath, objdumpPath) @@ -375,6 +470,10 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) { module.exports = { MIN_GLIBC, + ELF_MACHINE_BY_ARCH, + readElfMachine, + declaredArchFromPath, + findArchViolation, VERSION_FLOORS, FLOOR_LABEL, RELOCATED_SYMBOL_PROVIDERS, diff --git a/config/scripts/verify-linux-glibc-floor.test.mjs b/config/scripts/verify-linux-glibc-floor.test.mjs index 603e4e85c00..d8d82165053 100644 --- a/config/scripts/verify-linux-glibc-floor.test.mjs +++ b/config/scripts/verify-linux-glibc-floor.test.mjs @@ -6,6 +6,10 @@ import { describe, expect, it } from 'vitest' const require = createRequire(import.meta.url) const { + readElfMachine, + declaredArchFromPath, + findArchViolation, + ELF_MACHINE_BY_ARCH, parseGlibcVersion, compareGlibcVersions, parseVersionNeeds, @@ -321,3 +325,86 @@ describe.skipIf(process.platform === 'win32')('verifyLinuxGlibcFloor', () => { } }) }) + +/** Minimal little-endian 64-bit ELF header with the given e_machine. */ +function elfHeader(machine) { + const header = Buffer.alloc(64) + header.write('\x7fELF', 0, 'latin1') + header[4] = 2 // ELFCLASS64 + header[5] = 1 // ELFDATA2LSB + header[6] = 1 // EV_CURRENT + header.writeUInt16LE(3, 16) // ET_DYN + header.writeUInt16LE(machine, 18) + return header +} + +describe('bundled native binary architecture', () => { + it('reads e_machine from a little-endian ELF', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const file = join(dir, 'pty.node') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.arm64)) + expect(readElfMachine(file)).toBe(ELF_MACHINE_BY_ARCH.arm64) + await rm(dir, { recursive: true, force: true }) + }) + + // The observed failure: cross-building arm64 on an x64 host packed an x86-64 pty.node, whose + // symbol versions are valid, so every other gate here passed it. + // Real CI hit: @parcel/watcher ships every architecture and its loader picks the match, so the + // arm64 copy is present in an x64 build on purpose. + it('accepts a per-arch vendored package that matches its own path', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const pkg = join(dir, '@parcel', 'watcher-linux-arm64-glibc') + await mkdir(pkg, { recursive: true }) + const file = join(pkg, 'watcher.node') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.arm64)) + expect(declaredArchFromPath(file)).toBe('arm64') + expect(findArchViolation(file, 'x64')).toBeNull() + await rm(dir, { recursive: true, force: true }) + }) + + // But a path that names an arch must actually hold it — this is the Pi 5 failure. + it('flags a binary that contradicts the architecture its own path names', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const nested = join(dir, 'bin', 'linux-arm64-148') + await mkdir(nested, { recursive: true }) + const file = join(nested, 'node-pty.node') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64)) + expect(findArchViolation(file, 'arm64')).toMatchObject({ actual: 'x64', expectedArch: 'arm64' }) + // Still caught even when the slice being built is x64. + expect(findArchViolation(file, 'x64')).toMatchObject({ actual: 'x64', expectedArch: 'arm64' }) + await rm(dir, { recursive: true, force: true }) + }) + + it('flags an x86-64 binary in an arm64 slice', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const file = join(dir, 'pty.node') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64)) + expect(findArchViolation(file, 'arm64')).toMatchObject({ actual: 'x64' }) + await rm(dir, { recursive: true, force: true }) + }) + + it('accepts a matching architecture', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const file = join(dir, 'pty.node') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64)) + expect(findArchViolation(file, 'x64')).toBeNull() + await rm(dir, { recursive: true, force: true }) + }) + + it('stays silent when no target architecture is supplied', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const file = join(dir, 'pty.node') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64)) + expect(findArchViolation(file, undefined)).toBeNull() + await rm(dir, { recursive: true, force: true }) + }) + + it('ignores a file that is not a readable little-endian ELF', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const file = join(dir, 'not-elf.node') + await writeFile(file, Buffer.from('not an elf at all')) + expect(readElfMachine(file)).toBeNull() + expect(findArchViolation(file, 'arm64')).toBeNull() + await rm(dir, { recursive: true, force: true }) + }) +}) diff --git a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs index 0c192382d7a..a8c2cb3f4e7 100644 --- a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs +++ b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs @@ -47,8 +47,10 @@ const WINDOWS_SHIM_SPAWN_ALLOWLIST = [ 'config/scripts/ensure-native-runtime.test.mjs', 'config/scripts/live-remote-freeze-rpc.mjs', 'config/scripts/remote-agent-session-authority-repro.mjs', - // macOS-only build path; the win32 branch is dead code there. + // Platform-local build paths; the win32 branch is dead code on both. 'config/scripts/build-mac-local.mjs', + 'config/scripts/build-linux-local.mjs', + 'config/scripts/build-linux-local.test.mjs', // Benchmarks, repros and e2e drivers — developer-invoked or Linux-only in CI. 'config/scripts/build-orcad-prebuilds.mjs', 'config/scripts/run-ai-vault-typing-bench.mjs', diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 93d556602f8..1b9600188f2 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -127,6 +127,8 @@ // Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this // project's serve spec; the module has no imports, so listing it pulls in nothing else. "../src/main/startup/serve-mode-argv.ts", + // The parity test keeps this import-free list aligned with COMMAND_SPECS. + "../src/main/startup/cli-command-names.ts", "../src/main/runtime/runtime-metadata.ts", "../src/main/sqlite/sync-database.ts", "../src/main/win32-utils.ts" diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index 3d7db834e8e..7368678c2e4 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -8,7 +8,11 @@ Linux, the packaged AppImage still needs the libraries that Electron expects at startup. Current Orca builds start Xvfb automatically for `orca serve` when no `DISPLAY` is set, but Xvfb must be installed first. A separate D-Bus session is not required. When `DISPLAY` is set, Orca uses that display instead of starting -a competing Xvfb process. +a competing Xvfb process, provided the display is usable: its socket must exist, +and if an X lock file is present it must name a running process. A `DISPLAY` +whose lock names a dead process is refused rather than replaced, and `orca serve` +exits — unset `DISPLAY` to let Orca start its own Xvfb. A socket published with +no lock at all (a container bind-mounting `/tmp/.X11-unix`, or WSLg) is accepted. The supported deployment matrix covers Ubuntu 20.04, 22.04, and 24.04 and current Debian stable — anything with glibc 2.31 or newer (see @@ -229,6 +233,10 @@ clients should use. `KillMode=mixed` sends the graceful stop signal only to Orca's main process, then retains systemd's cgroup-wide `SIGKILL` fallback if shutdown times out. This lets Orca keep its owned Xvfb alive until Electron disconnects cleanly. +It does **not** preserve the detached terminal daemon: the daemon and its PTYs +remain in `orca-serve.service`'s cgroup and are killed when the stop completes. +Every `systemctl stop` or `restart` therefore ends live terminals and agent +processes, even though their persisted layout and terminal history remain. Exit status `3` means another process already owns this userData profile, so `RestartPreventExitStatus=3` stops the unit instead of retrying a launch that @@ -324,6 +332,14 @@ sudo systemctl enable --now orca-xvfb.service orca-serve.service ## CLI Install Note +The registered Linux CLI command is `orca-ide`, not `orca`, to avoid shadowing +the GNOME Orca screen reader. Desktop-managed terminals receive a +terminal-scoped bare-`orca` shim. A packaged headless `orca serve` also makes a +best-effort dispatcher at `$HOME/.local/bin/orca` for the service user's own +shell, so the Claude Teams launcher can resolve its bare command; it does not +replace another user's `orca`. From an ordinary shell outside that service +user's managed environment, substitute `orca-ide` for `orca` in commands below. + On a headless host, you do not need to open the desktop UI just to run the server. Invoke the AppImage directly: @@ -376,7 +392,7 @@ at all — the built-in updater only runs in the desktop GUI, and no paired mobi or web client can trigger it remotely. Upgrading is always a deliberate step: replace the AppImage and restart the service. -Two facts make this safe and predictable: +Two facts make the persisted-state transition predictable: - **State lives in the service user's home, not next to the binary.** Persisted data is under `/home/orca/.config/` (Orca uses both an `orca` and an `Orca` @@ -388,15 +404,37 @@ Two facts make this safe and predictable: state into the current schema and writes it back in the current shape, so a forward upgrade needs no manual data step. +These guarantees do not preserve live processes. The service restart kills +every terminal and agent in its cgroup; an agent conversation may be resumable, +but its current process and any in-flight command are gone. + +Immediately before stopping the service, obtain a fresh census as the service's +OS account and home. Use the installer's absolute launcher path so `sudo`'s +`secure_path` cannot hide a per-user registration: +`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`. +Replace both `orca` and `/home/orca` with the service account and home used by +your unit; for an extracted deployment, use its absolute `resources/bin/orca-ide` +launcher instead. Proceed only when the result is +untruncated, has an explicit `hostScope`, covers every execution host affected +by this service stop, and lists no terminals on those hosts. Every +`omittedHostIds` entry must be explicitly accounted for outside this service's +execution boundary. A separately paired runtime is outside that boundary; local +execution and SSH hosts reached through this runtime are not. An affected or +unknown omission, missing scope, failed request or lost connection is +`unverifiable`, so defer the restart. Do not allow new work between that census +and the stop; Orca does not yet provide an atomic census-and-stop fence. + Rolling back is the case that needs care — see [Roll back](#roll-back). ### Record the version you deploy -Orca has no headless version command: there is no `--version` flag or `version` -subcommand, and `orca serve` prints only its endpoint. Choose a release tag -explicitly instead of following the `latest` URL, and record it next to the -binary so upgrades are auditable. The steps below keep that record in -`/opt/orca/VERSION`. +The bundled CLI launcher prints the Orca build with `orca-ide --version`. For an +extracted deployment, that launcher is +`squashfs-root/resources/bin/orca-ide`; deb/rpm installs and CLI registration put +it on `PATH`. Do not use `orca-linux.AppImage --version` for this audit because +Electron owns the direct binary's version flags and may report its own runtime +version. For an AppImage service, choose a release tag explicitly and record it +next to the binary. The steps below keep that record in `/opt/orca/VERSION`. ### Upgrade steps @@ -877,8 +915,8 @@ refuse to run there and print the command to run on the machine you want. - `dlopen(): error loading libfuse.so.2`: install `libfuse2`. - `Missing X server or $DISPLAY`: install `xvfb`, or start the managed Xvfb service and set `DISPLAY=:99`. -- `Xvfb not found`: confirm `command -v Xvfb` and use that absolute path in the - systemd unit. +- `[serve] Xvfb failed to start` or `[serve] Could not start Xvfb`: confirm + `command -v Xvfb` and that it is on the service `PATH`. - GPU or DRI warnings on a VPS: keep `LIBGL_ALWAYS_SOFTWARE=1` in the service environment. - Chromium sandbox errors: confirm the service is running as the non-root diff --git a/docs/reference/linux-glibc-compatibility.md b/docs/reference/linux-glibc-compatibility.md index a11506235fe..20e9b38acb5 100644 --- a/docs/reference/linux-glibc-compatibility.md +++ b/docs/reference/linux-glibc-compatibility.md @@ -6,6 +6,14 @@ Packaging enforces this floor automatically; keep it in mind when adding or upgrading native dependencies. (The optional speech feature is the one exception — see below.) +## Local package build prerequisites + +`pnpm run build:linux` produces AppImage, deb, and RPM artifacts. The RPM target +requires `rpmbuild` on `PATH`; install `rpm` on Ubuntu/Debian, `rpm-build` on +Fedora/RHEL, or `rpm` through Homebrew on macOS, then verify it with +`rpmbuild --version` before packaging. Cross-host builds have the same +requirement. + ## Why this needs attention A native module (`.node`) links against the glibc of the machine that compiled diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md index bbde9829514..2901a5bf0b6 100644 --- a/docs/reference/orcad-operations.md +++ b/docs/reference/orcad-operations.md @@ -4,8 +4,6 @@ whatever supervises it: what it binds, what it owns on disk, who restarts what, and what its readiness payload actually proves. -Design background: `docs/design/shipping-orcad.html` §00c and §04. - ## Two long-lived processes, not one A deployment is **orcad** plus **the terminal daemon**. @@ -14,18 +12,22 @@ A deployment is **orcad** plus **the terminal daemon**. | ---------- | -------------------------------- | ------------------------------------- | | Started by | the supervisor | orcad, detached | | Owns | RPC, git, worktrees, persistence | every local PTY | -| Lifetime | one supervised run | **outlives orcad** | +| Lifetime | one supervised run | detached from orcad, not its service | | Endpoint | `ws://:` | `/daemon/daemon-v.sock` | -The daemon outliving orcad is the property the whole peer model is recommended for -(`docs/reference/ssh-execution-boundary.md`): daemon-backed PTYs stay `live` across a runtime -restart, so a restart, an update or a rollback does not destroy running work. Everything -below exists to keep that true. +orcad detaches the daemon and calls `disconnectDaemon()`, never `shutdownDaemon()`. The +built-in remote deployment path stops only the recorded orcad PID, so the daemon and its PTYs +survive. The successor adopts the current endpoint and routes supported previous protocol +versions through legacy adapters. This makes a PID-scoped update, rollback or restart +non-destructive to live work. -**Consequence for supervision:** orcad's shutdown path calls `disconnectDaemon()`, never -`shutdownDaemon()`. A supervisor that reaps orcad's whole process group — systemd's -`KillMode=control-group` — kills the daemon too and turns every restart back into data loss. -Use `KillMode=mixed` (the default) or `process`, and never `--send-sigkill` on the group. +Process detachment is not service isolation. A daemon forked by orcad, and every PTY it owns, +remain in the same systemd service cgroup. `KillMode=mixed` does **not** preserve them: it +sends the graceful stop signal only to the main process, then sends `SIGKILL` to every process +remaining in the cgroup when the stop timeout expires. `KillMode=control-group` is destructive +too. `KillMode=process` leaves service-owned processes unmanaged and is not a supported +preservation mechanism. Service-restart survival requires separately supervised cgroups; the +current deployment does not provide them. ## Bind policy @@ -76,6 +78,25 @@ a live daemon makes worthwhile. ## Supervision +### Process-scoped and cgroup-wide stops + +The built-in remote updater performs a PID-scoped stop and keeps the daemon's install version +pinned while it owns sessions. A combined-unit systemd stop or restart is different: it reaps +the daemon and every live terminal after the graceful window. + +Before a cgroup-wide stop, obtain a fresh `orca-ide terminal list --json` result using the same OS +account and home as the daemon. Invoke the installer's absolute launcher path so `sudo`'s +`secure_path` cannot hide a per-user registration (for example, +`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`). Replace both `orca` and +`/home/orca` with the service account and home used by the unit; an extracted deployment may use +its absolute `resources/bin/orca-ide` launcher instead. A safe empty census is untruncated, has an explicit `hostScope`, covers every +execution host affected by the stop, and lists no terminals on those hosts. Every +`omittedHostIds` entry must be explicitly accounted for outside the target service's execution +boundary. A separately paired runtime is outside that boundary; local execution and SSH hosts +reached through this runtime are not. An affected or unknown omission, missing scope, +truncation, a failed request or lost contact makes the result `unverifiable`: defer the stop. Do +not admit new work after the census. Orca does not yet provide an atomic census-and-stop fence. + ### Who supervises orcad An external supervisor (systemd, launchd, a process manager). orcad conforms to it: @@ -127,11 +148,11 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to ### Decommissioning -The daemon outliving orcad is deliberate, so stopping orcad does **not** leave the host with -zero Orca processes. A daemon that has been adopted stays resident after its runtime -disconnects — that is what makes the next start a reattach rather than a cold restore. To -retire a host completely, stop orcad and then stop the daemon named by -`health.terminalDaemon.pid`, or delete the data root and let the endpoint go stale. +After a PID-scoped stop, an adopted daemon stays resident so the next orcad can reattach. +A combined-unit systemd stop kills it instead. To retire a process-scoped deployment, apply +the census rule above, stop orcad, then stop the daemon named by `health.terminalDaemon.pid`. +Only report it `exited` after verification on the execution host; loss of contact is +`unverifiable`. ## Health @@ -145,7 +166,8 @@ nodeVersion / nodeAbi process.versions.node / .modules — the ABI native add platform / arch / pid terminalDaemon: state live | degraded | absent - ownsFreshSessions whether NEW terminals are daemon-owned, i.e. survive an orcad restart + ownsFreshSessions whether NEW terminals are daemon-owned; this supports PID-scoped + restart recovery, not supervisor or service-cgroup isolation pid the live daemon's pid, from its own PID record buildVersion the build the LIVE daemon was forked from (may legitimately predate this orcad after an update — reporting orcad's version for both would @@ -179,11 +201,13 @@ Named here so nothing reads as implemented that is not: - **A continuous health endpoint.** `health` is published once, in the readiness payload. A supervisor's periodic liveness/readiness probe needs an HTTP or RPC surface over the same `collectOrcadHealth()`; that surface does not exist yet. -- **libc slot.** §04 asks for it in the health payload. It belongs to the native strategy - (plan item 5), which owns libc detection; there is no honest value to publish until then. -- **`degradations[]`.** Plan item 2's contract, not this one. +- **Systemd-isolated daemon supervision.** orcad and its daemon currently share one service + cgroup, so a combined-unit stop cannot preserve live terminals. +- **libc slot.** There is no honest health value to publish until native libc detection owns + it. +- **`degradations[]`.** The readiness contract does not publish this collection yet. - **Credential administration** (list / revoke / rotate devices, expiring pending offers, - structured security logging) — §04, not delivered here. + structured security logging). - **Pinned-port fail-closed.** A pinned `--port` still falls back to an OS-assigned port on conflict. - **Reconciling `webClientUrl` with reachability** under the loopback default. diff --git a/docs/reference/ssh-execution-boundary.md b/docs/reference/ssh-execution-boundary.md index d85dadacccd..45b307411f6 100644 --- a/docs/reference/ssh-execution-boundary.md +++ b/docs/reference/ssh-execution-boundary.md @@ -82,4 +82,4 @@ A listing is only evidence about the hosts it actually covered. When a result do An SSH host and a paired runtime (`orca environment`) imply opposite boundaries: the first is a dumb execution host driven by your client, the second is a peer that owns its own control plane. Registering the same machine both ways splits its worktrees across two identities, makes `terminal list` return different sets depending on `--environment`, and reliably confuses both humans and agents. Pick one per machine. -For work that must continue while you are offline, use the peer/headless-runtime model on the remote host instead of the direct-SSH model. Its control plane is host-local, and its daemon-backed PTYs stay `live` across a normal runtime restart so the runtime can reattach; an explicit daemon shutdown can still make them `exited`. Do not register the same machine through both models. A detached agent process outside Orca can also survive a control-plane outage, but it has no stdin, so its instructions cannot be amended mid-run. +For work that must continue while you are offline, use the peer/headless-runtime model on the remote host instead of the direct-SSH model. Its control plane is host-local, and its daemon-backed PTYs can stay `live` across a PID-scoped runtime restart so the runtime can reattach. A service manager that reaps the runtime's cgroup, or an explicit daemon shutdown, makes them `exited`; see [Running orcad](./orcad-operations.md#process-scoped-and-cgroup-wide-stops). Do not register the same machine through both models. A detached agent process outside Orca can also survive a control-plane outage, but it has no stdin, so its instructions cannot be amended mid-run. diff --git a/package.json b/package.json index aab4c660c48..9846604fab6 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "verify:localization-coverage": "node config/scripts/audit-localization-coverage.mjs --check", "audit:localization": "node config/scripts/audit-localization-coverage.mjs", "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs", + "test:linux-cli-contract": "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage", "test:repro:skills-cli-runtime": "pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", "build:electron-vite:parallel": "node config/scripts/run-electron-vite-targets-in-parallel.mjs", @@ -94,7 +95,7 @@ "build:icons": "bash resources/icon-source/generate.sh", "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:keyboard-layout-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && node config/scripts/build-mac-local.mjs", "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:keyboard-layout-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", - "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb", + "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && node config/scripts/build-linux-local.mjs", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless", "test:e2e:workspace-session-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-quit-relaunch-session.spec.ts tests/e2e/golden-terminal-file-link.spec.ts tests/e2e/golden-worktree-create-switch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs", diff --git a/resources/linux/bin/orca-ide b/resources/linux/bin/orca-ide index 88b04e9e47d..f191f27c770 100755 --- a/resources/linux/bin/orca-ide +++ b/resources/linux/bin/orca-ide @@ -38,4 +38,5 @@ export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}" unset NODE_OPTIONS unset NODE_REPL_EXTERNAL_MODULE +# CLI commands run in Electron's Node mode and must never initialize Chromium. ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON" "$CLI" "$@" diff --git a/resources/linux/packaging/after-remove.sh b/resources/linux/packaging/after-remove.sh index 0f497024613..a23426df446 100755 --- a/resources/linux/packaging/after-remove.sh +++ b/resources/linux/packaging/after-remove.sh @@ -4,6 +4,12 @@ # /usr/bin/orca-ide a user or other package may own. set -e +# RPM passes an instance count; dpkg passes the package lifecycle action. +case "${1-}" in + 0 | remove | purge) ;; + *) exit 0 ;; +esac + link="/usr/bin/orca-ide" if [ -L "$link" ]; then diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index eeedfbe0428..1ac86d99e12 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -93,6 +93,16 @@ describe('parseArgs', () => { expect(parsed.flags.get('repo')).toBe('id:abc') }) + it('preserves a project selector before the project command', () => { + const parsed = parseArgs( + ['--project', 'github:stablyai/orca', 'project', 'setups'], + [['project', 'setups']] + ) + + expect(parsed.commandPath).toEqual(['project', 'setups']) + expect(parsed.flags.get('project')).toBe('github:stablyai/orca') + }) + it('preserves a selector value that is also a registered command', () => { const parsed = parseArgs( ['--environment', 'status', 'worktree', 'list'], @@ -113,6 +123,16 @@ describe('parseArgs', () => { expect(parsed.flags.get('environment')).toBe('worktree') }) + it.each([ + ['--project', 'project', 'project', 'setups'], + ['--project=project', 'project', 'setups'] + ])('preserves a command-named project selector in %j', (...args) => { + const parsed = parseArgs(args, [['project', 'setups']]) + + expect(parsed.commandPath).toEqual(['project', 'setups']) + expect(parsed.flags.get('project')).toBe('project') + }) + it('parses emulator reinstall as a boolean flag', () => { const parsed = parseArgs(['emulator', 'install', 'app.apk', '--reinstall', '--device', 'emu']) diff --git a/src/cli/args.ts b/src/cli/args.ts index c4c373a814f..a934915655e 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,6 +1,12 @@ import { RuntimeClientError } from './runtime/types' import { unknownCommandData, unknownFlagData } from './command-suggestion' import { specPaths, type CommandSpec } from './command-spec' +import { + CLI_BOOLEAN_FLAGS, + CLI_GLOBAL_FLAGS, + CLI_GLOBAL_VALUE_FLAGS, + findCliCommandIndex +} from '../shared/cli-argument-boundary' export { specPaths } export type { CommandSpec } @@ -11,51 +17,9 @@ export type ParsedArgs = { positionalFlagConflicts?: string[] } -export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment'] -const GLOBAL_VALUE_FLAGS = new Set(['pairing-code', 'environment']) -export const BOOLEAN_FLAGS = new Set([ - 'all', - 'attachments', - 'children', - 'comments', - 'connect', - 'current', - 'dry-run', - 'enter', - 'focus', - 'force', - 'full', - 'help', - 'inject', - 'include-archived', - 'include-visual-layouts', - 'interrupt', - 'json', - 'local', - 'messages', - 'me', - 'mobile', - 'mobile-pairing', - 'no-pairing', - 'screen', - 'parent-current', - 'provision', - 'ready', - 'recipe-json', - 'relations', - 'reinstall', - 'restore-window', - 'return-preamble', - 'run-hooks', - 'show-profile', - 'staged', - 'tab', - 'tasks', - 'text-stdin', - 'unread', - 'value-stdin', - 'wait' -]) +export const GLOBAL_FLAGS = CLI_GLOBAL_FLAGS +const GLOBAL_VALUE_FLAGS = new Set(CLI_GLOBAL_VALUE_FLAGS) +export const BOOLEAN_FLAGS = CLI_BOOLEAN_FLAGS export const REPEATED_FLAG_SEPARATOR = '\u0000' const REPEATABLE_STRING_FLAGS = new Set(['label', 'skill']) @@ -69,25 +33,10 @@ function setFlagValue(flags: Map, name: string, value: flags.set(name, value) } -function commandPathStartsAt(argv: string[], tokenIndex: number, path: string[]): boolean { - let cursor = tokenIndex - for (const part of path) { - while (argv[cursor]?.startsWith('--')) { - const assignment = argv[cursor].slice(2) - const flag = assignment.split('=', 1)[0] - cursor += assignment.includes('=') || BOOLEAN_FLAGS.has(flag) ? 1 : 2 - } - if (argv[cursor] !== part) { - return false - } - cursor += 1 - } - return true -} - export function parseArgs(argv: string[], commandPaths?: readonly string[][]): ParsedArgs { const commandPath: string[] = [] const flags = new Map() + const commandIndex = findCliCommandIndex(argv, commandPaths ?? []) for (let i = 0; i < argv.length; i += 1) { const token = argv[i] @@ -112,9 +61,7 @@ export function parseArgs(argv: string[], commandPaths?: readonly string[][]): P continue } // Why: a pre-command flag must not consume a registry-resolvable command path. - const startsCommandAt = (tokenIndex: number): boolean => - commandPaths?.some((path) => commandPathStartsAt(argv, tokenIndex, path)) ?? false - if (commandPath.length === 0 && startsCommandAt(i + 1) && !startsCommandAt(i + 2)) { + if (commandPath.length === 0 && i + 1 === commandIndex) { flags.set(flag, true) continue } diff --git a/src/cli/cli-command-name-parity.test.ts b/src/cli/cli-command-name-parity.test.ts new file mode 100644 index 00000000000..32bbcc06add --- /dev/null +++ b/src/cli/cli-command-name-parity.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { CLI_COMMAND_NAMES } from '../main/startup/cli-command-names' +import { COMMAND_SPECS } from './specs' + +const specCommandNames = [...new Set(COMMAND_SPECS.map((spec) => spec.path[0]))].sort() + +describe('CLI command-name parity between COMMAND_SPECS and the launch redirect', () => { + it('has commands to compare', () => { + expect(specCommandNames.length).toBeGreaterThan(0) + }) + + it('redirects every top-level CLI command', () => { + const redirected = new Set(CLI_COMMAND_NAMES) + expect(specCommandNames.filter((name) => !redirected.has(name))).toEqual([]) + }) + + it('lists no command that COMMAND_SPECS does not define', () => { + const specNames = new Set(specCommandNames) + expect([...CLI_COMMAND_NAMES].filter((name) => !specNames.has(name))).toEqual([]) + }) + + it('stays sorted and free of duplicates so additions are easy to review', () => { + expect([...CLI_COMMAND_NAMES]).toEqual([...new Set(CLI_COMMAND_NAMES)].sort()) + }) +}) diff --git a/src/cli/cli-version.test.ts b/src/cli/cli-version.test.ts new file mode 100644 index 00000000000..6ffe60692fb --- /dev/null +++ b/src/cli/cli-version.test.ts @@ -0,0 +1,36 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { readOrcaCliVersion } from './cli-version' + +const temporaryDirectories: string[] = [] + +afterEach(() => + Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))) +) + +describe('CLI version', () => { + it('reads the package boundary beside the compiled CLI', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-version-')) + const runtimeDir = join(root, 'cli') + temporaryDirectories.push(root) + await mkdir(runtimeDir) + await writeFile(join(root, 'package.json'), JSON.stringify({ version: '1.4.178-rc.2' })) + + expect(readOrcaCliVersion(runtimeDir)).toBe('1.4.178-rc.2') + }) + + it('rejects missing, malformed, and non-string versions', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-version-invalid-')) + const runtimeDir = join(root, 'cli') + temporaryDirectories.push(root) + await mkdir(runtimeDir) + + expect(readOrcaCliVersion(runtimeDir)).toBeNull() + await writeFile(join(root, 'package.json'), '{') + expect(readOrcaCliVersion(runtimeDir)).toBeNull() + await writeFile(join(root, 'package.json'), JSON.stringify({ version: 178 })) + expect(readOrcaCliVersion(runtimeDir)).toBeNull() + }) +}) diff --git a/src/cli/cli-version.ts b/src/cli/cli-version.ts new file mode 100644 index 00000000000..0918ec763fd --- /dev/null +++ b/src/cli/cli-version.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +// Node-mode CLI code cannot read the package metadata inside app.asar. +export function readOrcaCliVersion(runtimeDir = __dirname): string | null { + try { + const parsed = JSON.parse(readFileSync(join(runtimeDir, '..', 'package.json'), 'utf8')) as { + version?: unknown + } + return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : null + } catch { + return null + } +} diff --git a/src/cli/command-suggestion.ts b/src/cli/command-suggestion.ts index db481de6ea8..6bc6d6eee0b 100644 --- a/src/cli/command-suggestion.ts +++ b/src/cli/command-suggestion.ts @@ -1,4 +1,7 @@ import { specPaths, type CommandSpec } from './command-spec' +import { levenshtein } from '../shared/edit-distance' + +export { levenshtein } from '../shared/edit-distance' // Why: rank the live registry so typo recovery cannot drift from accepted paths. @@ -46,30 +49,6 @@ export type CommandErrorData = { nextSteps: string[] } -export function levenshtein(a: string, b: string): number { - const m = a.length - const n = b.length - if (m === 0) { - return n - } - if (n === 0) { - return m - } - let prev = Array.from({ length: n + 1 }, (_, index) => index) - let curr = Array.from({ length: n + 1 }, () => 0) - for (let i = 1; i <= m; i += 1) { - curr[0] = i - for (let j = 1; j <= n; j += 1) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1 - curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost) - } - const swap = prev - prev = curr - curr = swap - } - return prev[n] -} - // Why: one bounded near-match ranking keeps command and flag recovery consistent. function rankByDistance(scored: { label: string; distance: number }[]): string[] { return scored diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index 145540bb627..d4979ff2ae9 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -3,6 +3,7 @@ import type { CommandHandler } from '../dispatch' import { formatCliStatus, formatStatus, printResult } from '../format' import { RuntimeClientError, serveOrcaApp } from '../runtime-client' import { stripElectronRunAsNode } from '../runtime/launch' +import { getServeOptionValidationError } from '../../shared/serve-option-validation' function envRecord(): Record { // Why: the `orca` launcher runs Orca's Electron binary as Node, so this CLI @@ -92,43 +93,29 @@ export const CORE_HANDLERS: Record = { printResult(result, json, formatCliStatus) }, serve: async ({ flags, json }) => { - if (flags.get('no-pairing') === true && flags.get('mobile-pairing') === true) { - throw new RuntimeClientError( - 'invalid_argument', - 'Use either --mobile-pairing or --no-pairing, not both.' - ) - } - if (flags.get('recipe-json') === true && flags.get('no-pairing') === true) { - throw new RuntimeClientError( - 'invalid_argument', - 'Recipe JSON output requires runtime pairing; remove --no-pairing.' - ) - } - if (flags.get('recipe-json') === true && flags.get('mobile-pairing') === true) { - throw new RuntimeClientError( - 'invalid_argument', - 'Recipe JSON output requires runtime pairing; remove --mobile-pairing.' - ) - } - const projectRoot = - typeof flags.get('project-root') === 'string' ? (flags.get('project-root') as string) : null - if (flags.get('recipe-json') === true && !projectRoot) { - throw new RuntimeClientError( - 'invalid_argument', - 'Recipe JSON output requires --project-root.' - ) + const projectRootValue = flags.get('project-root') + const projectRoot = typeof projectRootValue === 'string' ? projectRootValue : null + const noPairing = flags.get('no-pairing') === true + const mobilePairing = flags.get('mobile-pairing') === true + const recipeJson = flags.get('recipe-json') === true + const validationError = getServeOptionValidationError({ + noPairing, + mobilePairing, + recipeJson, + projectRoot + }) + if (validationError) { + throw new RuntimeClientError('invalid_argument', validationError) } const port = getOptionalServePort(flags) + const pairingAddressValue = flags.get('pairing-address') const exitCode = await serveOrcaApp({ json, port, - pairingAddress: - typeof flags.get('pairing-address') === 'string' - ? (flags.get('pairing-address') as string) - : null, - noPairing: flags.get('no-pairing') === true, - mobilePairing: flags.get('mobile-pairing') === true, - recipeJson: flags.get('recipe-json') === true, + pairingAddress: typeof pairingAddressValue === 'string' ? pairingAddressValue : null, + noPairing, + mobilePairing, + recipeJson, projectRoot }) process.exitCode = exitCode diff --git a/src/cli/index.ts b/src/cli/index.ts index 2a8921a2cdb..c29bfff7060 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,6 +8,7 @@ import { specPaths, validateCommandAndFlags } from './args' +import { readOrcaCliVersion } from './cli-version' import { dispatch } from './dispatch' import { assertEnvironmentSelectorResolvable, @@ -59,6 +60,17 @@ export async function main( argv = process.argv.slice(2), cwd = resolveInvocationCwd() ): Promise { + // Why: version audits use the bundled launcher; Electron intercepts direct binary version flags. + if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) { + const version = readOrcaCliVersion() + if (!version) { + process.stderr.write('Could not determine the Orca version for this build.\n') + process.exitCode = 1 + return + } + process.stdout.write(`${version}\n`) + return + } if (argv[0] === 'agent-teams-tmux') { await runAgentTeamsTmuxShim(argv.slice(1)) return diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts index 235b2b2ed56..7931e489e3d 100644 --- a/src/cli/runtime/launch.test.ts +++ b/src/cli/runtime/launch.test.ts @@ -13,12 +13,14 @@ import { SERVE_REPLACEMENT_READY_TIMEOUT_MS } from './serve-update-supervisor' -const { spawnMock } = vi.hoisted(() => ({ - spawnMock: vi.fn() +const { spawnMock, spawnSyncMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + spawnSyncMock: vi.fn() })) vi.mock('child_process', () => ({ - spawn: spawnMock + spawn: spawnMock, + spawnSync: spawnSyncMock })) import { launchOrcaApp, serveOrcaApp } from './launch' @@ -86,6 +88,7 @@ describe('serveOrcaApp', () => { beforeEach(() => { spawnMock.mockReset() + spawnSyncMock.mockReset() process.env.ORCA_APP_EXECUTABLE = '/Applications/Orca.app/Contents/MacOS/Orca' }) @@ -93,7 +96,6 @@ describe('serveOrcaApp', () => { vi.restoreAllMocks() delete process.env.ORCA_APP_EXECUTABLE delete process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT - delete process.env.ORCA_APPIMAGE_NO_SANDBOX delete process.env.ORCA_USER_DATA_PATH return Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) @@ -391,32 +393,6 @@ describe('serveOrcaApp', () => { ) }) - it('preserves an AppImage no-sandbox launch for the server child', async () => { - process.env.ORCA_APPIMAGE_NO_SANDBOX = '1' - const child = { - kill: vi.fn(), - once: vi.fn( - (event: string, handler: (code: number | null, signal: string | null) => void) => { - if (event === 'exit') { - queueMicrotask(() => handler(0, null)) - } - return child - } - ) - } - spawnMock.mockReturnValue(child) - - await expect(serveOrcaApp({ json: true })).resolves.toBe(0) - - expect(spawnMock).toHaveBeenCalledWith( - '/Applications/Orca.app/Contents/MacOS/Orca', - ['--no-sandbox', '--serve', '--serve-json'], - expect.any(Object) - ) - const spawnOptions = spawnMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } - expect(spawnOptions.env).not.toHaveProperty('ORCA_APPIMAGE_NO_SANDBOX') - }) - it('passes the app root before serve flags for dev Electron executables', async () => { process.env.ORCA_APP_EXECUTABLE = '/repo/node_modules/.bin/electron' process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1' @@ -444,6 +420,66 @@ describe('serveOrcaApp', () => { ) }) + it.each([ + { probe: 'exits nonzero', result: { status: 1 }, expectedPrefix: ['--no-sandbox'] }, + { probe: 'succeeds', result: { status: 0 }, expectedPrefix: [] }, + { + probe: 'times out', + result: { + status: null, + error: Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) + }, + expectedPrefix: ['--no-sandbox'] + }, + { + probe: 'cannot start', + result: { status: null, error: Object.assign(new Error('missing'), { code: 'ENOENT' }) }, + expectedPrefix: ['--no-sandbox'] + } + ])( + 'uses the extracted AppImage sandbox fallback when the userns probe $probe', + async ({ result: userNamespaceResult, expectedPrefix }) => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + const getuidDescriptor = Object.getOwnPropertyDescriptor(process, 'getuid') + const root = await mkdtemp(join(tmpdir(), 'orca-extracted-appimage-')) + temporaryDirectories.push(root) + const executable = join(root, 'orca-ide') + await writeFile(join(root, 'AppRun'), '', { mode: 0o755 }) + process.env.ORCA_APP_EXECUTABLE = executable + Object.defineProperty(process, 'platform', { value: 'linux' }) + Object.defineProperty(process, 'getuid', { configurable: true, value: () => 1000 }) + spawnSyncMock.mockReturnValue(userNamespaceResult) + const child = new FakeChildProcess() + spawnMock.mockReturnValue(child) + + try { + const result = serveOrcaApp({ json: true }) + queueMicrotask(() => child.emit('exit', 0, null)) + await expect(result).resolves.toBe(0) + expect(spawnSyncMock).toHaveBeenCalledWith( + 'unshare', + ['-Ur', 'true'], + expect.objectContaining({ stdio: 'ignore', timeout: 2_000 }) + ) + expect(spawnMock).toHaveBeenCalledWith( + executable, + [...expectedPrefix, '--serve', '--serve-json'], + // Foreground serve must share POSIX job-control signals with its CLI supervisor. + expect.objectContaining({ detached: false }) + ) + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor) + } + if (getuidDescriptor) { + Object.defineProperty(process, 'getuid', getuidDescriptor) + } else { + Reflect.deleteProperty(process, 'getuid') + } + } + } + ) + it('prints recipe JSON from a detached server child and exits', async () => { const child = new FakeChildProcess() spawnMock.mockReturnValue(child) @@ -599,6 +635,7 @@ describe('serveOrcaApp', () => { describe('launchOrcaApp', () => { beforeEach(() => { spawnMock.mockReset() + spawnSyncMock.mockReset() }) afterEach(() => { @@ -618,4 +655,51 @@ describe('launchOrcaApp', () => { expect(child.unref).toHaveBeenCalled() }) + + it('adds the extracted-AppImage sandbox fallback for open launches', async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + const getuidDescriptor = Object.getOwnPropertyDescriptor(process, 'getuid') + const root = await mkdtemp(join(tmpdir(), 'orca-open-extracted-appimage-')) + const executable = join(root, 'orca-ide') + + try { + await writeFile(join(root, 'AppRun'), '') + process.env.ORCA_APP_EXECUTABLE = executable + process.env.ELECTRON_RUN_AS_NODE = '1' + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + Object.defineProperty(process, 'getuid', { configurable: true, value: () => 1000 }) + spawnSyncMock.mockReturnValue({ status: 1 }) + const child = new FakeChildProcess() + spawnMock.mockReturnValue(child) + + launchOrcaApp() + + expect(spawnSyncMock).toHaveBeenCalledWith( + 'unshare', + ['-Ur', 'true'], + expect.objectContaining({ stdio: 'ignore', timeout: 2_000 }) + ) + expect(spawnMock).toHaveBeenCalledWith( + executable, + ['--no-sandbox'], + expect.objectContaining({ + detached: true, + stdio: 'ignore', + env: expect.not.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }) + }) + ) + expect(child.unref).toHaveBeenCalledOnce() + } finally { + await rm(root, { recursive: true, force: true }) + delete process.env.ELECTRON_RUN_AS_NODE + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor) + } + if (getuidDescriptor) { + Object.defineProperty(process, 'getuid', getuidDescriptor) + } else { + Reflect.deleteProperty(process, 'getuid') + } + } + }) }) diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index bd7d939be5a..a326ae333f5 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -1,6 +1,8 @@ import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process' -import { resolve } from 'node:path' +import { existsSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' import { StringDecoder } from 'node:string_decoder' +import { runProcessSync } from '../../shared/child-process/run-process' import { SERVE_UPDATE_HANDOFF_PATH_ENV, getServeUpdateHandoffPath @@ -19,6 +21,7 @@ import { import { RuntimeClientError } from './types' const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout' +const USER_NAMESPACE_PROBE_TIMEOUT_MS = 2_000 export function launchOrcaApp(): void { const overrideCommand = process.env.ORCA_OPEN_COMMAND @@ -29,7 +32,7 @@ export function launchOrcaApp(): void { const overrideExecutable = process.env.ORCA_APP_EXECUTABLE if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) { - spawnDetached(overrideExecutable, getExecutableAppArgs(), { + spawnDetached(overrideExecutable, getExecutableAppArgs(overrideExecutable), { ...getExecutableSpawnOptions(overrideExecutable), env: stripElectronRunAsNode(process.env) }) @@ -50,7 +53,7 @@ export function launchOrcaApp(): void { } } - spawnDetached(process.execPath, [], { + spawnDetached(process.execPath, getExecutableAppArgs(process.execPath), { env: stripElectronRunAsNode(process.env) }) return @@ -86,10 +89,7 @@ export function serveOrcaApp( } = {} ): Promise { const executable = resolveForegroundOrcaExecutable() - const childArgs = [...getExecutableAppArgs()] - if (process.env.ORCA_APPIMAGE_NO_SANDBOX === '1') { - childArgs.push('--no-sandbox') - } + const childArgs = [...getExecutableAppArgs(executable)] childArgs.push('--serve') if (args.json) { childArgs.push('--serve-json') @@ -121,7 +121,6 @@ export function serveOrcaApp( ? getServeUpdateHandoffPath(getDefaultUserDataPath()) : null const childEnv = stripElectronRunAsNode(process.env) - delete childEnv.ORCA_APPIMAGE_NO_SANDBOX if (handoffPath) { childEnv[SERVE_UPDATE_HANDOFF_PATH_ENV] = handoffPath } @@ -256,8 +255,34 @@ function waitForRecipeJson(child: ReturnType): Promise { diff --git a/src/cli/runtime/serve-signal-exit-diagnostic.test.ts b/src/cli/runtime/serve-signal-exit-diagnostic.test.ts index f5d348798c3..cc47deec3f7 100644 --- a/src/cli/runtime/serve-signal-exit-diagnostic.test.ts +++ b/src/cli/runtime/serve-signal-exit-diagnostic.test.ts @@ -134,6 +134,36 @@ describe('superviseForegroundServe signal exits', () => { expect(vi.getTimerCount()).toBe(0) }) + it('forwards Linux terminal hangup and removes the listener after exit', async () => { + setPlatform('linux') + const listenersBefore = process.listeners('SIGHUP') + const child = new FakeChildProcess() + const supervised = superviseChild(child) + + expect(process.listeners('SIGHUP')).toHaveLength(listenersBefore.length + 1) + process.emit('SIGHUP', 'SIGHUP') + expect(child.kill).toHaveBeenCalledWith('SIGHUP') + + child.emit('exit', null, 'SIGHUP') + await expect(supervised).resolves.toBe(0) + expect(process.listeners('SIGHUP')).toEqual(listenersBefore) + + const killCallsAfterExit = child.kill.mock.calls.length + process.emit('SIGHUP', 'SIGHUP') + expect(child.kill).toHaveBeenCalledTimes(killCallsAfterExit) + }) + + it('treats a child exit through the caller-forwarded SIGINT as graceful', async () => { + setPlatform('linux') + const child = new FakeChildProcess() + const supervised = superviseChild(child) + + process.emit('SIGINT', 'SIGINT') + child.emit('exit', null, 'SIGINT') + + await expect(supervised).resolves.toBe(0) + }) + it('does not terminate an exited child when update handoff completion fails late', async () => { vi.useFakeTimers() const missingParent = await mkdtemp(join(tmpdir(), 'orca-serve-missing-handoff-')) diff --git a/src/cli/runtime/serve-update-supervisor.ts b/src/cli/runtime/serve-update-supervisor.ts index a5a303dc1a2..f791ef2ae6e 100644 --- a/src/cli/runtime/serve-update-supervisor.ts +++ b/src/cli/runtime/serve-update-supervisor.ts @@ -86,8 +86,8 @@ export async function superviseForegroundServe( handoff?.phase !== 'install-requested' || (child.pid !== undefined && handoff.servingPid !== child.pid) ) { - if (typeof result.code === 'number') { - return result.code + if (typeof result.code === 'number' || result.signalWasForwarded) { + return result.code ?? 0 } throw serveSignalExitError(result.signal) } @@ -114,8 +114,11 @@ function waitForForegroundChild( code: number | null signal: NodeJS.Signals | null readiness: ServeReadiness + signalWasForwarded: boolean }> { return new Promise((resolveWait, reject) => { + const forwardsHangup = process.platform === 'linux' + const forwardedSignals = new Set() let forceKillTimer: ReturnType | null = null let readyTimer: ReturnType | null = null let readiness: ServeReadiness = expected ? 'pending' : 'not-expected' @@ -155,6 +158,7 @@ function waitForForegroundChild( const forwardSignal = (signal: NodeJS.Signals): void => { // A Windows console delivers Ctrl-C to parent and child; child.kill would terminate the child mid-teardown. if (process.platform !== 'win32') { + forwardedSignals.add(signal) child.kill(signal) } forceKillTimer ??= setTimeout(() => child.kill('SIGKILL'), SERVE_CHILD_FORCE_KILL_GRACE_MS) @@ -188,6 +192,9 @@ function waitForForegroundChild( const cleanup = (): void => { process.off('SIGINT', forwardSignal) process.off('SIGTERM', forwardSignal) + if (forwardsHangup) { + process.off('SIGHUP', forwardSignal) + } if (typeof child.off === 'function') { child.off('message', handleMessage) } @@ -200,6 +207,9 @@ function waitForForegroundChild( } process.on('SIGINT', forwardSignal) process.on('SIGTERM', forwardSignal) + if (forwardsHangup) { + process.on('SIGHUP', forwardSignal) + } if (typeof child.on === 'function') { child.on('message', handleMessage) } @@ -213,7 +223,8 @@ function waitForForegroundChild( const handleExit = (code: number | null, signal: NodeJS.Signals | null): void => { childSettled = true cleanup() - void stateWrite.then(() => resolveWait({ code, signal, readiness })) + const signalWasForwarded = signal !== null && forwardedSignals.has(signal) + void stateWrite.then(() => resolveWait({ code, signal, readiness, signalWasForwarded })) } child.once('error', (error) => { childSettled = true diff --git a/src/cli/serve-electron-flag-parity.test.ts b/src/cli/serve-electron-flag-parity.test.ts index 4213d360a41..a4964e1a824 100644 --- a/src/cli/serve-electron-flag-parity.test.ts +++ b/src/cli/serve-electron-flag-parity.test.ts @@ -35,7 +35,7 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite', expect(normalizeServeModeArgv(argv)).toEqual(expected) if (takesValue) { - // The equals form is the other shape `orca serve` accepts, and getServeOptions only reads the next token. + // The equals form is the other shape `orca serve` accepts; normalize it to the internal shape. expect(normalizeServeModeArgv(['/AppRun', 'serve', `--${flag}=value`])).toEqual(expected) } else { // A boolean with an attached value is not a truthy assertion: the CLI reads these as @@ -53,20 +53,19 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite', }) it('emits the same --serve-* names the CLI spawns with and the main process reads', () => { - // Why source text: serveOrcaApp spawns a real process and getServeOptions is not exported, so - // both ends of the contract are only readable statically. Without this leg the rewrite could - // emit a name nothing reads and every behavioural assertion above would still pass. + // Why source text: serveOrcaApp spawns a real process; keeping both names visible here makes + // the rewrite/parser contract fail loudly if either side drifts. const launchSource = readFileSync(join(process.cwd(), 'src/cli/runtime/launch.ts'), 'utf8') - const mainSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-serve.ts'), + const serveOptionsSource = readFileSync( + join(process.cwd(), 'src/main/startup/serve-options.ts'), 'utf8' ) - const start = mainSource.indexOf('export function getServeOptions(') + const start = serveOptionsSource.indexOf('export function getServeOptions(') // Why bound the anchor: an unresolved indexOf slices to EOF and passes vacuously. expect(start).toBeGreaterThanOrEqual(0) - const end = mainSource.indexOf('\n}', start) + const end = serveOptionsSource.indexOf('\n}', start) expect(end).toBeGreaterThan(start) - const getServeOptionsBody = mainSource.slice(start, end) + const getServeOptionsBody = serveOptionsSource.slice(start, end) for (const flag of translatedFlags) { expect(launchSource).toContain(`'--serve-${flag}'`) diff --git a/src/main/cli/cli-command-installation.ts b/src/main/cli/cli-command-installation.ts index fbabc3bf6dd..a3b5e9a0523 100644 --- a/src/main/cli/cli-command-installation.ts +++ b/src/main/cli/cli-command-installation.ts @@ -20,11 +20,9 @@ import { } from './cli-command-filesystem-transaction' import { DEV_LAUNCHER_DIR, LEGACY_LINUX_COMMAND_NAME } from './cli-install-constants' import { buildWindowsForwarder } from './cli-dev-launcher' -import { isMissingError, isPermissionError } from './cli-install-errors' +import { isPermissionError } from './cli-install-errors' import { isPathInsideOrEqual } from './cli-install-path-format' -const STABLE_LEGACY_INSPECTION_ATTEMPTS = 3 - export class CliCommandInstallation extends CliCommandInspection { protected async installSymlink(status: CliInstallStatus): Promise { const commandPath = status.commandPath @@ -198,34 +196,25 @@ export class CliCommandInstallation extends CliCommandInspection { }) | null > { - for (let attempt = 0; attempt < STABLE_LEGACY_INSPECTION_ATTEMPTS; attempt += 1) { - const before = await readEntrySnapshot(commandPath) - if (!before) { - return null - } - let target: string | null = null - try { - target = before.isSymbolicLink ? await readlink(commandPath) : null - } catch (error) { - if (isMissingError(error)) { - continue - } - throw error - } - const after = await readEntrySnapshot(commandPath) - if (after && hasSameSnapshot(before, after)) { - const resolvedTarget = target ? resolve(dirname(commandPath), target) : null - return { - fileSha256: null, - rawSymlinkTarget: target, - snapshot: after, - managed: Boolean( - resolvedTarget && this.isManagedLegacyLinuxTarget(resolvedTarget, launcherPath) - ) - } - } + const inspected = await inspectStableCommand(commandPath, () => + this.inspectSymlink(commandPath, launcherPath) + ) + if (!inspected.snapshot) { + return null + } + const resolvedTarget = inspected.rawSymlinkTarget + ? resolve(dirname(commandPath), inspected.rawSymlinkTarget) + : inspected.status.currentTarget + return { + fileSha256: inspected.fileSha256, + rawSymlinkTarget: inspected.rawSymlinkTarget, + snapshot: inspected.snapshot, + managed: Boolean( + resolvedTarget && + (this.isManagedLegacyLinuxTarget(resolvedTarget, launcherPath) || + (this.appImagePath && resolve(resolvedTarget) === resolve(this.appImagePath))) + ) } - throw new Error(`The command at ${commandPath} changed while Orca inspected it.`) } private async restoreQuarantinedCommand( diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 1a5279644e9..51d5cf05e35 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -370,6 +370,56 @@ describe('CliInstaller', () => { } ) + it.skipIf(process.platform === 'win32')( + 'removes a legacy AppImage wrapper only when it names the current AppImage', + async () => { + const fixture = await makeFixture() + const homePath = join(fixture.root, 'home') + const commandDir = join(homePath, '.local', 'bin') + const legacyCommandPath = join(commandDir, 'orca') + const appImagePath = join(fixture.root, 'Orca.AppImage') + const foreignAppImagePath = join(fixture.root, 'Other.AppImage') + const cacheRootPath = join(fixture.root, 'cache') + await mkdir(commandDir, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { + encoding: 'utf8', + mode: 0o755 + }) + await writeFile(foreignAppImagePath, '#!/usr/bin/env bash\n', { + encoding: 'utf8', + mode: 0o755 + }) + await writeFile(legacyCommandPath, buildLegacyAppImageCliWrapper(appImagePath), { + encoding: 'utf8', + mode: 0o755 + }) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, + homePath, + processPathEnv: commandDir + }) + + await installer.install() + await expect(lstat(legacyCommandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + + await writeFile(legacyCommandPath, buildLegacyAppImageCliWrapper(foreignAppImagePath), { + encoding: 'utf8', + mode: 0o755 + }) + await installer.remove() + await expect(readFile(legacyCommandPath, 'utf8')).resolves.toBe( + buildLegacyAppImageCliWrapper(foreignAppImagePath) + ) + } + ) + // Why: the privilegedRunner is injectable so the EACCES→osascript path can be // exercised in integration without spawning osascript in unit tests. it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index d5c17123ec1..1fb71e3e00f 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -305,6 +305,63 @@ node -e 'console.log(JSON.stringify({ await rm(root, { recursive: true, force: true }) } }) + + itRunsUnixShell('keeps Linux serve on the CLI entrypoint in node mode', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-linux-cli-serve-')) + try { + const appDir = join(root, 'Orca') + const resourcesDir = join(appDir, 'resources') + const launcherDir = join(resourcesDir, 'bin') + const cliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') + const launcherPath = join(launcherDir, 'orca-ide') + const appRunPath = join(appDir, 'AppRun') + const electronPath = join(appDir, 'orca-ide') + const cliPath = join(cliDir, 'index.js') + const statePath = join(root, 'launch-state.json') + + await mkdir(launcherDir, { recursive: true }) + await mkdir(cliDir, { recursive: true }) + await copyFile(linuxLauncherAsset, launcherPath) + await writeFile(cliPath, '', 'utf8') + // An accidental AppRun handoff would skip CLI validation and fail this contract. + await writeFile( + appRunPath, + `#!/usr/bin/env bash +printf 'unexpected AppRun handoff\n' >&2 +exit 97 +`, + { encoding: 'utf8', mode: 0o755 } + ) + await writeFile( + electronPath, + `#!/usr/bin/env node +require('node:fs').writeFileSync(process.env.ORCA_TEST_LAUNCH_STATE, JSON.stringify({ + argv: process.argv.slice(2), + runAsNode: process.env.ELECTRON_RUN_AS_NODE ?? null +})) +`, + { encoding: 'utf8', mode: 0o755 } + ) + + await execFileAsync(launcherPath, ['serve', '--recipe-json', '--project-root', '/tmp/repo'], { + env: { ...process.env, ORCA_TEST_LAUNCH_STATE: statePath } + }) + const payload = JSON.parse(await readFile(statePath, 'utf8')) as { + argv: string[] + runAsNode: string | null + } + expect(payload.argv).toEqual([ + cliPath, + 'serve', + '--recipe-json', + '--project-root', + '/tmp/repo' + ]) + expect(payload.runAsNode).toBe('1') + } finally { + await rm(root, { recursive: true, force: true }) + } + }) }) async function waitForListenerState(path: string): Promise<{ pid: number; port: number }> { diff --git a/src/main/linux-package-downloaded-status.ts b/src/main/linux-package-downloaded-status.ts new file mode 100644 index 00000000000..df0c9b32e6d --- /dev/null +++ b/src/main/linux-package-downloaded-status.ts @@ -0,0 +1,88 @@ +import type { UpdateStatus } from '../shared/update-status-types' +import { + captureLinuxPackageArtifact, + clearTrackedLinuxPackageArtifact, + getTrackedLinuxPackageArtifact +} from './linux-package-update-recovery' +import { getLinuxPackageType } from './linux-update-package-type' +import type { LinuxPackageArtifact } from './linux-package-update-recovery' + +export const LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE = + 'Orca could not verify the installed Linux package format, so it will not install this update automatically. Download the update from the official release page and install it manually.' +export const LINUX_PACKAGE_EXTERNALLY_MANAGED_MESSAGE = + 'This copy of Orca is managed by your system package manager, so Orca cannot install updates itself. Update Orca through your distribution instead.' +export const LINUX_PACKAGE_MANUAL_INSTALL_MESSAGE = + 'Quit Orca before running the system package install command.' +const PACKAGE_METADATA_UNUSABLE_MESSAGE = + 'The downloaded package metadata could not be verified. Quit Orca before downloading and installing the update from the official release page.' + +export function createLinuxPackageManualInstallStatus( + artifact: Pick +): UpdateStatus { + return { + state: 'error', + message: LINUX_PACKAGE_MANUAL_INSTALL_MESSAGE, + recovery: { + kind: 'linux-package-install', + packageType: artifact.packageType, + reason: 'manual-install-required', + version: artifact.version + } + } +} + +export function getRetainedLinuxPackageManualInstallStatus(): UpdateStatus | null { + const artifact = getTrackedLinuxPackageArtifact() + return artifact ? createLinuxPackageManualInstallStatus(artifact) : null +} + +function getActiveDownloadVersion(status: UpdateStatus): string | null { + if (status.state === 'downloading' || status.state === 'downloaded') { + return status.version + } + if (status.state === 'error' && status.recovery?.kind === 'linux-package-install') { + return status.recovery.version + } + return null +} + +export function shouldIgnoreDownloadedUpdateEvent( + status: UpdateStatus, + infoVersion: string, + pendingVersion: string +): boolean { + const activeDownloadVersion = getActiveDownloadVersion(status) + return ( + activeDownloadVersion === null || + infoVersion !== activeDownloadVersion || + (pendingVersion !== '' && infoVersion !== pendingVersion) + ) +} + +export function resolveLinuxPackageDownloadedStatus(info: { + version: string +}): UpdateStatus | null { + const packageType = getLinuxPackageType() + if (packageType === 'non-root') { + return null + } + if (packageType === 'unusable') { + clearTrackedLinuxPackageArtifact() + return { + state: 'error', + message: LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE, + version: info.version, + retryable: false + } + } + const artifact = captureLinuxPackageArtifact(info) + if (!artifact) { + return { + state: 'error', + message: PACKAGE_METADATA_UNUSABLE_MESSAGE, + version: info.version, + retryable: false + } + } + return createLinuxPackageManualInstallStatus(artifact) +} diff --git a/src/main/linux-package-install-command.test.ts b/src/main/linux-package-install-command.test.ts index 368e6620fd5..c76c062bbce 100644 --- a/src/main/linux-package-install-command.test.ts +++ b/src/main/linux-package-install-command.test.ts @@ -252,3 +252,54 @@ describe('buildLinuxPackageInstallCommand', () => { }) }) }) + +describe('hasTrustedPackageManagerFor', () => { + it('accepts a deb host that has dpkg', async () => { + install('/usr/bin/dpkg') + const { hasTrustedPackageManagerFor } = await loadCommandModule() + expect(hasTrustedPackageManagerFor('deb')).toBe(true) + }) + + it('accepts a deb host that has only apt', async () => { + install('/usr/bin/apt') + const { hasTrustedPackageManagerFor } = await loadCommandModule() + expect(hasTrustedPackageManagerFor('deb')).toBe(true) + }) + + // The #17702 case: an Arch rebuild of the .deb inherits the marker but has no deb tooling. + it('rejects a deb marker on a host with only pacman', async () => { + install('/usr/bin/pacman') + install('/usr/bin/sudo') + const { hasTrustedPackageManagerFor } = await loadCommandModule() + expect(hasTrustedPackageManagerFor('deb')).toBe(false) + }) + + it('rejects an rpm marker on a host with only deb tooling', async () => { + install('/usr/bin/dpkg') + install('/usr/bin/apt') + const { hasTrustedPackageManagerFor } = await loadCommandModule() + expect(hasTrustedPackageManagerFor('rpm')).toBe(false) + }) + + it('accepts each rpm-family manager on its own', async () => { + for (const name of ['zypper', 'dnf', 'yum', 'rpm']) { + vi.resetModules() + executables = new Map() + install(`/usr/bin/${name}`) + const { hasTrustedPackageManagerFor } = await loadCommandModule() + expect(hasTrustedPackageManagerFor('rpm')).toBe(true) + } + }) + + it('ignores a package manager outside the trusted directories', async () => { + install('/home/user/.local/bin/dpkg') + const { hasTrustedPackageManagerFor } = await loadCommandModule() + expect(hasTrustedPackageManagerFor('deb')).toBe(false) + }) + + it('ignores a non-executable file at a trusted path', async () => { + install('/usr/bin/dpkg', { mode: 0o644 }) + const { hasTrustedPackageManagerFor } = await loadCommandModule() + expect(hasTrustedPackageManagerFor('deb')).toBe(false) + }) +}) diff --git a/src/main/linux-package-install-command.ts b/src/main/linux-package-install-command.ts index 60a8ecdca00..728ffcd1d92 100644 --- a/src/main/linux-package-install-command.ts +++ b/src/main/linux-package-install-command.ts @@ -52,6 +52,16 @@ export function resolveTrustedExecutable(name: string): string | null { return null } +/** + * Whether this host has any package manager able to install the marker's format. A repackaged + * install (AUR, Nix, a container rebuild) inherits the `package-type` marker from the .deb/.rpm it + * was built from, so the marker alone never proves the host can act on it. + */ +export function hasTrustedPackageManagerFor(packageType: LinuxRootPackageType): boolean { + const candidates = packageType === 'deb' ? DEB_PACKAGE_MANAGERS : RPM_PACKAGE_MANAGERS + return candidates.some((candidate) => resolveTrustedExecutable(candidate.name) !== null) +} + /** * Builds the interactive command the user pastes into their own terminal. Every token except the * package path is a fixed literal, and the path is POSIX-single-quoted — Orca never runs this. diff --git a/src/main/linux-package-install-diagnostic.test.ts b/src/main/linux-package-install-diagnostic.test.ts index 5b8e1b1c77b..14e82a0763b 100644 --- a/src/main/linux-package-install-diagnostic.test.ts +++ b/src/main/linux-package-install-diagnostic.test.ts @@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as DiagnosticModule from './linux-package-install-diagnostic' const ESC = String.fromCharCode(27) - let diagnostic: typeof DiagnosticModule beforeEach(async () => { @@ -20,64 +19,35 @@ afterEach(() => { }) describe('redactLinuxPackageInstallText', () => { - it('strips ANSI escape sequences', () => { - const text = `${ESC}[31mdpkg: error${ESC}[0m processing` - expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe('dpkg: error processing') + it('strips terminal escapes and control characters', () => { + const text = `${ESC}[?25l${ESC}[31mdpkg:\r\n\terror\u0000${ESC}[0m${ESC}[?25h` + expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe('dpkg: error') }) - it('strips ANSI sequences with private and intermediate bytes', () => { - const text = `${ESC}[?25lworking${ESC}[?25h` - expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe('working') + it('strips string escape payloads and remaining two-byte escapes', () => { + const BEL = String.fromCharCode(7) + const text = + `${ESC}]8;;https://tracker.invalid/report${BEL}dpkg${ESC}]8;;${BEL} ` + + `${ESC}P1;2|payload${ESC}\\failed${ESC}c` + expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe('dpkg failed') }) - it('replaces control characters and collapses whitespace', () => { - const text = `line one\r\n\tline\u0000two spaced\u007f` - expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe('line one line two spaced') - }) - - it('replaces the cached package path with a placeholder', () => { - const packagePath = '/home/user/.cache/orca-updater/Orca-1.2.3.deb' - const text = `dpkg: error processing ${packagePath} (--install)` - expect(diagnostic.redactLinuxPackageInstallText(text, packagePath)).toBe( - 'dpkg: error processing (--install)' - ) - }) - - it('replaces every occurrence of the package path', () => { - const packagePath = '/tmp/orca.deb' - const text = `${packagePath} failed; retry ${packagePath}` - expect(diagnostic.redactLinuxPackageInstallText(text, packagePath)).toBe( - ' failed; retry ' - ) - }) - - it('replaces the home directory with a placeholder', () => { - const home = os.homedir() - const text = `could not read ${home}/.config/orca/settings.json` - expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe( - 'could not read /.config/orca/settings.json' - ) - }) - - it('prefers the package placeholder for a path inside the home directory', () => { + it('replaces every cached package-path occurrence before the home directory', () => { const home = os.homedir() const packagePath = `${home}/.cache/orca-updater/Orca-1.2.3.deb` - expect(diagnostic.redactLinuxPackageInstallText(`install ${packagePath}`, packagePath)).toBe( - 'install ' + const text = `${packagePath} failed; retry ${packagePath}; config ${home}/.config/orca` + expect(diagnostic.redactLinuxPackageInstallText(text, packagePath)).toBe( + ' failed; retry ; config /.config/orca' ) }) - it('replaces the bare username with a placeholder', () => { - // Why: sudo names the user without any path around it, so the rule never sees it. + it('replaces a bare username without corrupting short names', () => { vi.spyOn(os, 'userInfo').mockReturnValue({ username: 'devuser' } as os.UserInfo) expect( diagnostic.redactLinuxPackageInstallText('devuser is not in the sudoers file', null) ).toBe(' is not in the sudoers file') - }) - it('leaves a username shorter than three characters alone', () => { - // Short names would corrupt unrelated words. - vi.spyOn(os, 'userInfo').mockReturnValue({ username: 'ci' } as os.UserInfo) + vi.mocked(os.userInfo).mockReturnValue({ username: 'ci' } as os.UserInfo) expect(diagnostic.redactLinuxPackageInstallText('ci: incident in circuit', null)).toBe( 'ci: incident in circuit' ) @@ -92,34 +62,23 @@ describe('redactLinuxPackageInstallText', () => { ) }) - it('truncates to 1024 characters', () => { - const result = diagnostic.redactLinuxPackageInstallText('a'.repeat(2000), null) - expect(result).toHaveLength(1024) + it('bounds the result at 1024 characters', () => { + expect(diagnostic.redactLinuxPackageInstallText('a'.repeat(2_000), null)).toHaveLength(1_024) + expect(diagnostic.redactLinuxPackageInstallText('a'.repeat(1_024), null)).toHaveLength(1_024) }) - it('keeps text at exactly the limit', () => { - const result = diagnostic.redactLinuxPackageInstallText('a'.repeat(1024), null) - expect(result).toHaveLength(1024) - }) - - it('returns null for empty and whitespace-only input', () => { + it('returns null when no visible text remains', () => { expect(diagnostic.redactLinuxPackageInstallText('', null)).toBeNull() expect(diagnostic.redactLinuxPackageInstallText(' \n\t ', null)).toBeNull() expect(diagnostic.redactLinuxPackageInstallText(`${ESC}[0m`, null)).toBeNull() - }) - - it('returns null for null and undefined', () => { expect(diagnostic.redactLinuxPackageInstallText(null, null)).toBeNull() expect(diagnostic.redactLinuxPackageInstallText(undefined, null)).toBeNull() }) - it('uses the message of an Error', () => { + it('normalizes errors, objects, and primitives', () => { expect(diagnostic.redactLinuxPackageInstallText(new Error('pkexec failed'), null)).toBe( 'pkexec failed' ) - }) - - it('serializes plain objects and other primitives', () => { expect(diagnostic.redactLinuxPackageInstallText({ code: 127 }, null)).toBe('{"code":127}') expect(diagnostic.redactLinuxPackageInstallText(127, null)).toBe('127') }) @@ -130,208 +89,22 @@ describe('redactLinuxPackageInstallText', () => { expect(diagnostic.redactLinuxPackageInstallText(circular, null)).toBeNull() }) - it('strips an OSC hyperlink along with its URL payload', () => { - const BEL = String.fromCharCode(7) - const text = `${ESC}]8;;https://tracker.invalid/report${BEL}dpkg: error${ESC}]8;;${BEL} processing` - expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe('dpkg: error processing') - }) - - it('strips a string-terminated DCS sequence and a two-byte escape', () => { - const text = `${ESC}P1;2|payload${ESC}\\dpkg${ESC}c: error` - expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe('dpkg: error') - }) - it('ignores an empty package path', () => { expect(diagnostic.redactLinuxPackageInstallText('plain output', '')).toBe('plain output') }) }) describe('createUpdaterDiagnosticLogger', () => { - it('retains redacted error output while capturing', () => { - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture('/tmp/orca.deb') - logger.error(`${ESC}[31mpkexec: /tmp/orca.deb not authorized${ESC}[0m`) - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toEqual({ - message: 'pkexec: not authorized', - reason: 'authentication-denied' - }) - }) - - it('ignores non-error levels', () => { - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture(null) - logger.info('downloading') - logger.warn('retrying') - logger.debug('verbose') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toBeNull() - }) - - it('still forwards every level to the console', () => { + it('forwards every updater level to the matching console method', () => { const logger = diagnostic.createUpdaterDiagnosticLogger() logger.info('a') logger.warn('b') logger.error('c') logger.debug('d') + expect(console.info).toHaveBeenCalledWith('[autoUpdater]', 'a') expect(console.warn).toHaveBeenCalledWith('[autoUpdater]', 'b') expect(console.error).toHaveBeenCalledWith('[autoUpdater]', 'c') expect(console.debug).toHaveBeenCalledWith('[autoUpdater]', 'd') }) - - it('retains nothing outside a capture window', () => { - const logger = diagnostic.createUpdaterDiagnosticLogger() - logger.error('unrelated failure') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toBeNull() - }) - - it('keeps the last usable error and ignores empty ones', () => { - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture(null) - logger.error('first failure') - logger.error('second failure') - logger.error('') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toEqual({ - message: 'second failure', - reason: 'package-install-failed' - }) - }) - - it('hands back and clears the diagnostic when capture ends', () => { - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture(null) - logger.error('request dismissed') - expect(diagnostic.endLinuxPackageInstallDiagnosticCapture()).toEqual({ - message: 'request dismissed', - reason: 'authentication-denied' - }) - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toBeNull() - logger.error('later noise') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toBeNull() - }) - - it('drops a previous attempt when a new capture begins', () => { - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture('/tmp/a.deb') - logger.error('old failure') - diagnostic.beginLinuxPackageInstallDiagnosticCapture('/tmp/b.deb') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toBeNull() - logger.error('new failure at /tmp/b.deb') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toEqual({ - message: 'new failure at ', - reason: 'package-install-failed' - }) - }) - - it('classifies the original output, not the redacted text', () => { - // A user named "age" turns "agent" into "nt", which would hide the missing polkit agent. - vi.spyOn(os, 'userInfo').mockReturnValue({ username: 'age' } as os.UserInfo) - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture(null) - logger.error('Error executing command as another user: No authentication agent found for age.') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toEqual({ - message: - 'Error executing command as another user: No authentication nt found for .', - reason: 'authentication-agent-unavailable' - }) - }) - - it('keeps a specific verdict when a generic line follows it', () => { - // electron-updater logs the polkit output first, then "Command failed, exited with code 126". - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture(null) - logger.error('polkit-agent-helper-1: no authentication agent found') - logger.error('Command failed, exited with code 126') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toEqual({ - message: 'polkit-agent-helper-1: no authentication agent found', - reason: 'authentication-agent-unavailable' - }) - }) - - it('lets a later specific line replace an earlier one', () => { - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture(null) - logger.error('no authentication agent') - logger.error('request dismissed') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toEqual({ - message: 'request dismissed', - reason: 'authentication-denied' - }) - }) - - it('returns null from an empty capture window', () => { - diagnostic.beginLinuxPackageInstallDiagnosticCapture(null) - expect(diagnostic.endLinuxPackageInstallDiagnosticCapture()).toBeNull() - }) -}) - -describe('classifyLinuxPackageInstallFailure', () => { - it('reports a missing authentication agent', () => { - for (const text of [ - 'Error executing command as another user: No authentication agent found.', - 'polkit-agent-helper: agent not found', - 'polkit agent was not found' - ]) { - expect(diagnostic.classifyLinuxPackageInstallFailure(text)).toBe( - 'authentication-agent-unavailable' - ) - } - }) - - it('reports a denied authentication', () => { - for (const text of [ - 'Error executing command as another user: Request dismissed', - 'polkit: Authentication failed', - 'Error executing command as another user: Not authorized', - 'Authorization failed for org.freedesktop.policykit.exec', - 'pkexec: 3 incorrect password attempts' - ]) { - expect(diagnostic.classifyLinuxPackageInstallFailure(text)).toBe('authentication-denied') - } - }) - - it('prefers the agent reason when both patterns appear', () => { - expect( - diagnostic.classifyLinuxPackageInstallFailure( - 'No authentication agent found; authentication failed' - ) - ).toBe('authentication-agent-unavailable') - }) - - it('falls back to a generic failure for localized output', () => { - expect( - diagnostic.classifyLinuxPackageInstallFailure( - "Erreur lors de l'exécution : aucun agent d'authentification trouvé" - ) - ).toBe('package-install-failed') - }) - - it('falls back to a generic failure for unrecognized and missing output', () => { - expect(diagnostic.classifyLinuxPackageInstallFailure('dpkg: dependency problems')).toBe( - 'package-install-failed' - ) - expect(diagnostic.classifyLinuxPackageInstallFailure(null)).toBe('package-install-failed') - expect(diagnostic.classifyLinuxPackageInstallFailure('')).toBe('package-install-failed') - }) -}) - -describe('parseLinuxPackageInstallExitCode', () => { - it('parses electron-updater exit-code messages', () => { - expect( - diagnostic.parseLinuxPackageInstallExitCode( - new Error('Command /usr/bin/pkexec exited with code 127') - ) - ).toBe(127) - expect(diagnostic.parseLinuxPackageInstallExitCode('Command failed exited with code 0')).toBe(0) - }) - - it('parses a negative code and matches case-insensitively', () => { - expect(diagnostic.parseLinuxPackageInstallExitCode('Exited With Code -1')).toBe(-1) - }) - - it('returns null when no code is present', () => { - expect(diagnostic.parseLinuxPackageInstallExitCode(new Error('spawn ENOENT'))).toBeNull() - expect(diagnostic.parseLinuxPackageInstallExitCode('exited with code abc')).toBeNull() - expect(diagnostic.parseLinuxPackageInstallExitCode(null)).toBeNull() - expect(diagnostic.parseLinuxPackageInstallExitCode({ code: 127 })).toBeNull() - }) }) diff --git a/src/main/linux-package-install-diagnostic.ts b/src/main/linux-package-install-diagnostic.ts index bdef7c5450e..c65c62679fa 100644 --- a/src/main/linux-package-install-diagnostic.ts +++ b/src/main/linux-package-install-diagnostic.ts @@ -1,16 +1,7 @@ import os from 'node:os' -import type { LinuxPackageInstallFailureReason } from '../shared/update-status-types' - -/** The redacted text shown locally, paired with the reason classified from the ORIGINAL output. */ -export type LinuxPackageInstallDiagnostic = { - message: string - reason: LinuxPackageInstallFailureReason -} const MAX_DIAGNOSTIC_LENGTH = 1_024 -// Built via RegExp so the source carries no raw control bytes. Alternatives in order: CSI; then the -// string sequences (OSC/DCS/PM/APC/SOS), whose payload — an OSC 8 hyperlink URL, say — must be -// dropped with the introducer rather than left behind; then any remaining two-byte escape. +// Alternatives in order: CSI; string sequences whose payload must also be dropped; remaining two-byte escapes. const ANSI_ESCAPE = new RegExp( [ String.raw`\u001b\[[0-9;?]*[ -/]*[@-~]`, @@ -20,28 +11,7 @@ const ANSI_ESCAPE = new RegExp( 'g' ) const CONTROL_CHARACTERS = new RegExp(String.raw`[\u0000-\u001f\u007f]`, 'g') - -// Why: pkexec/polkit print these before any package manager runs; matching them keeps the UI from -// blaming dpkg for an authentication problem. Anything else stays generic on purpose. -const AGENT_UNAVAILABLE_PATTERNS = [ - /no authentication agent/i, - /polkit.{0,20}agent.{0,20}not found/i -] -const AUTHENTICATION_DENIED_PATTERNS = [ - /request dismissed/i, - /authentication failed/i, - /not authorized/i, - /authorization failed/i, - /incorrect password attempt/i -] - -let capturing = false -let retainedDiagnostic: string | null = null -// Why: classification must read the ORIGINAL text. Redaction can rewrite a pattern word — a user -// named "age" turns "No authentication agent found" into "No authentication nt" — which would -// silently downgrade a missing-agent failure to the generic reason. -let retainedReason: LinuxPackageInstallFailureReason | null = null -let redactedPackagePath: string | null = null +const MIN_REDACTED_USERNAME_LENGTH = 3 function stringifyLoggerValue(value: unknown): string { if (typeof value === 'string') { @@ -63,9 +33,6 @@ function stringifyLoggerValue(value: unknown): string { return String(value) } -// Short names would corrupt unrelated words, so they are left alone. -const MIN_REDACTED_USERNAME_LENGTH = 3 - function readUserName(): string | null { try { return os.userInfo().username || null @@ -75,16 +42,10 @@ function readUserName(): string | null { } function replaceAllLiteral(text: string, needle: string, replacement: string): string { - if (needle.length === 0) { - return text - } - return text.split(needle).join(replacement) + return needle.length === 0 ? text : text.split(needle).join(replacement) } -/** - * Turns arbitrary updater/child output into text safe to show locally: no ANSI, no control bytes, - * no home directory, no cached package path, bounded length. - */ +/** Removes terminal escapes and local identity from updater text before showing it in the UI. */ export function redactLinuxPackageInstallText( value: unknown, packagePath: string | null @@ -101,7 +62,7 @@ export function redactLinuxPackageInstallText( if (homeDir) { text = replaceAllLiteral(text, homeDir, '') } - // Why: sudo reports " is not in the sudoers file", which the home-directory rule cannot catch. + // Why: privilege tools can name the user without including their home directory. const userName = readUserName() if (userName && userName.length >= MIN_REDACTED_USERNAME_LENGTH) { text = replaceAllLiteral(text, userName, '') @@ -113,97 +74,16 @@ export function redactLinuxPackageInstallText( return text.length > MAX_DIAGNOSTIC_LENGTH ? text.slice(0, MAX_DIAGNOSTIC_LENGTH) : text } -/** Starts retaining redacted error output for one native root-package install attempt. */ -export function beginLinuxPackageInstallDiagnosticCapture(packagePath: string | null): void { - capturing = true - retainedDiagnostic = null - retainedReason = null - redactedPackagePath = packagePath -} - -/** Stops capture and hands back the retained diagnostic, clearing it for the next attempt. */ -export function endLinuxPackageInstallDiagnosticCapture(): LinuxPackageInstallDiagnostic | null { - const captured = getLinuxPackageInstallDiagnostic() - capturing = false - retainedDiagnostic = null - retainedReason = null - redactedPackagePath = null - return captured -} - -export function getLinuxPackageInstallDiagnostic(): LinuxPackageInstallDiagnostic | null { - return retainedDiagnostic === null - ? null - : { message: retainedDiagnostic, reason: retainedReason ?? 'package-install-failed' } -} - -function recordLinuxPackageInstallDiagnostic(value: unknown): void { - if (!capturing) { - return - } - const raw = stringifyLoggerValue(value) - const redacted = redactLinuxPackageInstallText(raw, redactedPackagePath) - if (!redacted) { - return - } - const reason = classifyLinuxPackageInstallFailure(raw) - // Why: electron-updater logs the polkit output first and a generic "exited with code N" line after, - // so a later generic line must not erase the specific verdict the card branches on. - if ( - reason === 'package-install-failed' && - retainedReason !== null && - retainedReason !== 'package-install-failed' - ) { - return - } - retainedDiagnostic = redacted - retainedReason = reason -} - -/** - * The `autoUpdater.logger`. Every level still reaches the same console method; only error output - * during an in-flight root-package install is retained, redacted, for the recovery card. - */ export function createUpdaterDiagnosticLogger(): { - info: (m: unknown) => void - warn: (m: unknown) => void - error: (m: unknown) => void - debug: (m: unknown) => void + info: (message: unknown) => void + warn: (message: unknown) => void + error: (message: unknown) => void + debug: (message: unknown) => void } { return { - info: (m: unknown) => console.info('[autoUpdater]', m), - warn: (m: unknown) => console.warn('[autoUpdater]', m), - error: (m: unknown) => { - recordLinuxPackageInstallDiagnostic(m) - console.error('[autoUpdater]', m) - }, - debug: (m: unknown) => console.debug('[autoUpdater]', m) + info: (message) => console.info('[autoUpdater]', message), + warn: (message) => console.warn('[autoUpdater]', message), + error: (message) => console.error('[autoUpdater]', message), + debug: (message) => console.debug('[autoUpdater]', message) } } - -export function classifyLinuxPackageInstallFailure( - diagnostic: string | null -): LinuxPackageInstallFailureReason { - if (!diagnostic) { - return 'package-install-failed' - } - if (AGENT_UNAVAILABLE_PATTERNS.some((pattern) => pattern.test(diagnostic))) { - return 'authentication-agent-unavailable' - } - if (AUTHENTICATION_DENIED_PATTERNS.some((pattern) => pattern.test(diagnostic))) { - return 'authentication-denied' - } - // Localized or unrecognized output must never be reported as a missing agent. - return 'package-install-failed' -} - -/** Parses the child exit status out of electron-updater's `Command exited with code `. */ -export function parseLinuxPackageInstallExitCode(error: unknown): number | null { - const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '' - const match = /exited with code (-?\d{1,5})\b/i.exec(message) - if (!match) { - return null - } - const code = Number.parseInt(match[1], 10) - return Number.isFinite(code) ? code : null -} diff --git a/src/main/linux-package-update-recovery.test.ts b/src/main/linux-package-update-recovery.test.ts index ec2738b823b..859e80e74b2 100644 --- a/src/main/linux-package-update-recovery.test.ts +++ b/src/main/linux-package-update-recovery.test.ts @@ -5,18 +5,14 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeFs from 'node:fs' import type { LinuxPackageInstallRecovery } from '../shared/update-status-types' +import type { LinuxPackageArtifact } from './linux-package-update-recovery' import type * as RecoveryModule from './linux-package-update-recovery' -const { showItemInFolderMock, getPackageTypeMock, buildCommandMock, hashPasses } = vi.hoisted( - () => ({ - showItemInFolderMock: vi.fn(), - getPackageTypeMock: vi.fn(), - buildCommandMock: vi.fn(), - hashPasses: { count: 0 } - }) -) - -vi.mock('electron', () => ({ shell: { showItemInFolder: showItemInFolderMock } })) +const { getPackageTypeMock, buildCommandMock, hashPasses } = vi.hoisted(() => ({ + getPackageTypeMock: vi.fn(), + buildCommandMock: vi.fn(), + hashPasses: { count: 0 } +})) vi.mock('./linux-update-package-type', () => ({ getLinuxRootPackageType: getPackageTypeMock })) @@ -67,9 +63,9 @@ async function writePackage(name: string, contents = PAYLOAD): Promise { } /** Captures a well-formed downloaded event unless a field is overridden. */ -function capture(overrides: Record = {}): void { +function capture(overrides: Record = {}): LinuxPackageArtifact | null { const downloadedFile = (overrides.downloadedFile ?? path.join(downloadDir, 'orca.deb')) as string - recovery.captureLinuxPackageArtifact({ + return recovery.captureLinuxPackageArtifact({ version: VERSION, files: [{ url: path.basename(downloadedFile), sha512: SHA512 }], ...overrides, @@ -80,7 +76,6 @@ function capture(overrides: Record = {}): void { beforeEach(async () => { vi.resetModules() hashPasses.count = 0 - showItemInFolderMock.mockReset() getPackageTypeMock.mockReset().mockReturnValue('deb') buildCommandMock.mockReset().mockReturnValue({ ok: true, command: 'installed command' }) tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'orca-recovery-')) @@ -103,13 +98,14 @@ afterEach(async () => { describe('captureLinuxPackageArtifact', () => { it('retains the downloaded package with the digest from the event metadata', () => { - capture() - expect(recovery.getTrackedLinuxPackageArtifact()).toEqual({ + const artifact = { packageType: 'deb', version: VERSION, path: path.join(downloadDir, 'orca.deb'), sha512: SHA512 - }) + } satisfies LinuxPackageArtifact + expect(capture()).toEqual(artifact) + expect(recovery.getTrackedLinuxPackageArtifact()).toEqual(artifact) }) it('ignores the event on a build that is not a root package', () => { @@ -221,7 +217,7 @@ describe('captureLinuxPackageArtifact', () => { it('keeps a retained artifact when a later event carries a malformed digest', () => { capture() - capture({ files: [{ url: 'orca.deb', sha512: 'not-a-digest' }] }) + expect(capture({ files: [{ url: 'orca.deb', sha512: 'not-a-digest' }] })).toBeNull() expect(recovery.getTrackedLinuxPackageArtifact()?.sha512).toBe(SHA512) }) @@ -596,7 +592,7 @@ describePosix('validation coalescing', () => { capture() const [first, second] = await Promise.all([ recovery.resolveLinuxPackageInstallInstructions(recoveryFor()), - recovery.revealLinuxPackage(recoveryFor()) + recovery.resolveLinuxPackageRevealTarget(recoveryFor()) ]) expect(first.ok).toBe(true) expect(second.ok).toBe(true) @@ -611,31 +607,6 @@ describePosix('validation coalescing', () => { expect(hashPasses.count).toBe(2) }) - // Why: a verdict handed to a root package manager must cover the bytes as of the click, not the - // bytes a Copy click started streaming seconds earlier. - it('never reuses an in-flight pass for a pre-install re-proof', async () => { - await writePackage('orca.deb') - capture() - const artifact = recovery.getTrackedLinuxPackageArtifact() - const copyPass = recovery.resolveLinuxPackageInstallInstructions(recoveryFor()) - const installPass = recovery.revalidateLinuxPackageForInstall(artifact!) - - await expect(installPass).resolves.toEqual({ ok: true }) - await expect(copyPass).resolves.toMatchObject({ ok: true }) - expect(hashPasses.count).toBe(2) - }) - - it('lets a later Copy click join the pre-install pass', async () => { - await writePackage('orca.deb') - capture() - const artifact = recovery.getTrackedLinuxPackageArtifact() - const installPass = recovery.revalidateLinuxPackageForInstall(artifact!) - const copyPass = recovery.resolveLinuxPackageInstallInstructions(recoveryFor()) - - await Promise.all([installPass, copyPass]) - expect(hashPasses.count).toBe(1) - }) - it('does not reuse an in-flight pass for a different artifact', async () => { await writePackage('orca.deb') await writePackage('orca-next.deb') @@ -652,77 +623,43 @@ describePosix('validation coalescing', () => { await Promise.all([first, second]) expect(hashPasses.count).toBe(2) }) -}) -describePosix('revalidateLinuxPackageForInstall', () => { - it('proves the retained package still matches its release digest', async () => { + it('starts a fresh proof when the same package is captured again', async () => { await writePackage('orca.deb') capture() - const artifact = recovery.getTrackedLinuxPackageArtifact() - await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({ - ok: true - }) - }) - - it('rejects a package swapped after the download was verified', async () => { - await writePackage('orca.deb') + const first = recovery.resolveLinuxPackageInstallInstructions(recoveryFor()) capture() - const artifact = recovery.getTrackedLinuxPackageArtifact() - await writePackage('orca.deb', 'attacker supplied package') - await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({ - ok: false, - reason: 'hash-mismatch' - }) - }) + const second = recovery.resolveLinuxPackageInstallInstructions(recoveryFor()) - it('reports a package deleted from the cache as missing', async () => { - const filePath = await writePackage('orca.deb') - capture() - const artifact = recovery.getTrackedLinuxPackageArtifact() - await fsp.rm(filePath) - await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({ - ok: false, - reason: 'missing' - }) + await Promise.all([first, second]) + expect(hashPasses.count).toBe(2) }) }) -describePosix('revealLinuxPackage', () => { - it('reveals a verified package on the machine that owns it', async () => { +describePosix('resolveLinuxPackageRevealTarget', () => { + it('returns the verified package path', async () => { const filePath = await writePackage('orca.deb') capture() - await expect(recovery.revealLinuxPackage(recoveryFor())).resolves.toEqual({ ok: true }) - expect(showItemInFolderMock).toHaveBeenCalledWith(filePath) + await expect(recovery.resolveLinuxPackageRevealTarget(recoveryFor())).resolves.toEqual({ + ok: true, + path: filePath + }) }) - it('does not reveal a package that fails validation', async () => { + it('rejects a package that fails validation', async () => { const filePath = await writePackage('orca.deb') capture() await fsp.writeFile(filePath, 'tampered payload') - await expect(recovery.revealLinuxPackage(recoveryFor())).resolves.toEqual({ + await expect(recovery.resolveLinuxPackageRevealTarget(recoveryFor())).resolves.toEqual({ ok: false, reason: 'hash-mismatch' }) - expect(showItemInFolderMock).not.toHaveBeenCalled() }) - it('reports read-failed when the desktop file manager throws', async () => { - await writePackage('orca.deb') - capture() - showItemInFolderMock.mockImplementation(() => { - throw new Error('no file manager available') - }) - await expect(recovery.revealLinuxPackage(recoveryFor())).resolves.toEqual({ - ok: false, - reason: 'read-failed' - }) - }) - - it('does not reveal anything without a retained artifact', async () => { - await expect(recovery.revealLinuxPackage(recoveryFor())).resolves.toEqual({ + it('returns missing without a retained artifact', async () => { + await expect(recovery.resolveLinuxPackageRevealTarget(recoveryFor())).resolves.toEqual({ ok: false, reason: 'missing' }) - expect(showItemInFolderMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/linux-package-update-recovery.ts b/src/main/linux-package-update-recovery.ts index e5269a2077b..e0bf530bb65 100644 --- a/src/main/linux-package-update-recovery.ts +++ b/src/main/linux-package-update-recovery.ts @@ -3,7 +3,6 @@ import { createReadStream } from 'node:fs' import fsp from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { shell } from 'electron' import type { LinuxPackageInstallRecovery, LinuxRootPackageType @@ -36,7 +35,7 @@ export type LinuxPackageInstructionsResult = | { ok: false; reason: LinuxPackageRecoveryUnavailableReason } export type LinuxPackageRevealResult = - | { ok: true } + | { ok: true; path: string } | { ok: false; reason: LinuxPackageRecoveryUnavailableReason } type ValidationResult = @@ -45,7 +44,7 @@ type ValidationResult = let trackedArtifact: LinuxPackageArtifact | null = null // Why: the renderer debounces clicks, but the IPC boundary must not allow parallel hashing of a 160 MB package. -let inFlightValidation: { key: string; promise: Promise } | null = null +const inFlightValidations = new WeakMap>() export function getTrackedLinuxPackageArtifact(): LinuxPackageArtifact | null { return trackedArtifact @@ -117,24 +116,24 @@ function resolveExpectedSha512( } /** - * Retains the verified download so a failed root-package install stays recoverable without paying - * for the 160 MB transfer again. Only the in-memory event metadata is trusted for the digest. + * Retains the downloaded package and its release digest so manual actions do not repeat the 160 MB + * transfer. Only the in-memory event metadata is trusted for the digest. */ -export function captureLinuxPackageArtifact(event: unknown): void { +export function captureLinuxPackageArtifact(event: unknown): LinuxPackageArtifact | null { const packageType = getLinuxRootPackageType() if (!packageType) { - return + return null } const downloadedFile = (event as { downloadedFile?: unknown })?.downloadedFile const version = (event as { version?: unknown })?.version if (typeof downloadedFile !== 'string' || !path.isAbsolute(downloadedFile)) { - return + return null } if (!downloadedFile.toLowerCase().endsWith(`.${packageType}`)) { - return + return null } if (typeof version !== 'string' || version.length === 0) { - return + return null } const sha512 = resolveExpectedSha512( (event as { files?: unknown })?.files, @@ -147,9 +146,11 @@ export function captureLinuxPackageArtifact(event: unknown): void { // Why: an unresolvable digest only means THIS event cannot arm recovery. A previously retained // artifact carries its own digest and is revalidated on every use, so dropping it would force a // needless 160 MB redownload of a file that is still on disk and still verifiable. - return + return null } - trackedArtifact = { packageType, version, path: downloadedFile, sha512 } + const artifact = { packageType, version, path: downloadedFile, sha512 } + trackedArtifact = artifact + return artifact } function isInsideDirectory(root: string, target: string): boolean { @@ -244,26 +245,18 @@ async function validateArtifact(artifact: LinuxPackageArtifact): Promise { - const key = `${artifact.packageType}:${artifact.version}:${artifact.path}:${artifact.sha512}` - if (!options?.fresh && inFlightValidation?.key === key) { - return inFlightValidation.promise +/** Hashes the exact captured artifact, joining only that capture's in-flight proof. */ +function runValidation(artifact: LinuxPackageArtifact): Promise { + const inFlight = inFlightValidations.get(artifact) + if (inFlight) { + return inFlight } const promise: Promise = validateArtifact(artifact).finally(() => { - // Why: identity, not key — a fresh install pass may already have replaced this entry. - if (inFlightValidation?.promise === promise) { - inFlightValidation = null + if (inFlightValidations.get(artifact) === promise) { + inFlightValidations.delete(artifact) } }) - inFlightValidation = { key, promise } + inFlightValidations.set(artifact, promise) return promise } @@ -305,38 +298,12 @@ export async function resolveLinuxPackageInstallInstructions( } } -/** - * Re-proves the retained package immediately before the privileged installer consumes it. - * - * The cache path is user-writable, so a digest checked when the download finished says nothing - * about the bytes `dpkg -i` will read minutes later. Re-hashing here does not close the race — - * only an immutable handoff would — but it shrinks the window from "since the download" to - * "since this call", and it catches the artifact being swapped or deleted outright. Takes the - * artifact rather than a recovery so both the retry and the plain "Restart to Update" install - * are covered. - */ -export async function revalidateLinuxPackageForInstall( - artifact: LinuxPackageArtifact -): Promise<{ ok: true } | { ok: false; reason: LinuxPackageRecoveryUnavailableReason }> { - const validation = await runValidation(artifact, { fresh: true }) - return validation.ok ? { ok: true } : { ok: false, reason: validation.reason } -} - -export async function revealLinuxPackage( +export async function resolveLinuxPackageRevealTarget( recovery: LinuxPackageInstallRecovery ): Promise { const validation = await validateTrackedArtifact(recovery) if (!validation.ok) { return validation } - // Why: this cache path must not travel through the workspace shell:openPath API, whose execution - // host can be an SSH or WSL machine rather than the one that owns the installed package. - try { - shell.showItemInFolder(validation.artifact.path) - } catch { - // Why: every other failure in this module reports through {ok:false}; a raw throw here would - // reject the IPC with an unredacted message and skip the lifecycle record. - return { ok: false, reason: 'read-failed' } - } - return { ok: true } + return { ok: true, path: validation.artifact.path } } diff --git a/src/main/linux-update-package-type.test.ts b/src/main/linux-update-package-type.test.ts index 2a2858c2c68..7453c727bb8 100644 --- a/src/main/linux-update-package-type.test.ts +++ b/src/main/linux-update-package-type.test.ts @@ -2,16 +2,38 @@ import fsp from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { LinuxPackageType, LinuxRootPackageType } from './linux-update-package-type' -const { appMock } = vi.hoisted(() => ({ appMock: { isPackaged: true } })) +const { appMock, hasTrustedPackageManagerForMock } = vi.hoisted(() => ({ + appMock: { isPackaged: true }, + hasTrustedPackageManagerForMock: vi.fn(() => true) +})) vi.mock('electron', () => ({ app: appMock })) +vi.mock('./linux-package-install-command', () => ({ + hasTrustedPackageManagerFor: hasTrustedPackageManagerForMock +})) const originalPlatform = process.platform const originalResourcesPath = process.resourcesPath as string | undefined +const originalExecPath = process.execPath +const originalAppImage = process.env.APPIMAGE +const originalAppDir = process.env.APPDIR let resourcesDir: string +type PackageTypeModule = { + getLinuxPackageType: () => LinuxPackageType + getLinuxRootPackageType: () => LinuxRootPackageType | null + isExternallyManagedLinuxInstall: () => boolean + isLegacyAppImageRuntimeIdentity: (identity: { + appImagePath: unknown + appDirPath: unknown + execPath: unknown + resourcesPath: unknown + }) => boolean +} + function setPlatform(platform: string): void { Object.defineProperty(process, 'platform', { configurable: true, value: platform }) } @@ -20,19 +42,25 @@ function setResourcesPath(value: unknown): void { Object.defineProperty(process, 'resourcesPath', { configurable: true, value }) } +function setExecPath(value: string): void { + Object.defineProperty(process, 'execPath', { configurable: true, value }) +} + async function writeMarker(contents: string): Promise { await fsp.writeFile(path.join(resourcesDir, 'package-type'), contents, 'utf8') } -async function loadPackageType(): Promise<() => 'deb' | 'rpm' | null> { - const module = await import('./linux-update-package-type') - return module.getLinuxRootPackageType +async function loadPackageType(): Promise { + return import('./linux-update-package-type') } beforeEach(async () => { vi.resetModules() + hasTrustedPackageManagerForMock.mockReset().mockReturnValue(true) vi.spyOn(console, 'warn').mockImplementation(() => {}) appMock.isPackaged = true + delete process.env.APPIMAGE + delete process.env.APPDIR setPlatform('linux') resourcesDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'orca-package-type-')) setResourcesPath(resourcesDir) @@ -42,140 +70,297 @@ afterEach(async () => { vi.restoreAllMocks() setPlatform(originalPlatform) setResourcesPath(originalResourcesPath) + setExecPath(originalExecPath) + if (originalAppImage === undefined) { + delete process.env.APPIMAGE + } else { + process.env.APPIMAGE = originalAppImage + } + if (originalAppDir === undefined) { + delete process.env.APPDIR + } else { + process.env.APPDIR = originalAppDir + } await fsp.rm(resourcesDir, { recursive: true, force: true }) }) describe('getLinuxRootPackageType', () => { it('reads a deb marker', async () => { await writeMarker('deb') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBe('deb') + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('deb') + expect(module.getLinuxRootPackageType()).toBe('deb') }) it('reads an rpm marker', async () => { await writeMarker('rpm') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBe('rpm') + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('rpm') + expect(module.getLinuxRootPackageType()).toBe('rpm') }) it('trims surrounding whitespace', async () => { await writeMarker('\n rpm \t\n') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBe('rpm') + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('rpm') + expect(module.getLinuxRootPackageType()).toBe('rpm') }) it('treats the AppImage marker as not a root package', async () => { await writeMarker('AppImage') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('non-root') + expect(module.getLinuxRootPackageType()).toBeNull() }) - it('treats an unknown marker value as not a root package', async () => { + it('treats an unknown marker value as unusable', async () => { await writeMarker('snap') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() }) - it('treats a pacman marker as a recognized but unsupported target', async () => { + it('treats a pacman marker as unusable until recovery supports it', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) await writeMarker('pacman') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() - expect(warn).not.toHaveBeenCalled() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() + expect(warn).toHaveBeenCalledTimes(1) }) it('rejects a marker that only differs by case', async () => { await writeMarker('DEB') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() }) - it('returns null when the marker is missing', async () => { - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + it('uses a legacy AppImage identity when its executable and resources are inside APPDIR', async () => { + process.env.APPIMAGE = '/opt/orca/orca.AppImage' + process.env.APPDIR = '/tmp/.mount_orca' + setExecPath('/tmp/.mount_orca/orca') + setResourcesPath('/tmp/.mount_orca/resources') + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('non-root') + expect(module.getLinuxRootPackageType()).toBeNull() }) - it('returns null when the marker is unreadable', async () => { + it.each([ + ['relative APPIMAGE', 'relative/orca.AppImage', '/tmp/.mount_orca'], + ['relative APPDIR', '/opt/orca/orca.AppImage', 'relative/.mount_orca'] + ])('rejects a legacy identity with %s', async (_label, appImagePath, appDirPath) => { + process.env.APPIMAGE = appImagePath + process.env.APPDIR = appDirPath + setExecPath('/tmp/.mount_orca/orca') + setResourcesPath('/tmp/.mount_orca/resources') + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() + }) + + it.each([ + ['executable', '/tmp/.mount_orca-shadow/orca', '/tmp/.mount_orca/resources'], + ['resources', '/tmp/.mount_orca/orca', '/tmp/.mount_orca-shadow/resources'] + ])( + 'rejects a prefix-collision outside APPDIR for %s', + async (_label, execPath, resourcesPath) => { + process.env.APPIMAGE = '/opt/orca/orca.AppImage' + process.env.APPDIR = '/tmp/.mount_orca' + setExecPath(execPath) + setResourcesPath(resourcesPath) + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() + } + ) + + it('rejects NULs in every legacy AppImage identity path', async () => { + const { isLegacyAppImageRuntimeIdentity } = await loadPackageType() + const identity = { + appImagePath: '/opt/orca/orca.AppImage', + appDirPath: '/tmp/.mount_orca', + execPath: '/tmp/.mount_orca/orca', + resourcesPath: '/tmp/.mount_orca/resources' + } + + for (const field of Object.keys(identity) as (keyof typeof identity)[]) { + expect( + isLegacyAppImageRuntimeIdentity({ ...identity, [field]: `${identity[field]}\0suffix` }) + ).toBe(false) + } + }) + + it('requires every legacy AppImage identity path to be absolute', async () => { + const { isLegacyAppImageRuntimeIdentity } = await loadPackageType() + const identity = { + appImagePath: '/opt/orca/orca.AppImage', + appDirPath: '/tmp/.mount_orca', + execPath: '/tmp/.mount_orca/orca', + resourcesPath: '/tmp/.mount_orca/resources' + } + + for (const field of Object.keys(identity) as (keyof typeof identity)[]) { + expect(isLegacyAppImageRuntimeIdentity({ ...identity, [field]: 'relative/path' })).toBe(false) + } + }) + + it('prefers a package marker over an invalid legacy AppImage identity', async () => { + await writeMarker('deb') + process.env.APPIMAGE = 'relative/orca.AppImage' + process.env.APPDIR = 'relative/.mount_orca' + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('deb') + expect(module.getLinuxRootPackageType()).toBe('deb') + }) + + it('returns unusable when the marker is missing without AppImage identity', async () => { + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() + }) + + it('returns unusable when the marker is unreadable', async () => { // A directory in the marker's place makes readFileSync fail with EISDIR. await fsp.mkdir(path.join(resourcesDir, 'package-type')) - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() }) - it('returns null when resourcesPath is unavailable', async () => { + it('returns unusable when resourcesPath is unavailable', async () => { setResourcesPath(undefined) - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() }) - it('returns null when resourcesPath is empty', async () => { + it('returns unusable when resourcesPath is empty', async () => { setResourcesPath('') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() }) it('ignores a readable marker in an unpackaged dev run', async () => { await writeMarker('deb') appMock.isPackaged = false - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('non-root') + expect(module.getLinuxRootPackageType()).toBeNull() }) it('ignores a readable marker off Linux', async () => { await writeMarker('deb') setPlatform('darwin') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('non-root') + expect(module.getLinuxRootPackageType()).toBeNull() }) it('caches the resolved type for the process lifetime', async () => { await writeMarker('deb') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBe('deb') + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('deb') + expect(module.getLinuxRootPackageType()).toBe('deb') await writeMarker('rpm') - expect(getLinuxRootPackageType()).toBe('deb') + expect(module.getLinuxPackageType()).toBe('deb') + expect(module.getLinuxRootPackageType()).toBe('deb') }) - it('caches a resolved null so a later marker is not picked up', async () => { - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + it('caches an unusable result so a later marker is not picked up', async () => { + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') await writeMarker('deb') - expect(getLinuxRootPackageType()).toBeNull() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() }) it('warns about an unknown marker value', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) await writeMarker('snap') - const getLinuxRootPackageType = await loadPackageType() - getLinuxRootPackageType() + const module = await loadPackageType() + module.getLinuxPackageType() expect(warn).toHaveBeenCalledTimes(1) - expect(warn.mock.calls[0][0]).toContain('marker is not deb or rpm') + expect(warn.mock.calls[0][0]).toContain('marker is not AppImage, deb, or rpm') }) it('reads the marker once and warns once per process', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) // A directory in place of the marker is readable-but-unusable, which is the case worth reporting. await fsp.mkdir(path.join(resourcesDir, 'package-type')) - const getLinuxRootPackageType = await loadPackageType() - getLinuxRootPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + module.getLinuxPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() expect(warn).toHaveBeenCalledTimes(1) expect(warn.mock.calls[0][0]).toContain('marker unreadable') }) - // Why: AppImage ships no marker at all, so the normal case must stay silent. - it('stays silent when no marker is present', async () => { + it('warns when a packaged marker is missing', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() - expect(warn).not.toHaveBeenCalled() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('marker missing')) }) it('does not warn in a dev run', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) appMock.isPackaged = false - const getLinuxRootPackageType = await loadPackageType() - getLinuxRootPackageType() + const module = await loadPackageType() + module.getLinuxPackageType() expect(warn).not.toHaveBeenCalled() }) }) + +describe('isExternallyManagedLinuxInstall', () => { + // The #17702 case: an AUR/Nix/container rebuild of the .deb inherits `package-type` verbatim. + it('reports a deb marker with no deb package manager as externally managed', async () => { + hasTrustedPackageManagerForMock.mockReturnValue(false) + await writeMarker('deb') + const module = await loadPackageType() + expect(module.isExternallyManagedLinuxInstall()).toBe(true) + expect(hasTrustedPackageManagerForMock).toHaveBeenCalledWith('deb') + }) + + it('reports an rpm marker with no rpm package manager as externally managed', async () => { + hasTrustedPackageManagerForMock.mockReturnValue(false) + await writeMarker('rpm') + const module = await loadPackageType() + expect(module.isExternallyManagedLinuxInstall()).toBe(true) + expect(hasTrustedPackageManagerForMock).toHaveBeenCalledWith('rpm') + }) + + it('leaves a real deb host self-updatable', async () => { + await writeMarker('deb') + const module = await loadPackageType() + expect(module.isExternallyManagedLinuxInstall()).toBe(false) + }) + + it('never probes the host for an AppImage install', async () => { + hasTrustedPackageManagerForMock.mockReturnValue(false) + await writeMarker('AppImage') + const module = await loadPackageType() + expect(module.isExternallyManagedLinuxInstall()).toBe(false) + expect(hasTrustedPackageManagerForMock).not.toHaveBeenCalled() + }) + + it('never probes the host for an unusable marker', async () => { + hasTrustedPackageManagerForMock.mockReturnValue(false) + await writeMarker('snap') + const module = await loadPackageType() + expect(module.isExternallyManagedLinuxInstall()).toBe(false) + expect(hasTrustedPackageManagerForMock).not.toHaveBeenCalled() + }) + + it('probes the host at most once per process', async () => { + hasTrustedPackageManagerForMock.mockReturnValue(false) + await writeMarker('deb') + const module = await loadPackageType() + module.isExternallyManagedLinuxInstall() + module.isExternallyManagedLinuxInstall() + module.isExternallyManagedLinuxInstall() + expect(hasTrustedPackageManagerForMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/linux-update-package-type.ts b/src/main/linux-update-package-type.ts index 5acc83a4cf3..f58c6cf16ad 100644 --- a/src/main/linux-update-package-type.ts +++ b/src/main/linux-update-package-type.ts @@ -1,57 +1,136 @@ import { readFileSync } from 'node:fs' import path from 'node:path' import { app } from 'electron' +import { hasTrustedPackageManagerFor } from './linux-package-install-command' import type { LinuxRootPackageType } from '../shared/update-status-types' export type { LinuxRootPackageType } -// Why: `undefined` means "not resolved yet"; `null` is a resolved "not a root package". -let cachedPackageType: LinuxRootPackageType | null | undefined +/** The packaged Linux format that controls how updates may be installed. */ +export type LinuxPackageType = LinuxRootPackageType | 'non-root' | 'unusable' + +// Why: `undefined` means "not resolved yet"; every other value is stable for this process. +let cachedPackageType: LinuxPackageType | undefined +let cachedExternallyManaged: boolean | undefined // Bounded by construction: the marker is read at most once per process. function warnMarkerUnusable(detail: string): void { console.warn(`[updater] linux package-type marker unusable: ${detail}`) } -function readPackageTypeMarker(): LinuxRootPackageType | null { +function isAbsolutePathString(value: unknown): value is string { + return ( + typeof value === 'string' && value.length > 0 && !value.includes('\0') && path.isAbsolute(value) + ) +} + +function isInsideDirectory(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate) + return ( + relative.length > 0 && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ) +} + +export function isLegacyAppImageRuntimeIdentity(identity: { + appImagePath: unknown + appDirPath: unknown + execPath: unknown + resourcesPath: unknown +}): boolean { + if ( + !isAbsolutePathString(identity.appImagePath) || + !isAbsolutePathString(identity.appDirPath) || + !isAbsolutePathString(identity.execPath) || + !isAbsolutePathString(identity.resourcesPath) + ) { + return false + } + return ( + isInsideDirectory(identity.appDirPath, identity.execPath) && + isInsideDirectory(identity.appDirPath, identity.resourcesPath) + ) +} + +function hasLegacyAppImageRuntimeIdentity(resourcesPath: unknown): boolean { + return isLegacyAppImageRuntimeIdentity({ + appImagePath: process.env.APPIMAGE, + appDirPath: process.env.APPDIR, + execPath: process.execPath, + resourcesPath + }) +} + +function readPackageTypeMarker(): LinuxPackageType { if (process.platform !== 'linux' || !app.isPackaged) { - return null + return 'non-root' } const resourcesPath = process.resourcesPath if (typeof resourcesPath !== 'string' || resourcesPath.length === 0) { warnMarkerUnusable('resourcesPath unavailable') - return null + return 'unusable' } let raw: string try { raw = readFileSync(path.join(resourcesPath, 'package-type'), 'utf8') } catch (error) { - // Why: AppImage legitimately ships no marker, so only an unreadable one is worth reporting. - if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') { - warnMarkerUnusable('marker unreadable') + if ( + (error as NodeJS.ErrnoException)?.code === 'ENOENT' && + hasLegacyAppImageRuntimeIdentity(resourcesPath) + ) { + return 'non-root' } - return null + warnMarkerUnusable( + (error as NodeJS.ErrnoException)?.code === 'ENOENT' ? 'marker missing' : 'marker unreadable' + ) + return 'unusable' } const value = raw.trim() if (value === 'deb' || value === 'rpm') { return value } - // Why: electron-updater also supports pacman, but this recovery path covers deb/rpm only — a - // recognized marker is a deliberate scope cut, not a broken install. - if (value !== 'pacman') { - warnMarkerUnusable('marker is not deb or rpm') + if (value === 'AppImage') { + return 'non-root' } - return null + warnMarkerUnusable('marker is not AppImage, deb, or rpm') + return 'unusable' } /** - * The installed Linux package format, or null when this build does not install through a - * root package. Reads only the packaged marker `electron-updater` itself uses — never distro - * files, executable paths, or available package managers. + * Resolves the installed Linux package format. Packaged builds fail closed when their marker is + * missing or unusable unless the legacy APPIMAGE runtime identity is valid. Unpackaged, non-Linux, + * and identified AppImage runs are non-root. */ -export function getLinuxRootPackageType(): LinuxRootPackageType | null { +export function getLinuxPackageType(): LinuxPackageType { if (cachedPackageType === undefined) { cachedPackageType = readPackageTypeMarker() } return cachedPackageType } + +/** Returns a root package type when this build supports manual package recovery. */ +export function getLinuxRootPackageType(): LinuxRootPackageType | null { + const packageType = getLinuxPackageType() + return packageType === 'deb' || packageType === 'rpm' ? packageType : null +} + +/** + * Whether the marker claims a root package format this host cannot install. Repackagers (AUR, Nix, + * container rebuilds) unpack Orca's .deb and inherit its `package-type` verbatim, so the marker + * describes the artifact Orca was built as, never the system that now owns the install. Without a + * matching package manager no downloaded package can ever be applied here. + * + * A false positive is impossible by construction: this reuses the exact manager lists and resolver + * that `buildLinuxPackageInstallCommand` already loops over, so any host flagged here would have + * failed with `no-package-manager` after the download anyway. The gate only moves that verdict + * earlier — it never refuses a host that could have installed the update. + */ +export function isExternallyManagedLinuxInstall(): boolean { + if (cachedExternallyManaged === undefined) { + const packageType = getLinuxRootPackageType() + cachedExternallyManaged = packageType !== null && !hasTrustedPackageManagerFor(packageType) + } + return cachedExternallyManaged +} diff --git a/src/main/startup/appimage-cli-redirect.test.ts b/src/main/startup/appimage-cli-redirect.test.ts deleted file mode 100644 index 99d5024a517..00000000000 --- a/src/main/startup/appimage-cli-redirect.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { getAppImageCliArgs, maybeRedirectAppImageCliLaunch } from './appimage-cli-redirect' - -const commandNames = ['serve', 'status', 'terminal'] - -describe('AppImage CLI redirect', () => { - it('detects direct AppImage CLI commands', () => { - expect( - getAppImageCliArgs( - ['orca-linux.AppImage', 'status', '--json'], - { APPIMAGE: '/opt/orca' }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toEqual(['status', '--json']) - }) - - it('allows CLI global flags before the command', () => { - expect( - getAppImageCliArgs( - ['orca-linux.AppImage', '--pairing-code', 'abc123', '--json', 'terminal', 'list'], - { - APPIMAGE: '/opt/orca' - }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toEqual(['--pairing-code', 'abc123', '--json', 'terminal', 'list']) - }) - - it('does not redirect normal desktop AppImage launches', () => { - expect( - getAppImageCliArgs( - ['AppRun', '--no-sandbox', 'file:///tmp/example.txt'], - { - APPIMAGE: '/opt/orca' - }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toBeNull() - }) - - it('keeps direct serve launches in Electron when Chromium switches are present', () => { - expect( - getAppImageCliArgs( - [ - 'AppRun', - '--no-sandbox', - '--disable-features=FedCm,DirectSockets', - 'serve', - '--port', - '6768' - ], - { APPIMAGE: '/opt/orca' }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toBeNull() - }) - - it('keeps clean serve launches on the CLI path for validation', () => { - expect( - getAppImageCliArgs( - ['AppRun', '--no-sandbox', 'serve', '--port', '6768'], - { APPIMAGE: '/opt/orca' }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toEqual(['serve', '--port', '6768']) - }) - - it('handles a space-separated Chromium switch value', () => { - expect( - getAppImageCliArgs( - ['AppRun', '--disable-features', 'FedCm', 'serve'], - { APPIMAGE: '/opt/orca' }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toBeNull() - }) - - it('does not broaden the Electron-owned exception to switches after serve', () => { - expect( - getAppImageCliArgs( - ['AppRun', 'serve', '--disable-features=FedCm'], - { APPIMAGE: '/opt/orca' }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toEqual(['serve', '--disable-features=FedCm']) - }) - - it('removes no-sandbox before forwarding CLI help', () => { - expect( - getAppImageCliArgs( - ['AppRun', '--no-sandbox', 'serve', '--help'], - { APPIMAGE: '/opt/orca' }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toEqual(['serve', '--help']) - }) - - it('still redirects serve help even when Chromium switches are present', () => { - expect( - getAppImageCliArgs( - ['AppRun', '--disable-features=FedCm', 'serve', '--help'], - { APPIMAGE: '/opt/orca' }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toEqual(['--disable-features=FedCm', 'serve', '--help']) - }) - - it('spawns the unpacked CLI entrypoint with Electron node mode', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-redirect-')) - const cliEntryPath = join(root, 'app.asar.unpacked', 'out', 'cli', 'index.js') - await mkdir(join(root, 'app.asar.unpacked', 'out', 'cli'), { recursive: true }) - await writeFile(cliEntryPath, '', 'utf8') - const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })) - - const result = maybeRedirectAppImageCliLaunch({ - argv: ['orca-linux.AppImage', 'status', '--json'], - env: { - APPIMAGE: '/opt/orca/orca-linux.AppImage', - NODE_OPTIONS: '--inspect', - NODE_REPL_EXTERNAL_MODULE: '/tmp/repl.js' - }, - platform: 'linux', - isPackaged: true, - resourcesPath: root, - execPath: '/opt/orca/orca-ide', - commandNames, - spawn: spawn as never - }) - - expect(result).toEqual({ redirected: true, status: 0 }) - expect(spawn).toHaveBeenCalledWith('/opt/orca/orca-ide', [cliEntryPath, 'status', '--json'], { - env: expect.objectContaining({ - APPIMAGE: '/opt/orca/orca-linux.AppImage', - ELECTRON_RUN_AS_NODE: '1', - ORCA_NODE_OPTIONS: '--inspect', - ORCA_NODE_REPL_EXTERNAL_MODULE: '/tmp/repl.js' - }), - stdio: 'inherit' - }) - const spawnOptions = spawn.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv } | undefined - expect(spawnOptions?.env).not.toHaveProperty('NODE_OPTIONS') - expect(spawnOptions?.env).not.toHaveProperty('NODE_REPL_EXTERNAL_MODULE') - }) - - it('keeps a clean no-sandbox serve launch on the CLI path', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-redirect-')) - const cliEntryPath = join(root, 'app.asar.unpacked', 'out', 'cli', 'index.js') - await mkdir(join(root, 'app.asar.unpacked', 'out', 'cli'), { recursive: true }) - await writeFile(cliEntryPath, '', 'utf8') - const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })) - - const result = maybeRedirectAppImageCliLaunch({ - argv: ['orca-linux.AppImage', '--no-sandbox', 'serve'], - env: { APPIMAGE: '/opt/orca/orca-linux.AppImage' }, - platform: 'linux', - isPackaged: true, - resourcesPath: root, - execPath: '/opt/orca/orca-ide', - commandNames, - spawn: spawn as never - }) - - expect(result).toEqual({ redirected: true, status: 0 }) - expect(spawn).toHaveBeenCalledWith( - '/opt/orca/orca-ide', - [cliEntryPath, 'serve'], - expect.objectContaining({ - env: expect.objectContaining({ ORCA_APPIMAGE_NO_SANDBOX: '1' }) - }) - ) - }) -}) diff --git a/src/main/startup/appimage-cli-redirect.ts b/src/main/startup/appimage-cli-redirect.ts deleted file mode 100644 index 2ca5f54e155..00000000000 --- a/src/main/startup/appimage-cli-redirect.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { spawnSync, type SpawnSyncReturns } from 'node:child_process' -import { existsSync } from 'node:fs' -import { join } from 'node:path' - -type RedirectResult = - | { - redirected: false - } - | { - redirected: true - status: number - } - -type RedirectOptions = { - argv?: string[] - env?: NodeJS.ProcessEnv - platform?: NodeJS.Platform - isPackaged?: boolean - resourcesPath?: string - execPath?: string - commandNames?: readonly string[] - spawn?: typeof spawnSync -} - -const HELP_FLAGS = new Set(['--help', '-h', 'help']) -const APPIMAGE_DESKTOP_FLAGS = new Set(['--no-sandbox']) -const ELECTRON_LAUNCH_SWITCHES = new Set(['--disable-features']) -const CLI_FLAGS_WITH_VALUES = new Set(['--environment', '--pairing-code', '--disable-features']) -// Why: the main tsconfig cannot import the CLI project, but AppImage direct -// launches need a conservative allow-list before bypassing the GUI startup. -const APPIMAGE_CLI_COMMAND_NAMES = [ - 'agent', - 'automations', - 'back', - 'capture', - 'check', - 'clear', - 'click', - 'clipboard', - 'computer', - 'console', - 'cookie', - 'dblclick', - 'dialog', - 'download', - 'drag', - 'environment', - 'eval', - 'exec', - 'file', - 'fill', - 'find', - 'focus', - 'forward', - 'full-screenshot', - 'geolocation', - 'get', - 'goto', - 'highlight', - 'hover', - 'inserttext', - 'intercept', - 'is', - 'keypress', - 'mouse', - 'network', - 'open', - 'orchestration', - 'pdf', - 'reload', - 'repo', - 'screenshot', - 'scroll', - 'scrollintoview', - 'select', - 'select-all', - 'serve', - 'set', - 'snapshot', - 'status', - 'storage', - 'tab', - 'terminal', - 'type', - 'uncheck', - 'upload', - 'viewport', - 'wait', - 'worktree' -] - -export function maybeRedirectAppImageCliLaunch(options: RedirectOptions = {}): RedirectResult { - const argv = options.argv ?? process.argv - const env = options.env ?? process.env - const platform = options.platform ?? process.platform - const isPackaged = options.isPackaged ?? false - const resourcesPath = options.resourcesPath ?? process.resourcesPath - const execPath = options.execPath ?? process.execPath - const spawn = options.spawn ?? spawnSync - const cliArgs = getAppImageCliArgs(argv, env, { - platform, - isPackaged, - commandNames: options.commandNames ?? APPIMAGE_CLI_COMMAND_NAMES - }) - - if (!cliArgs) { - return { redirected: false } - } - - const cliEntryPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') - if (!existsSync(cliEntryPath)) { - process.stderr.write(`Unable to locate the Orca CLI entrypoint at ${cliEntryPath}\n`) - return { redirected: true, status: 1 } - } - - const childEnv = buildElectronRunAsNodeEnv(env) - if (argv.slice(1).includes('--no-sandbox')) { - // Why: the operator explicitly disabled Chromium's sandbox; preserve that choice when `serve` launches the Electron child. - childEnv.ORCA_APPIMAGE_NO_SANDBOX = '1' - } - const result = spawn(execPath, [cliEntryPath, ...cliArgs], { - env: childEnv, - stdio: 'inherit' - }) as SpawnSyncReturns - - if (result.error) { - process.stderr.write(`${result.error.message}\n`) - return { redirected: true, status: 1 } - } - - return { redirected: true, status: result.status ?? 1 } -} - -export function getAppImageCliArgs( - argv: string[], - env: NodeJS.ProcessEnv, - options: { - platform: NodeJS.Platform - isPackaged: boolean - commandNames: readonly string[] - } -): string[] | null { - if (options.platform !== 'linux' || !options.isPackaged) { - return null - } - if (!env.APPIMAGE && !env.APPDIR) { - return null - } - - const args = argv.slice(1) - if (args.length === 0) { - return null - } - const cliArgs = args.filter((arg) => !APPIMAGE_DESKTOP_FLAGS.has(arg)) - if (cliArgs.some((arg) => HELP_FLAGS.has(arg))) { - return cliArgs - } - - const commandNames = new Set(options.commandNames) - const firstPositional = findFirstCommandCandidate(cliArgs) - if (!firstPositional || !commandNames.has(firstPositional)) { - return null - } - // Keep serve in Electron only when an Electron launch switch is present. - // Forwarding it to the strict Node-mode CLI parser makes an otherwise valid - // serve launch fail, while clean serve invocations retain CLI validation. - if ( - firstPositional === 'serve' && - cliArgs - .slice(0, findFirstCommandCandidateIndex(cliArgs)) - .some((arg) => ELECTRON_LAUNCH_SWITCHES.has(flagName(arg))) - ) { - return null - } - return cliArgs -} - -function findFirstCommandCandidate(args: string[]): string | null { - const index = findFirstCommandCandidateIndex(args) - return index === -1 ? null : args[index]! -} - -function findFirstCommandCandidateIndex(args: string[]): number { - for (let index = 0; index < args.length; index += 1) { - const arg = args[index] - if (!arg.startsWith('-')) { - return index - } - if (CLI_FLAGS_WITH_VALUES.has(flagName(arg)) && !arg.includes('=')) { - index += 1 - } - } - return -1 -} - -function flagName(arg: string): string { - const equalsIndex = arg.indexOf('=') - return equalsIndex === -1 ? arg : arg.slice(0, equalsIndex) -} - -function buildElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const childEnv = { ...env } - childEnv.ORCA_NODE_OPTIONS = env.NODE_OPTIONS ?? '' - childEnv.ORCA_NODE_REPL_EXTERNAL_MODULE = env.NODE_REPL_EXTERNAL_MODULE ?? '' - childEnv.ELECTRON_RUN_AS_NODE = '1' - delete childEnv.NODE_OPTIONS - delete childEnv.NODE_REPL_EXTERNAL_MODULE - return childEnv -} diff --git a/src/main/startup/cli-command-names.ts b/src/main/startup/cli-command-names.ts new file mode 100644 index 00000000000..2f1b0e4394d --- /dev/null +++ b/src/main/startup/cli-command-names.ts @@ -0,0 +1,73 @@ +// Kept import-free for main/CLI project isolation; a parity test prevents drift. +export const CLI_COMMAND_NAMES = [ + 'account', + 'agent', + 'agent-context', + 'artifacts', + 'automations', + 'back', + 'capture', + 'check', + 'claude-teams', + 'clear', + 'click', + 'clipboard', + 'computer', + 'console', + 'cookie', + 'dblclick', + 'diagnostics', + 'dialog', + 'download', + 'drag', + 'emulator', + 'environment', + 'eval', + 'exec', + 'file', + 'fill', + 'find', + 'focus', + 'forward', + 'full-screenshot', + 'geolocation', + 'get', + 'goto', + 'highlight', + 'host', + 'hover', + 'inserttext', + 'intercept', + 'is', + 'keypress', + 'linear', + 'mouse', + 'network', + 'open', + 'open-url', + 'orchestration', + 'pdf', + 'project', + 'reload', + 'repo', + 'screenshot', + 'scroll', + 'scrollintoview', + 'select', + 'select-all', + 'serve', + 'set', + 'skills', + 'snapshot', + 'status', + 'storage', + 'tab', + 'terminal', + 'type', + 'uncheck', + 'upload', + 'viewport', + 'vm', + 'wait', + 'worktree' +] as const diff --git a/src/main/startup/cli-launch-redirect.test.ts b/src/main/startup/cli-launch-redirect.test.ts new file mode 100644 index 00000000000..7a4b29fe60e --- /dev/null +++ b/src/main/startup/cli-launch-redirect.test.ts @@ -0,0 +1,357 @@ +import { posix, win32 } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { getCliLaunchArgs, maybeRedirectCliLaunch } from './cli-launch-redirect' + +const COMMAND_NAMES = ['project', 'serve', 'status', 'skills', 'worktree'] + +const linux = { + resourcesPath: '/opt/Orca/resources', + execPath: '/opt/Orca/orca-ide', + get cliEntryPath(): string { + return posix.join(this.resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') + } +} +const windows = { + resourcesPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources', + execPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\Orca.exe', + get cliEntryPath(): string { + return win32.join(this.resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') + } +} + +const linuxOptions = { platform: 'linux' as const, isPackaged: true, commandNames: COMMAND_NAMES } +const windowsOptions = { platform: 'win32' as const, isPackaged: true, commandNames: COMMAND_NAMES } + +describe('CLI launch redirect: entry-path form', () => { + it('detects a launch that received the unpacked CLI entrypoint', () => { + expect( + getCliLaunchArgs( + [windows.execPath, windows.cliEntryPath.toUpperCase(), 'status', '--json'], + windows.cliEntryPath, + windowsOptions + ) + ).toEqual(['status', '--json']) + }) + + it('ignores normal desktop launches', () => { + expect( + getCliLaunchArgs([windows.execPath, '--updated'], windows.cliEntryPath, windowsOptions) + ).toBeNull() + }) + + it('ignores the entrypoint when it is only the executable itself (argv[0])', () => { + expect( + getCliLaunchArgs([windows.cliEntryPath, 'status'], windows.cliEntryPath, windowsOptions) + ).toBeNull() + }) + + it('applies on Linux too', () => { + expect( + getCliLaunchArgs( + [linux.execPath, linux.cliEntryPath, 'status'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['status']) + }) + + it('strips injected Chromium switches before node-mode CLI arguments', () => { + expect( + getCliLaunchArgs( + [ + linux.execPath, + linux.cliEntryPath, + '--no-sandbox', + '--disable-gpu', + '--disable-features=Vulkan', + 'status', + '--json' + ], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['status', '--json']) + expect( + getCliLaunchArgs( + [linux.execPath, linux.cliEntryPath, '--disable-features', 'Vulkan', 'skills', 'get'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['skills', 'get']) + }) + + it('keeps user flags after the command and malformed boolean assignments', () => { + expect( + getCliLaunchArgs( + [linux.execPath, linux.cliEntryPath, 'status', '--disable-features=Vulkan'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['status', '--disable-features=Vulkan']) + expect( + getCliLaunchArgs( + [linux.execPath, linux.cliEntryPath, '--no-sandbox=true', 'status'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['--no-sandbox=true', 'status']) + }) + + it('does not treat a later positional entrypoint path as the launcher', () => { + expect( + getCliLaunchArgs( + [linux.execPath, 'file', 'open', '--path', linux.cliEntryPath], + linux.cliEntryPath, + linuxOptions + ) + ).toBeNull() + }) +}) + +describe('CLI launch redirect: command form', () => { + it('redirects a direct binary launch with no AppImage env at all', () => { + expect( + getCliLaunchArgs( + ['/home/u/.config/orca-runtime/versions/1.4.158/orca-ide', 'skills', 'get', '--full'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['skills', 'get', '--full']) + }) + + it('strips Chromium switches node mode would reject', () => { + expect( + getCliLaunchArgs( + [linux.execPath, '--no-sandbox', '--disable-gpu', 'status', '--json'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['status', '--json']) + }) + + it('preserves desktop-shaped switches after the command', () => { + expect( + getCliLaunchArgs( + [linux.execPath, 'skills', 'get', '--disable-gpu', '--no-sandbox'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['skills', 'get', '--disable-gpu', '--no-sandbox']) + }) + + it('leaves direct serve in-process but redirects its help', () => { + expect( + getCliLaunchArgs( + [linux.execPath, '--no-sandbox', 'serve', '--port', '6768'], + linux.cliEntryPath, + linuxOptions + ) + ).toBeNull() + expect( + getCliLaunchArgs( + [linux.execPath, '--no-sandbox', 'serve', '--help'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['serve', '--help']) + expect( + getCliLaunchArgs( + [linux.execPath, '--disable-features', 'Vulkan', 'serve', '--help'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['serve', '--help']) + }) + + it('treats help as a CLI launch even without a command', () => { + expect(getCliLaunchArgs([linux.execPath, '--help'], linux.cliEntryPath, linuxOptions)).toEqual([ + '--help' + ]) + }) + + it.each(['--version', '-v'])('treats %s as a CLI launch even without a command', (flag) => { + expect(getCliLaunchArgs([linux.execPath, flag], linux.cliEntryPath, linuxOptions)).toEqual([ + flag + ]) + }) + + it.each(['--user-data-dir', '--proxy-server', '--unknown-desktop-switch'])( + 'does not treat a value of %s as a CLI early-exit flag', + (flag) => { + expect( + getCliLaunchArgs([linux.execPath, flag, 'help'], linux.cliEntryPath, linuxOptions) + ).toBeNull() + } + ) + + it('does not treat a serve option value as a help request', () => { + expect( + getCliLaunchArgs( + [linux.execPath, 'serve', '--project-root', 'help'], + linux.cliEntryPath, + linuxOptions + ) + ).toBeNull() + }) + + it('does not reinterpret help after the argument terminator', () => { + expect( + getCliLaunchArgs([linux.execPath, 'serve', '--', '--help'], linux.cliEntryPath, linuxOptions) + ).toBeNull() + }) + + it('leaves a plain desktop launch alone', () => { + expect(getCliLaunchArgs([linux.execPath], linux.cliEntryPath, linuxOptions)).toBeNull() + expect( + getCliLaunchArgs([linux.execPath, '/home/u/project'], linux.cliEntryPath, linuxOptions) + ).toBeNull() + }) + + it('skips flag values when looking for the command positional', () => { + expect( + getCliLaunchArgs( + [linux.execPath, '--environment', 'status', 'worktree', 'ps'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['--environment', 'status', 'worktree', 'ps']) + expect( + getCliLaunchArgs( + [linux.execPath, '--environment', 'status'], + linux.cliEntryPath, + linuxOptions + ) + ).toBeNull() + }) + + it.each([ + ['--project', 'github:stablyai/orca', 'project', 'setups'], + ['--project=github:stablyai/orca', 'project', 'setups'], + ['--project', 'project', 'project', 'setups'], + ['--project=project', 'project', 'setups'] + ])('preserves a project selector in %j', (...args) => { + expect(getCliLaunchArgs([linux.execPath, ...args], linux.cliEntryPath, linuxOptions)).toEqual( + args + ) + }) + + it('does not apply the command form on macOS or Windows', () => { + for (const platform of ['darwin', 'win32'] as const) { + expect( + getCliLaunchArgs([linux.execPath, 'status'], linux.cliEntryPath, { + platform, + isPackaged: true, + commandNames: COMMAND_NAMES + }) + ).toBeNull() + } + }) + + it('never redirects an unpackaged build', () => { + expect( + getCliLaunchArgs([linux.execPath, 'status'], linux.cliEntryPath, { + ...linuxOptions, + isPackaged: false + }) + ).toBeNull() + }) +}) + +describe('CLI launch redirect: spawning', () => { + it('runs the in-package CLI in Electron node mode with sanitized env', () => { + const run = vi.fn((..._args: unknown[]) => ({ + code: 0, + signal: null, + stdout: '', + stderr: '', + timedOut: false + })) + + const result = maybeRedirectCliLaunch({ + argv: [linux.execPath, 'status', '--json'], + env: { NODE_OPTIONS: '--inspect', NODE_REPL_EXTERNAL_MODULE: 'external-loader' }, + platform: 'linux', + isPackaged: true, + resourcesPath: linux.resourcesPath, + execPath: linux.execPath, + commandNames: COMMAND_NAMES, + exists: () => true, + run: run as never + }) + + expect(result).toEqual({ redirected: true, status: 0 }) + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + program: linux.execPath, + args: [linux.cliEntryPath, 'status', '--json'], + stdio: 'inherit', + timeoutMs: null, + env: expect.objectContaining({ + ELECTRON_RUN_AS_NODE: '1', + ORCA_CLI_LAUNCH_REDIRECTED: '1', + ORCA_NODE_OPTIONS: '--inspect', + ORCA_NODE_REPL_EXTERNAL_MODULE: 'external-loader' + }) + }) + ) + const spawnedEnv = (run.mock.calls[0][0] as { env: NodeJS.ProcessEnv }).env + expect(spawnedEnv).not.toHaveProperty('NODE_OPTIONS') + expect(spawnedEnv).not.toHaveProperty('NODE_REPL_EXTERNAL_MODULE') + }) + + it('refuses to redirect twice so a dropped ELECTRON_RUN_AS_NODE cannot loop', () => { + const run = vi.fn() + + const result = maybeRedirectCliLaunch({ + argv: [linux.execPath, 'status'], + env: { ORCA_CLI_LAUNCH_REDIRECTED: '1' }, + platform: 'linux', + isPackaged: true, + resourcesPath: linux.resourcesPath, + execPath: linux.execPath, + commandNames: COMMAND_NAMES, + exists: () => true, + run: run as never + }) + + expect(result).toEqual({ redirected: true, status: 1 }) + expect(run).not.toHaveBeenCalled() + }) + + it('reports a missing CLI entrypoint instead of booting the desktop app', () => { + const run = vi.fn() + + const result = maybeRedirectCliLaunch({ + argv: [linux.execPath, 'status'], + env: {}, + platform: 'linux', + isPackaged: true, + resourcesPath: linux.resourcesPath, + execPath: linux.execPath, + commandNames: COMMAND_NAMES, + exists: () => false, + run: run as never + }) + + expect(result).toEqual({ redirected: true, status: 1 }) + expect(run).not.toHaveBeenCalled() + }) + + it('surfaces a spawn failure as a non-zero exit', () => { + const result = maybeRedirectCliLaunch({ + argv: [linux.execPath, 'status'], + env: {}, + platform: 'linux', + isPackaged: true, + resourcesPath: linux.resourcesPath, + execPath: linux.execPath, + commandNames: COMMAND_NAMES, + exists: () => true, + run: (() => { + throw new Error('spawn ENOENT') + }) as never + }) + + expect(result).toEqual({ redirected: true, status: 1 }) + }) +}) diff --git a/src/main/startup/cli-launch-redirect.ts b/src/main/startup/cli-launch-redirect.ts new file mode 100644 index 00000000000..88c0e8062cb --- /dev/null +++ b/src/main/startup/cli-launch-redirect.ts @@ -0,0 +1,245 @@ +import { existsSync } from 'node:fs' +import { posix, win32 } from 'node:path' +import { runProcessSync } from '../../shared/child-process/run-process' +import { CLI_BOOLEAN_FLAGS, findCliCommandIndex } from '../../shared/cli-argument-boundary' +import { CLI_COMMAND_NAMES } from './cli-command-names' +import { VALUE_TAKING_FLAGS } from './serve-mode-argv' + +export type CliLaunchRedirectResult = { redirected: false } | { redirected: true; status: number } + +export type CliLaunchRedirectOptions = { + argv?: string[] + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + isPackaged?: boolean + resourcesPath?: string + execPath?: string + commandNames?: readonly string[] + exists?: typeof existsSync + run?: typeof runProcessSync +} + +const CLI_EARLY_EXIT_FLAGS = new Set(['--help', '-h', 'help', '--version', '-v']) +const DESKTOP_FLAGS = new Set(['--no-sandbox', '--disable-gpu']) +const DESKTOP_VALUE_FLAGS = new Set(['--disable-features']) +const CLI_LAUNCH_VALUE_FLAG_NAMES = [...VALUE_TAKING_FLAGS].map((flag) => flag.slice(2)) + +// Fence recursion if a wrapper drops ELECTRON_RUN_AS_NODE again. +const REDIRECT_ATTEMPT_ENV = 'ORCA_CLI_LAUNCH_REDIRECTED' + +// Redirect packaged CLI-shaped launches before Chromium initializes. +export function maybeRedirectCliLaunch( + options: CliLaunchRedirectOptions = {} +): CliLaunchRedirectResult { + const argv = options.argv ?? process.argv + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const isPackaged = options.isPackaged ?? false + const resourcesPath = options.resourcesPath ?? process.resourcesPath + const execPath = options.execPath ?? process.execPath + const exists = options.exists ?? existsSync + const run = options.run ?? runProcessSync + const cliEntryPath = buildPackagedCliEntryPath(platform, resourcesPath) + const cliArgs = getCliLaunchArgs(argv, cliEntryPath, { + platform, + isPackaged, + commandNames: options.commandNames ?? CLI_COMMAND_NAMES + }) + + if (!cliArgs) { + return { redirected: false } + } + if (env[REDIRECT_ATTEMPT_ENV] === '1') { + process.stderr.write('Unable to start the Orca CLI through Electron node mode.\n') + return { redirected: true, status: 1 } + } + if (!exists(cliEntryPath)) { + process.stderr.write(`Unable to locate the Orca CLI entrypoint at ${cliEntryPath}\n`) + return { redirected: true, status: 1 } + } + + const childEnv = buildElectronRunAsNodeEnv(env) + try { + const result = run({ + program: execPath, + args: [cliEntryPath, ...cliArgs], + env: childEnv, + stdio: 'inherit', + timeoutMs: null + }) + return { redirected: true, status: result.code ?? 1 } + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + return { redirected: true, status: 1 } + } +} + +export function getCliLaunchArgs( + argv: string[], + cliEntryPath: string, + options: { + platform: NodeJS.Platform + isPackaged: boolean + commandNames: readonly string[] + } +): string[] | null { + if (!options.isPackaged) { + return null + } + return getEntryPathLaunchArgs(argv, cliEntryPath, options) ?? getCommandLaunchArgs(argv, options) +} + +function getEntryPathLaunchArgs( + argv: string[], + cliEntryPath: string, + options: { platform: NodeJS.Platform; commandNames: readonly string[] } +): string[] | null { + const expectedCliPath = normalizePathForPlatform(cliEntryPath, options.platform) + // The packaged launcher always passes the entrypoint as Electron's first argument. + // Matching later positional arguments can mistake a normal desktop launch for the CLI. + if (!argv[1] || normalizePathForPlatform(argv[1], options.platform) !== expectedCliPath) { + return null + } + const args = argv.slice(2) + return stripDesktopFlags(args, findCommandIndex(args, options.commandNames)) +} + +function getCommandLaunchArgs( + argv: string[], + options: { platform: NodeJS.Platform; commandNames: readonly string[] } +): string[] | null { + if (options.platform !== 'linux') { + return null + } + const args = argv.slice(1) + if (args.length === 0) { + return null + } + const commandIndex = findCommandIndex(args, options.commandNames) + const cliArgs = stripDesktopFlags(args, commandIndex) + const command = commandIndex === -1 ? null : args[commandIndex] + // Keep direct serve in-process so signals reach its full child tree. + if (command && command !== 'serve') { + return cliArgs + } + return hasCliEarlyExitArg(args, commandIndex) ? cliArgs : null +} + +function findCommandIndex(args: readonly string[], commandNames: readonly string[]): number { + return findCliCommandIndex( + args, + commandNames.map((name) => [name]), + CLI_LAUNCH_VALUE_FLAG_NAMES + ) +} + +function stripDesktopFlags(args: readonly string[], commandIndex: number): string[] { + const boundary = commandIndex === -1 ? findLeadingFlagBoundary(args) : commandIndex + const cliArgs: string[] = [] + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]! + if (index < boundary) { + if (DESKTOP_FLAGS.has(arg)) { + continue + } + if (DESKTOP_VALUE_FLAGS.has(flagName(arg))) { + if (!arg.includes('=') && args[index + 1] && !args[index + 1]!.startsWith('-')) { + index += 1 + } + continue + } + } + cliArgs.push(arg) + } + return cliArgs +} + +function findLeadingFlagBoundary(args: readonly string[]): number { + let index = 0 + while (index < args.length) { + const token = args[index]! + if (token === '--' || !token.startsWith('-')) { + return index + } + index += 1 + if (takesLaunchValue(token, args[index])) { + index += 1 + } + } + return index +} + +function hasCliEarlyExitArg(args: readonly string[], commandIndex: number): boolean { + let index = 0 + let positionalCount = 0 + while (index < args.length) { + const token = args[index]! + if (token === '--') { + return false + } + if ( + CLI_EARLY_EXIT_FLAGS.has(token) && + (token !== 'help' || positionalCount === 0 || commandIndex !== -1) + ) { + return true + } + if (!token.startsWith('-')) { + if (token === 'help' && (positionalCount === 0 || commandIndex !== -1)) { + return true + } + positionalCount += 1 + } + index += 1 + if (takesLaunchValue(token, args[index])) { + index += 1 + } + } + return false +} + +function takesLaunchValue(token: string, next: string | undefined): boolean { + if ( + !next || + next.startsWith('-') || + !token.startsWith('-') || + token.includes('=') || + CLI_EARLY_EXIT_FLAGS.has(token) || + DESKTOP_FLAGS.has(token) + ) { + return false + } + const name = flagName(token) + return DESKTOP_VALUE_FLAGS.has(name) || !CLI_BOOLEAN_FLAGS.has(name.replace(/^-+/, '')) +} + +function flagName(arg: string): string { + const equalsIndex = arg.indexOf('=') + return equalsIndex === -1 ? arg : arg.slice(0, equalsIndex) +} + +function buildPackagedCliEntryPath(platform: NodeJS.Platform, resourcesPath: string): string { + return getPathApi(platform).join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') +} + +function normalizePathForPlatform(value: string, platform: NodeJS.Platform): string { + const pathApi = getPathApi(platform) + const normalized = pathApi.normalize(pathApi.isAbsolute(value) ? value : pathApi.resolve(value)) + // Windows path comparisons are case-insensitive. + return platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function getPathApi(platform: NodeJS.Platform): typeof win32 | typeof posix { + return platform === 'win32' ? win32 : posix +} + +function buildElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const childEnv = { ...env } + // Preserve user values without exposing them to Electron's bootstrap. + childEnv.ORCA_NODE_OPTIONS = env.NODE_OPTIONS ?? '' + childEnv.ORCA_NODE_REPL_EXTERNAL_MODULE = env.NODE_REPL_EXTERNAL_MODULE ?? '' + childEnv.ELECTRON_RUN_AS_NODE = '1' + childEnv[REDIRECT_ATTEMPT_ENV] = '1' + delete childEnv.NODE_OPTIONS + delete childEnv.NODE_REPL_EXTERNAL_MODULE + return childEnv +} diff --git a/src/main/startup/ensure-virtual-display.test.ts b/src/main/startup/ensure-virtual-display.test.ts index 93f4bb14f77..ded8360bce5 100644 --- a/src/main/startup/ensure-virtual-display.test.ts +++ b/src/main/startup/ensure-virtual-display.test.ts @@ -1,24 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { spawnMock, spawnSyncMock, existsSyncMock, readFileSyncMock, rmSyncMock, appMock } = +const { spawnMock, existsSyncMock, readFileSyncMock, rmSyncMock, statSyncMock, appMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), - spawnSyncMock: vi.fn(), existsSyncMock: vi.fn(), readFileSyncMock: vi.fn(), rmSyncMock: vi.fn(), + statSyncMock: vi.fn(), appMock: { disableHardwareAcceleration: vi.fn(), - commandLine: { appendSwitch: vi.fn() }, + commandLine: { appendSwitch: vi.fn(), getSwitchValue: vi.fn() }, once: vi.fn() } })) -vi.mock('child_process', () => ({ spawn: spawnMock, spawnSync: spawnSyncMock })) +vi.mock('child_process', () => ({ spawn: spawnMock })) vi.mock('fs', () => ({ existsSync: existsSyncMock, readFileSync: readFileSyncMock, - rmSync: rmSyncMock + rmSync: rmSyncMock, + statSync: statSyncMock })) vi.mock('electron', () => ({ app: appMock })) @@ -29,15 +30,39 @@ function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { value: platform, configurable: true }) } +function mockLiveXDisplay(pid = 4321): void { + statSyncMock.mockReturnValue({ isSocket: () => true }) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(`${pid}\n`) + vi.spyOn(process, 'kill').mockImplementation(() => true) +} + +function mockXvfbTakesDisplay(pid = 1234): void { + let bound = false + statSyncMock.mockImplementation(() => ({ isSocket: () => true })) + readFileSyncMock.mockImplementation(() => { + if (!bound) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return `${pid}\n` + }) + vi.spyOn(process, 'kill').mockImplementation(() => true) + spawnMock.mockImplementation(() => { + bound = true + return { pid, once: vi.fn(), kill: vi.fn(), killed: false } + }) +} + describe('ensureVirtualDisplayForHeadlessServe', () => { beforeEach(() => { spawnMock.mockReset() - spawnSyncMock.mockReset() existsSyncMock.mockReset() readFileSyncMock.mockReset() rmSyncMock.mockReset() + statSyncMock.mockReset() appMock.disableHardwareAcceleration.mockReset() appMock.commandLine.appendSwitch.mockReset() + appMock.commandLine.getSwitchValue.mockReset().mockReturnValue('') appMock.once.mockReset() delete process.env.DISPLAY }) @@ -76,6 +101,7 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { it('reuses an externally provided DISPLAY without starting Xvfb', async () => { setPlatform('linux') process.env.DISPLAY = ':0' + mockLiveXDisplay() const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) @@ -86,48 +112,75 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu') }) - it('reports unsupported (no spawn) when Xvfb is not installed', async () => { + it('reports unsupported when Xvfb cannot be launched', async () => { setPlatform('linux') - spawnSyncMock.mockReturnValue({ status: 1 }) // `which Xvfb` fails + spawnMock.mockReturnValue({ pid: undefined, once: vi.fn(), kill: vi.fn(), killed: false }) + const { ensureVirtualDisplayForHeadlessServe, MISSING_LINUX_DISPLAY_MESSAGE } = + await import('./ensure-virtual-display') + + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(false) + expect(spawnMock).toHaveBeenCalledWith('Xvfb', expect.any(Array), expect.any(Object)) + expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('endpoint is unavailable') + expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('XDG_RUNTIME_DIR') + expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('`xvfb` on Debian/Ubuntu') + expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('`xorg-x11-server-Xvfb`') + }) + + it('leaves an externally configured stale display untouched', async () => { + setPlatform('linux') + process.env.DISPLAY = ':77' + statSyncMock.mockReturnValue({ isSocket: () => true }) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockImplementation(() => { + throw new Error('display lock is outside this namespace') + }) const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(false) expect(spawnMock).not.toHaveBeenCalled() + expect(rmSyncMock).not.toHaveBeenCalled() + expect(process.env.DISPLAY).toBe(':77') }) - it('starts Xvfb and switches to software rendering when none exists', async () => { + // #15084 review: a container that bind-mounts only /tmp/.X11-unix used to serve and would + // otherwise now exit(1) at index.ts, since the serve gate treats false as fatal. + it('serves on an externally configured display that has no lock file', async () => { setPlatform('linux') - spawnSyncMock.mockReturnValue({ status: 0 }) // `which Xvfb` succeeds - // First existsSync (stale-socket check) false; later (socket-ready poll) true. - existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true) - spawnMock.mockReturnValue({ once: vi.fn(), kill: vi.fn(), killed: false }) - const processOnceSpy = vi.spyOn(process, 'once') - const processRemoveListenerSpy = vi.spyOn(process, 'removeListener') - const { ensureVirtualDisplayForHeadlessServe, stopVirtualDisplay } = - await import('./ensure-virtual-display') + process.env.DISPLAY = ':0' + statSyncMock.mockReturnValue({ isSocket: () => true }) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) + expect(spawnMock).not.toHaveBeenCalled() + expect(rmSyncMock).not.toHaveBeenCalled() + expect(process.env.DISPLAY).toBe(':0') + }) + + // removeStaleDisplayArtifacts unlinks the lock before the socket, so a crash between the two + // leaves a lockless socket on Orca's OWN :99. Adopting it would resurrect the orphan-socket bug. + it('does not adopt its own :99 socket when the lock is missing', async () => { + setPlatform('linux') + existsSyncMock.mockReturnValue(true) + mockXvfbTakesDisplay() + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) + // Cleaned up and respawned rather than trusted. + expect(rmSyncMock).toHaveBeenCalled() expect(spawnMock).toHaveBeenCalledWith( 'Xvfb', - expect.arrayContaining([':99', '-terminate']), - expect.objectContaining({ detached: true }) + expect.arrayContaining([':99']), + expect.any(Object) ) - expect(process.env.DISPLAY).toBe(':99') - expect(appMock.disableHardwareAcceleration).toHaveBeenCalled() - expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-dev-shm-usage') - expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu') - expect(processOnceSpy).toHaveBeenCalledWith('exit', stopVirtualDisplay) - const readyHandler = appMock.once.mock.calls.find(([event]) => event === 'ready')?.[1] - expect(readyHandler).toBeTypeOf('function') - readyHandler() - expect(processRemoveListenerSpy).toHaveBeenCalledWith('exit', stopVirtualDisplay) - expect(appMock.once.mock.calls.some(([event]) => event === 'will-quit')).toBe(false) }) it('reuses an existing virtual display only when its X server is alive', async () => { setPlatform('linux') - spawnSyncMock.mockReturnValue({ status: 0 }) - existsSyncMock.mockReturnValue(true) // :99 socket + lock present + statSyncMock.mockReturnValue({ isSocket: () => true }) // :99 socket present + existsSyncMock.mockReturnValue(true) readFileSyncMock.mockReturnValue('4321\n') // lock holds a PID const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never) // PID alive const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') @@ -142,14 +195,21 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { it('treats a stale socket (dead server) as no display and starts a fresh Xvfb', async () => { setPlatform('linux') - spawnSyncMock.mockReturnValue({ status: 0 }) - existsSyncMock.mockReturnValue(true) // orphan socket + lock present - readFileSyncMock.mockReturnValue('9999\n') - // PID is gone: process.kill throws ESRCH. - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { - throw new Error('ESRCH') + existsSyncMock.mockReturnValue(true) // lock present + let bound = false + statSyncMock.mockImplementation(() => ({ isSocket: () => true })) + readFileSyncMock.mockImplementation(() => (bound ? '1234\n' : '9999\n')) + // The orphan lock names a dead PID; the freshly spawned Xvfb is alive. + const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === 9999) { + throw new Error('ESRCH') + } + return true as never + }) + spawnMock.mockImplementation(() => { + bound = true + return { pid: 1234, once: vi.fn(), kill: vi.fn(), killed: false } }) - spawnMock.mockReturnValue({ once: vi.fn(), kill: vi.fn(), killed: false }) const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) @@ -163,4 +223,278 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { expect(process.env.DISPLAY).toBe(':99') killSpy.mockRestore() }) + + // A root-owned stale :99 socket (crashed system Xvfb, serve running as User=orca) cannot be + // unlinked, so our Xvfb refuses to bind and exits. Trusting the surviving socket set DISPLAY to a + // dead server and Chromium died in Ozone init with SIGSEGV. + it('reports failure when a stale socket blocks the Xvfb rebind', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => true }) + // Removal fails (foreign owner) and no lock ever appears, because Xvfb never took the display. + rmSyncMock.mockImplementation(() => { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + }) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + spawnMock.mockReturnValue({ pid: 4242, once: vi.fn(), kill: vi.fn(), killed: false }) + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(false) + expect(process.env.DISPLAY).toBeUndefined() + }) + + it('accepts the display once the spawned Xvfb owns its lock', async () => { + setPlatform('linux') + let lockWritten = false + statSyncMock.mockImplementation(() => ({ isSocket: () => lockWritten })) + rmSyncMock.mockImplementation(() => {}) + readFileSyncMock.mockImplementation(() => { + if (!lockWritten) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return '4242\n' + }) + vi.spyOn(process, 'kill').mockImplementation(() => true) + spawnMock.mockImplementation(() => { + lockWritten = true + return { pid: 4242, once: vi.fn(), kill: vi.fn(), killed: false } + }) + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) + expect(process.env.DISPLAY).toBe(':99') + }) + + describe('hasUsableLinuxDisplay', () => { + it('accepts live local X11 and Wayland sockets', async () => { + setPlatform('linux') + mockLiveXDisplay() + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(true) + expect( + hasUsableLinuxDisplay({ + WAYLAND_DISPLAY: 'wayland-0', + XDG_RUNTIME_DIR: '/run/user/1000' + }) + ).toBe(true) + expect(statSyncMock).toHaveBeenCalledWith('/tmp/.X11-unix/X0') + expect(statSyncMock).toHaveBeenCalledWith('/run/user/1000/wayland-0') + }) + + // An X server may bind only the abstract namespace, leaving nothing to stat. Abstract addresses + // are kernel-owned and vanish when the owner exits, so an entry is proof of a live server. + it('accepts an abstract-namespace X socket with no filesystem socket', async () => { + setPlatform('linux') + statSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + readFileSyncMock.mockImplementation((path: string) => { + if (path === '/proc/net/unix') { + return [ + 'Num RefCount Protocol Flags Type St Inode Path', + '0000000000000000: 00000003 00000000 00000000 0001 03 12014 @/tmp/.X11-unix/X0', + '' + ].join('\n') + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(true) + }) + + it('does not confuse a different display number in the abstract table', async () => { + setPlatform('linux') + statSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + readFileSyncMock.mockImplementation((path: string) => { + if (path === '/proc/net/unix') { + return '0000000000000000: 00000003 00000000 00000000 0001 03 12014 @/tmp/.X11-unix/X10\n' + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':1' })).toBe(false) + }) + + it('accepts an inherited WAYLAND_SOCKET fd with no WAYLAND_DISPLAY', async () => { + setPlatform('linux') + statSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ WAYLAND_SOCKET: '7' })).toBe(true) + expect(hasUsableLinuxDisplay({ WAYLAND_SOCKET: 'not-an-fd' })).toBe(false) + }) + + // Orca's own teardown unlinks the lock before the socket, so a lockless :99 is our own + // half-finished cleanup — trusting it because DISPLAY names it would accept a dead display. + it('does not trust a lockless socket on its own managed display number', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => true }) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':99' })).toBe(false) + // A foreign display number with the same shape is still accepted. + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(true) + }) + + it('rejects an orphaned local X11 socket whose server PID is gone', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => true }) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue('9999\n') + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('no such process'), { code: 'ESRCH' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':77' })).toBe(false) + expect(readFileSyncMock).toHaveBeenCalledWith('/tmp/.X77-lock', 'utf8') + }) + + // An X server writes its lock beside the socket and both survive a crash, so a lockless + // socket is an endpoint published from elsewhere (container bind mount, WSLg) — not an orphan. + it('accepts a local X11 socket published without a lock file', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => true }) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const killSpy = vi.spyOn(process, 'kill') + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(true) + expect(killSpy).not.toHaveBeenCalled() + }) + + // WSLg with ELECTRON_OZONE_PLATFORM_HINT=x11 has no Wayland fallback to rescue it. + it('accepts a lockless X11 socket when x11 is pinned and Wayland is unavailable', async () => { + setPlatform('linux') + statSyncMock.mockImplementation((path: string) => ({ + isSocket: () => path === '/tmp/.X11-unix/X0' + })) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0', ELECTRON_OZONE_PLATFORM_HINT: 'x11' })).toBe( + true + ) + }) + + it('still rejects a missing socket even when no lock file exists', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => false }) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(false) + }) + + it('rejects a lock that exists but cannot be read', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => true }) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(false) + }) + + it('accepts a live local X11 server owned by another user', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => true }) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue('4321\n') + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('not permitted'), { code: 'EPERM' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(true) + }) + + it('rejects absent, blank, and stale local displays', async () => { + setPlatform('linux') + statSyncMock.mockImplementation(() => { + throw new Error('ENOENT') + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({})).toBe(false) + expect(hasUsableLinuxDisplay({ DISPLAY: ' ', WAYLAND_DISPLAY: '' })).toBe(false) + expect(hasUsableLinuxDisplay({ DISPLAY: ':77' })).toBe(false) + expect( + hasUsableLinuxDisplay({ WAYLAND_DISPLAY: 'wayland-0', XDG_RUNTIME_DIR: '/run/user/1000' }) + ).toBe(false) + expect(hasUsableLinuxDisplay({ WAYLAND_DISPLAY: 'wayland-0' })).toBe(false) + }) + + it.each([ + ['localhost:10.0', true], + ['build-host.example:1', true], + ['[2001:db8::1]:2.0', true], + ['tcp/build-host.example:3', true], + ['garbage', false], + ['build host:1', false], + ['build-host.example:', false], + ['build-host.example:abc', false] + ])('validates remote X display syntax for %s', async (display, expected) => { + setPlatform('linux') + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: display })).toBe(expected) + expect(statSyncMock).not.toHaveBeenCalled() + }) + + it('honors forced X11 and Wayland platform selection', async () => { + setPlatform('linux') + statSyncMock.mockImplementation((path: string) => ({ + isSocket: () => path === '/run/user/1000/wayland-0' + })) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + const env = { + DISPLAY: ':77', + WAYLAND_DISPLAY: 'wayland-0', + XDG_RUNTIME_DIR: '/run/user/1000' + } + + appMock.commandLine.getSwitchValue.mockReturnValue('x11') + expect(hasUsableLinuxDisplay(env)).toBe(false) + appMock.commandLine.getSwitchValue.mockReturnValue('wayland') + expect(hasUsableLinuxDisplay(env)).toBe(true) + + statSyncMock.mockImplementation((path: string) => ({ + isSocket: () => path === '/tmp/.X11-unix/X0' + })) + expect(hasUsableLinuxDisplay({ ...env, DISPLAY: ':0' })).toBe(false) + + appMock.commandLine.getSwitchValue.mockReturnValue('') + expect(hasUsableLinuxDisplay({ ...env, ELECTRON_OZONE_PLATFORM_HINT: 'x11' })).toBe(false) + }) + + it('never gates a non-Linux platform', async () => { + setPlatform('darwin') + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({})).toBe(true) + expect(statSyncMock).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/startup/ensure-virtual-display.ts b/src/main/startup/ensure-virtual-display.ts index a9d517f9f99..6b78c05aa52 100644 --- a/src/main/startup/ensure-virtual-display.ts +++ b/src/main/startup/ensure-virtual-display.ts @@ -1,5 +1,6 @@ -import { spawn, spawnSync, type ChildProcess } from 'node:child_process' -import { existsSync, readFileSync, rmSync } from 'node:fs' +import { spawn, type ChildProcess } from 'node:child_process' +import { readFileSync, rmSync, statSync } from 'node:fs' +import { isAbsolute, join } from 'node:path' import { app } from 'electron' // Why: headless `orca serve` backs browser panes with offscreen BrowserWindows. @@ -12,6 +13,8 @@ const XVFB_STARTUP_TIMEOUT_MS = 5_000 const XVFB_POLL_INTERVAL_MS = 50 const VIRTUAL_DISPLAY_NUMBER = 99 const VIRTUAL_DISPLAY = `:${VIRTUAL_DISPLAY_NUMBER}` +const XVFB_INSTALL_GUIDANCE = + 'Install `xvfb` on Debian/Ubuntu or `xorg-x11-server-Xvfb` on RPM-based systems.' let xvfbProcess: ChildProcess | null = null @@ -33,32 +36,55 @@ function xDisplayLockPath(displayNumber: number): string { return `/tmp/.X${displayNumber}-lock` } -// Why: a socket file can outlive the X server that made it. The X lock file holds -// the server PID; if that process is gone, the display is dead despite the socket. -function isDisplayServerAlive(displayNumber: number): boolean { - const lockPath = xDisplayLockPath(displayNumber) - if (!existsSync(lockPath)) { - // No lock means no server claimed this display; the bare socket is stale. - return false - } +// Why: a socket file can outlive the X server that made it. The X lock file holds the server PID; +// if that process is gone, the display is dead despite the socket. `missing` is a third outcome the +// two callers must treat differently — see each call site. +type DisplayLockProbe = 'alive' | 'dead' | 'missing' + +function probeDisplayLock(displayNumber: number): DisplayLockProbe { let pid: number try { - pid = Number.parseInt(readFileSync(lockPath, 'utf8').trim(), 10) - } catch { - return false + pid = Number.parseInt(readFileSync(xDisplayLockPath(displayNumber), 'utf8').trim(), 10) + } catch (error) { + // An unreadable lock is a lock we cannot clear: treat it as dead, not absent. + return (error as NodeJS.ErrnoException)?.code === 'ENOENT' ? 'missing' : 'dead' } if (!Number.isInteger(pid) || pid <= 0) { - return false + return 'dead' } try { // signal 0 probes existence without affecting the process. process.kill(pid, 0) - return true - } catch { - return false + return 'alive' + } catch (error) { + // EPERM means the PID exists under another uid — a root-owned X server is still live. + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EPERM' + ? 'alive' + : 'dead' } } +/** + * Liveness for a display Orca did not create. An X server writes its lock beside the socket and + * both survive a crash (verified against Xvfb under SIGKILL), so a socket with no lock was never + * left by a crashed server — it is an endpoint published from elsewhere: a container bind-mounting + * only /tmp/.X11-unix, WSLg, or a foreign PID namespace. We cannot judge those, and refusing them + * blocks startup on displays that work. + */ +function isForeignDisplayServerAlive(displayNumber: number): boolean { + return probeDisplayLock(displayNumber) !== 'dead' +} + +/** + * Liveness for Orca's own VIRTUAL_DISPLAY_NUMBER. Stricter on purpose: `removeStaleDisplayArtifacts` + * unlinks the lock before the socket, so a lockless socket here is Orca's own half-finished + * teardown, not a foreign endpoint. Adopting it would resurrect the orphan-socket bug and stop the + * cleanup below from self-healing. + */ +function isManagedDisplayServerAlive(displayNumber: number): boolean { + return probeDisplayLock(displayNumber) === 'alive' +} + function removeStaleDisplayArtifacts(displayNumber: number): void { for (const path of [xDisplayLockPath(displayNumber), xvfbSocketPath(displayNumber)]) { try { @@ -69,30 +95,126 @@ function removeStaleDisplayArtifacts(displayNumber: number): void { } } -function hasXvfbBinary(): boolean { - // Why: spawnSync `which` is cheap and avoids spawning Xvfb only to fail; a - // clear up-front warning beats a cryptic ENOENT mid-startup. - const result = spawnSync('which', ['Xvfb'], { stdio: 'ignore' }) - return result.status === 0 -} - function sleepSync(ms: number): void { // Why: this runs in the synchronous pre-whenReady startup path, so block // without spinning the CPU or spawning a process. Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } -function waitForDisplaySocket(displayNumber: number, deadline: number): boolean { - const socket = xvfbSocketPath(displayNumber) - // Why: Xvfb creates its socket asynchronously after spawn; Electron must not - // boot before it exists or display init still fails. +// Why not socket presence alone: a stale socket we failed to unlink (a root-owned one left by a +// crashed system Xvfb, which `User=orca` serve cannot remove) still exists after our own Xvfb +// refused to bind the display. Treating that as ready sets DISPLAY to a dead server and Chromium +// dies in Ozone init with SIGSEGV instead of reporting an unusable display. +function waitForDisplayReady(displayNumber: number, deadline: number): boolean { + const isReady = (): boolean => + isUnixSocket(xvfbSocketPath(displayNumber)) && isManagedDisplayServerAlive(displayNumber) while (Date.now() < deadline) { - if (existsSync(socket)) { + if (isReady()) { return true } sleepSync(XVFB_POLL_INTERVAL_MS) } - return existsSync(socket) + return isReady() +} + +// Validate display syntax and local sockets before Chromium reaches Ozone initialization. +export function hasUsableLinuxDisplay(env: NodeJS.ProcessEnv = process.env): boolean { + if (process.platform !== 'linux') { + return true + } + + const ozonePlatform = app.commandLine.getSwitchValue('ozone-platform').trim().toLowerCase() + const ozonePlatformHint = env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase() + const selectedPlatform = + ozonePlatform === 'x11' || ozonePlatform === 'wayland' + ? ozonePlatform + : ozonePlatformHint === 'x11' || ozonePlatformHint === 'wayland' + ? ozonePlatformHint + : null + + if (selectedPlatform === 'x11') { + return hasUsableXDisplay(env.DISPLAY) + } + if (selectedPlatform === 'wayland') { + return hasUsableWaylandDisplay(env) + } + return hasUsableXDisplay(env.DISPLAY) || hasUsableWaylandDisplay(env) +} + +export const MISSING_LINUX_DISPLAY_MESSAGE = [ + 'Orca needs a usable display server, but the selected X11 or Wayland endpoint is unavailable.', + 'Check DISPLAY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR, and any --ozone-platform override.', + `Use \`orca-ide serve\` to run headless. On a bare server, ${XVFB_INSTALL_GUIDANCE}` +].join('\n') + +// Why: an X server may bind only the abstract namespace (`@/tmp/.X11-unix/X0`), which leaves no +// filesystem socket to stat. Abstract addresses are kernel-owned and vanish the moment the owner +// exits, so an entry here is proof of a live server — no lock file needed, and no stale entry is +// possible. Refusing these was a hard startup failure with no workaround. +function hasAbstractXSocket(displayNumber: number): boolean { + let table: unknown + try { + table = readFileSync('/proc/net/unix', 'utf8') + } catch { + return false + } + if (typeof table !== 'string') { + return false + } + const address = `@${xvfbSocketPath(displayNumber)}` + return table + .split('\n') + .some((line) => line.slice(line.lastIndexOf(' ') + 1).trimEnd() === address) +} + +function isUnixSocket(path: string): boolean { + try { + return statSync(path).isSocket() + } catch { + return false + } +} + +function hasUsableXDisplay(value: string | undefined): boolean { + const display = value?.trim() + if (!display) { + return false + } + + const localDisplay = /^(?:unix\/?)?:(\d+)(?:\.\d+)?$/i.exec(display) + // Remote endpoints cannot be proven with local socket checks. + if (!localDisplay) { + return /^\S+:\d+(?:\.\d+)?$/.test(display) + } + const displayNumber = Number(localDisplay[1]) + if (isUnixSocket(xvfbSocketPath(displayNumber))) { + // Why the managed number is never treated as foreign: Orca's own teardown unlinks the lock + // before the socket, so a lockless socket on VIRTUAL_DISPLAY_NUMBER is our own half-finished + // cleanup even when DISPLAY names it explicitly. Trusting it there would accept a dead display. + return displayNumber === VIRTUAL_DISPLAY_NUMBER + ? isManagedDisplayServerAlive(displayNumber) + : isForeignDisplayServerAlive(displayNumber) + } + return hasAbstractXSocket(displayNumber) +} + +function hasUsableWaylandDisplay(env: NodeJS.ProcessEnv): boolean { + // Why: WAYLAND_SOCKET is an already-connected fd handed over by the compositor, so there is no + // path to stat and WAYLAND_DISPLAY may be unset entirely. Its presence IS the display. + const inheritedFd = env.WAYLAND_SOCKET?.trim() + if (inheritedFd && /^\d+$/.test(inheritedFd)) { + return true + } + const display = env.WAYLAND_DISPLAY?.trim() + if (!display) { + return false + } + if (isAbsolute(display)) { + return isUnixSocket(display) + } + + const runtimeDir = env.XDG_RUNTIME_DIR?.trim() + return Boolean(runtimeDir && isAbsolute(runtimeDir) && isUnixSocket(join(runtimeDir, display))) } /** @@ -107,16 +229,17 @@ export function ensureVirtualDisplayForHeadlessServe(options: { isServeMode: boo configureHeadlessServeChromiumFlags() - // Why: respect an externally provided display (a real X server, or the image - // already running its own Xvfb). Don't start a competing one. - if (process.env.DISPLAY && process.env.DISPLAY.trim().length > 0) { - return true - } - - if (!hasXvfbBinary()) { + // Offscreen serve windows require X11; Wayland alone still needs Xvfb. + // Never delete artifacts from an externally managed display: a container may + // expose its socket without the host lock/PID being visible here. + const configuredDisplay = process.env.DISPLAY?.trim() + if (configuredDisplay) { + if (hasUsableXDisplay(configuredDisplay)) { + return true + } console.warn( - '[serve] Xvfb not found; browser panes are unavailable on this headless Linux host. ' + - 'Install Xvfb (e.g. `apt-get install xvfb`) or set DISPLAY to enable them.' + `[serve] DISPLAY=${configuredDisplay} is not verifiably live; leaving it untouched. ` + + 'Unset DISPLAY to let Orca start its own Xvfb.' ) return false } @@ -124,8 +247,8 @@ export function ensureVirtualDisplayForHeadlessServe(options: { isServeMode: boo // Why: reuse an existing display ONLY if a live X server actually backs it. // A crashed prior run can leave an orphan socket; trusting it by path alone // would advertise browser support that then fails at tab creation. - if (existsSync(xvfbSocketPath(VIRTUAL_DISPLAY_NUMBER))) { - if (isDisplayServerAlive(VIRTUAL_DISPLAY_NUMBER)) { + if (isUnixSocket(xvfbSocketPath(VIRTUAL_DISPLAY_NUMBER))) { + if (isManagedDisplayServerAlive(VIRTUAL_DISPLAY_NUMBER)) { process.env.DISPLAY = VIRTUAL_DISPLAY return true } @@ -147,6 +270,11 @@ export function ensureVirtualDisplayForHeadlessServe(options: { isServeMode: boo xvfbProcess.once('error', (error) => { console.warn('[serve] Xvfb failed to start:', error instanceof Error ? error.message : error) }) + // PATH lookup failures emit asynchronously, but a successful spawn has a PID immediately. + if (xvfbProcess.pid === undefined) { + xvfbProcess = null + return false + } } catch (error) { console.warn( '[serve] Could not start Xvfb:', @@ -155,9 +283,12 @@ export function ensureVirtualDisplayForHeadlessServe(options: { isServeMode: boo return false } - const ready = waitForDisplaySocket(VIRTUAL_DISPLAY_NUMBER, Date.now() + XVFB_STARTUP_TIMEOUT_MS) + const ready = waitForDisplayReady(VIRTUAL_DISPLAY_NUMBER, Date.now() + XVFB_STARTUP_TIMEOUT_MS) if (!ready) { - console.warn('[serve] Xvfb did not become ready in time; browser panes may be unavailable.') + console.warn( + `[serve] Xvfb did not take ownership of ${VIRTUAL_DISPLAY}; browser panes are unavailable. ` + + 'A stale socket from another user can block the rebind.' + ) stopVirtualDisplay() return false } diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index eb2a51cb7ff..acbe42360e8 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -2,8 +2,7 @@ import { app, ipcMain, powerMonitor, session } from 'electron' import { is } from '@electron-toolkit/utils' import os from 'node:os' import { join } from 'node:path' -import { maybeRedirectAppImageCliLaunch } from './appimage-cli-redirect' -import { maybeRedirectPackagedCliEntryLaunch } from './packaged-cli-entry-redirect' +import { maybeRedirectCliLaunch } from './cli-launch-redirect' import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' import { configureDevUserDataPath, @@ -78,7 +77,11 @@ import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' import { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics' import { getMainProcessLifecycleIdentity } from '../crash-reporting/main-process-lifecycle-identity' -import { ensureVirtualDisplayForHeadlessServe } from './ensure-virtual-display' +import { + ensureVirtualDisplayForHeadlessServe, + hasUsableLinuxDisplay, + MISSING_LINUX_DISPLAY_MESSAGE +} from './ensure-virtual-display' import { maybeApplyGpuFallbackForThisLaunch, registerGpuLifecycleHandlers } from './gpu-lifecycle' import { mainProcessState as state } from './main-process-state' import { initializeSyntheticTitleRuntime } from './synthetic-title-runtime' @@ -91,25 +94,15 @@ export type MainProcessPreflightOptions = { /** Performs all module-scope work that must happen before Electron's ready event. */ export function runMainProcessPreflight(options: MainProcessPreflightOptions): boolean { // Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. - // Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim. - // It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its - // command-name lookup finds a port number and strands the launch in an in-process serve. The - // packaged-CLI one matches on the entry path instead, so order cannot affect it either way. - const packagedRedirect = maybeRedirectPackagedCliEntryLaunch({ + // The redirect runs before the serve-argv rewrite so it still matches on the launch argv verbatim. + // Direct serve stays in-process so its signal handlers own all children. + const cliLaunchRedirect = maybeRedirectCliLaunch({ isPackaged: app.isPackaged, resourcesPath: process.resourcesPath, execPath: process.execPath }) - if (packagedRedirect.redirected) { - app.exit(packagedRedirect.status) - } - const appImageRedirect = maybeRedirectAppImageCliLaunch({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - execPath: process.execPath - }) - if (appImageRedirect.redirected) { - app.exit(appImageRedirect.status) + if (cliLaunchRedirect.redirected) { + app.exit(cliLaunchRedirect.status) } // Why: extracted AppRun / binary launches can land CLI-form `serve` args on the // Electron process without the CLI rewrite that injects `--serve` (#12677). @@ -118,6 +111,11 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b process.argv = normalizeServeModeArgv(process.argv) } state.isServeMode = process.argv.includes('--serve') + // Fail before Chromium's missing-display teardown can segfault (#13719). + if (app.isPackaged && !state.isServeMode && !hasUsableLinuxDisplay()) { + process.stderr.write(`${MISSING_LINUX_DISPLAY_MESSAGE}\n`) + app.exit(1) + } if (state.isServeMode) { reserveServeStdoutForReadiness() } @@ -317,6 +315,11 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b state.headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ isServeMode: state.isServeMode }) + // Why: continuing without Xvfb lets Ozone initialize without a display and SIGSEGV (#17615). + if (state.isServeMode && !state.headlessBrowserDisplayAvailable) { + process.stderr.write(`${MISSING_LINUX_DISPLAY_MESSAGE}\n`) + app.exit(1) + } initializeSyntheticTitleRuntime() registerGpuLifecycleHandlers() return true diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 78061fb439c..7e5f278fdc5 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -280,7 +280,7 @@ export async function initializeMainProcessRuntimeLaunch( } let serveOptions: ReturnType | null = null try { - serveOptions = state.isServeMode ? getServeOptions() : null + serveOptions = state.isServeMode ? getServeOptions(process.argv) : null } catch (error) { console.error(error instanceof Error ? error.message : String(error)) app.exit(1) diff --git a/src/main/startup/main-process-serve.ts b/src/main/startup/main-process-serve.ts index 367bdf6d033..2be4d47d071 100644 --- a/src/main/startup/main-process-serve.ts +++ b/src/main/startup/main-process-serve.ts @@ -4,45 +4,9 @@ import { app } from 'electron' import { resolveAdvertisedPairingEndpoint } from '../runtime/pairing-endpoint' import { notifyServeSupervisorReady } from '../serve-update-handoff' import { mainProcessState as state } from './main-process-state' +import { getServeOptions, type ServeOptions } from './serve-options' -export type ServeOptions = { - json: boolean - wsPort?: number - pairingAddress: string | null - noPairing: boolean - mobilePairing: boolean - recipeJson: boolean - projectRoot: string | null -} - -export function getServeOptions(argv = process.argv): ServeOptions { - const valueAfter = (flag: string): string | null => { - const index = argv.indexOf(flag) - if (index === -1) { - return null - } - const value = argv[index + 1] - return value && !value.startsWith('--') ? value : null - } - const rawPort = valueAfter('--serve-port') - let wsPort: number | undefined - if (rawPort) { - const parsedPort = Number(rawPort) - if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { - throw new Error(`Invalid --serve-port value: ${rawPort}`) - } - wsPort = parsedPort - } - return { - json: argv.includes('--serve-json'), - ...(wsPort !== undefined ? { wsPort } : {}), - pairingAddress: valueAfter('--serve-pairing-address'), - noPairing: argv.includes('--serve-no-pairing'), - mobilePairing: argv.includes('--serve-mobile-pairing'), - recipeJson: argv.includes('--serve-recipe-json'), - projectRoot: valueAfter('--serve-project-root') - } -} +export { getServeOptions, type ServeOptions } export function getBundledWebClientRoot(): string | undefined { const appPath = app.getAppPath() diff --git a/src/main/startup/packaged-cli-entry-redirect.test.ts b/src/main/startup/packaged-cli-entry-redirect.test.ts deleted file mode 100644 index 4f91657eca0..00000000000 --- a/src/main/startup/packaged-cli-entry-redirect.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { win32 } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { - getPackagedCliEntryArgs, - maybeRedirectPackagedCliEntryLaunch -} from './packaged-cli-entry-redirect' - -const resourcesPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources' -const execPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\Orca.exe' -const cliEntryPath = win32.join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') - -describe('packaged CLI entry redirect', () => { - it('detects Windows GUI launches that received the unpacked CLI entrypoint', () => { - expect( - getPackagedCliEntryArgs( - [execPath, cliEntryPath.toUpperCase(), 'status', '--json'], - cliEntryPath, - 'win32' - ) - ).toEqual(['status', '--json']) - }) - - it('ignores normal desktop launches', () => { - expect(getPackagedCliEntryArgs([execPath, '--updated'], cliEntryPath, 'win32')).toBeNull() - }) - - it('ignores the entrypoint when it is only the executable itself (argv[0])', () => { - expect(getPackagedCliEntryArgs([cliEntryPath, 'status'], cliEntryPath, 'win32')).toBeNull() - }) - - it('does not match the entrypoint on non-Windows platforms', () => { - expect( - getPackagedCliEntryArgs([execPath, cliEntryPath, 'status'], cliEntryPath, 'linux') - ).toBeNull() - }) - - it('spawns the in-package CLI in Electron node mode before the single-instance lock can win', () => { - const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })) - - const result = maybeRedirectPackagedCliEntryLaunch({ - argv: [execPath, cliEntryPath, 'status', '--json'], - env: { - NODE_OPTIONS: '--inspect', - NODE_REPL_EXTERNAL_MODULE: 'external-loader' - }, - platform: 'win32', - isPackaged: true, - resourcesPath, - execPath, - exists: () => true, - spawn: spawn as never - }) - - expect(result).toEqual({ redirected: true, status: 0 }) - expect(spawn).toHaveBeenCalledWith(execPath, [cliEntryPath, 'status', '--json'], { - env: expect.objectContaining({ - ELECTRON_RUN_AS_NODE: '1', - ORCA_PACKAGED_CLI_ENTRY_REDIRECTED: '1', - ORCA_NODE_OPTIONS: '--inspect', - ORCA_NODE_REPL_EXTERNAL_MODULE: 'external-loader' - }), - stdio: 'inherit' - }) - const spawnOptions = spawn.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv } | undefined - expect(spawnOptions?.env).not.toHaveProperty('NODE_OPTIONS') - expect(spawnOptions?.env).not.toHaveProperty('NODE_REPL_EXTERNAL_MODULE') - }) - - it('never spawns an attacker-supplied script — only the computed in-package entry', () => { - const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })) - const attackerScript = 'C:\\Users\\me\\evil.js' - - const result = maybeRedirectPackagedCliEntryLaunch({ - // An attacker placing some other script path in argv must not cause it to run. - argv: [execPath, attackerScript, 'status'], - platform: 'win32', - isPackaged: true, - resourcesPath, - execPath, - exists: () => true, - spawn: spawn as never - }) - - expect(result).toEqual({ redirected: false }) - expect(spawn).not.toHaveBeenCalled() - }) - - it('does not redirect development launches', () => { - const spawn = vi.fn() - - const result = maybeRedirectPackagedCliEntryLaunch({ - argv: ['C:\\dev\\Orca.exe', cliEntryPath, 'status'], - platform: 'win32', - isPackaged: false, - resourcesPath, - execPath: 'C:\\dev\\Orca.exe', - exists: () => true, - spawn: spawn as never - }) - - expect(result).toEqual({ redirected: false }) - expect(spawn).not.toHaveBeenCalled() - }) - - it('reports a clear failure instead of locating a missing entrypoint', () => { - const spawn = vi.fn() - const stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - try { - const result = maybeRedirectPackagedCliEntryLaunch({ - argv: [execPath, cliEntryPath, 'status'], - platform: 'win32', - isPackaged: true, - resourcesPath, - execPath, - exists: () => false, - spawn: spawn as never - }) - - expect(result).toEqual({ redirected: true, status: 1 }) - expect(stderrWrite).toHaveBeenCalledWith( - `Unable to locate the Orca CLI entrypoint at ${cliEntryPath}\n` - ) - expect(spawn).not.toHaveBeenCalled() - } finally { - stderrWrite.mockRestore() - } - }) - - it('fails clearly instead of recursively redirecting when node mode already failed once', () => { - const spawn = vi.fn() - const stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - try { - const result = maybeRedirectPackagedCliEntryLaunch({ - argv: [execPath, cliEntryPath, 'status', '--json'], - env: { - ORCA_PACKAGED_CLI_ENTRY_REDIRECTED: '1' - }, - platform: 'win32', - isPackaged: true, - resourcesPath, - execPath, - exists: () => true, - spawn: spawn as never - }) - - expect(result).toEqual({ redirected: true, status: 1 }) - expect(stderrWrite).toHaveBeenCalledWith( - 'Unable to start the Orca CLI through Electron node mode.\n' - ) - expect(spawn).not.toHaveBeenCalled() - } finally { - stderrWrite.mockRestore() - } - }) -}) diff --git a/src/main/startup/packaged-cli-entry-redirect.ts b/src/main/startup/packaged-cli-entry-redirect.ts deleted file mode 100644 index 333da2fcdd8..00000000000 --- a/src/main/startup/packaged-cli-entry-redirect.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { spawnSync, type SpawnSyncReturns } from 'node:child_process' -import { existsSync } from 'node:fs' -import { posix, win32 } from 'node:path' - -type RedirectResult = - | { - redirected: false - } - | { - redirected: true - status: number - } - -type RedirectOptions = { - argv?: string[] - env?: NodeJS.ProcessEnv - platform?: NodeJS.Platform - isPackaged?: boolean - resourcesPath?: string - execPath?: string - exists?: typeof existsSync - spawn?: typeof spawnSync -} - -// Why: set on the re-spawned node-mode child so a failure to honor -// ELECTRON_RUN_AS_NODE can't make us redirect forever in a tight loop. -const REDIRECT_ATTEMPT_ENV = 'ORCA_PACKAGED_CLI_ENTRY_REDIRECTED' - -/** - * Why: on Windows the bundled native launcher runs `Orca.exe ` - * with ELECTRON_RUN_AS_NODE=1. When that env var is dropped (e.g. a wrapper or - * shell that resets it), Orca boots as a GUI, loses the single-instance lock to - * an already-running window, and exits silently with no stdout. This detects the - * CLI-shaped launch — argv carrying the known in-package CLI entry path — and - * re-runs it in Electron node mode BEFORE the lock gate, then exits with the - * CLI's status. - * - * Security: the spawned program is always `execPath` (Orca.exe) and the script - * is always `cliEntryPath`, derived solely from `resourcesPath` + a fixed - * relative path — never taken from argv. argv only contributes the trailing - * CLI arguments forwarded to the already-trusted in-package CLI, and the - * redirect only fires when an argv element exactly equals that computed path, - * so it cannot be coerced into spawning an arbitrary script. - */ -export function maybeRedirectPackagedCliEntryLaunch(options: RedirectOptions = {}): RedirectResult { - const argv = options.argv ?? process.argv - const env = options.env ?? process.env - const platform = options.platform ?? process.platform - const isPackaged = options.isPackaged ?? false - const resourcesPath = options.resourcesPath ?? process.resourcesPath - const execPath = options.execPath ?? process.execPath - const exists = options.exists ?? existsSync - const spawn = options.spawn ?? spawnSync - const cliEntryPath = buildPackagedCliEntryPath(platform, resourcesPath) - const cliArgs = getPackagedCliEntryArgs(argv, cliEntryPath, platform) - - if (!isPackaged || !cliArgs) { - return { redirected: false } - } - if (env[REDIRECT_ATTEMPT_ENV] === '1') { - process.stderr.write('Unable to start the Orca CLI through Electron node mode.\n') - return { redirected: true, status: 1 } - } - if (!exists(cliEntryPath)) { - process.stderr.write(`Unable to locate the Orca CLI entrypoint at ${cliEntryPath}\n`) - return { redirected: true, status: 1 } - } - - const result = spawn(execPath, [cliEntryPath, ...cliArgs], { - env: buildElectronRunAsNodeEnv(env), - stdio: 'inherit' - }) as SpawnSyncReturns - - if (result.error) { - process.stderr.write(`${result.error.message}\n`) - return { redirected: true, status: 1 } - } - - return { redirected: true, status: result.status ?? 1 } -} - -/** - * Returns the CLI arguments that follow the in-package CLI entrypoint in argv, - * or null when this is not a Windows CLI-shaped launch. Scoped to win32 because - * the AppImage redirect already covers the Linux equivalent. - */ -export function getPackagedCliEntryArgs( - argv: string[], - cliEntryPath: string, - platform: NodeJS.Platform -): string[] | null { - if (platform !== 'win32') { - return null - } - const expectedCliPath = normalizePathForPlatform(cliEntryPath, platform) - const cliEntryIndex = argv.findIndex( - (arg, index) => index > 0 && normalizePathForPlatform(arg, platform) === expectedCliPath - ) - return cliEntryIndex === -1 ? null : argv.slice(cliEntryIndex + 1) -} - -function buildPackagedCliEntryPath(platform: NodeJS.Platform, resourcesPath: string): string { - return getPathApi(platform).join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') -} - -function normalizePathForPlatform(value: string, platform: NodeJS.Platform): string { - const pathApi = getPathApi(platform) - const normalized = pathApi.normalize(pathApi.isAbsolute(value) ? value : pathApi.resolve(value)) - // Why: Windows paths are case-insensitive, so compare case-folded. - return platform === 'win32' ? normalized.toLowerCase() : normalized -} - -function getPathApi(platform: NodeJS.Platform): typeof win32 | typeof posix { - return platform === 'win32' ? win32 : posix -} - -function buildElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const childEnv = { ...env } - // Why: the CLI re-reads these from the ORCA_-prefixed copies; clearing the - // originals keeps Electron's own node bootstrap from inheriting them. - childEnv.ORCA_NODE_OPTIONS = env.NODE_OPTIONS ?? '' - childEnv.ORCA_NODE_REPL_EXTERNAL_MODULE = env.NODE_REPL_EXTERNAL_MODULE ?? '' - childEnv.ELECTRON_RUN_AS_NODE = '1' - childEnv[REDIRECT_ATTEMPT_ENV] = '1' - delete childEnv.NODE_OPTIONS - delete childEnv.NODE_REPL_EXTERNAL_MODULE - return childEnv -} diff --git a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts index 3455c8c59ab..182bc94cc98 100644 --- a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts +++ b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts @@ -1,70 +1,67 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { getAppImageCliArgs } from './appimage-cli-redirect' +import { getCliLaunchArgs } from './cli-launch-redirect' import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' -// Why: index.ts runs CLI redirects before rewriting argv. Direct AppImage serve -// stays in Electron so launch switches do not cross into the strict Node-mode -// CLI parser; other CLI commands still depend on redirect ordering (#12677). - +const CLI_ENTRY_PATH = '/opt/orca/resources/app.asar.unpacked/out/cli/index.js' const REDIRECT_OPTIONS = { platform: 'linux' as const, isPackaged: true, commandNames: ['serve', 'status'] } -// A mounted AppImage is the case where the runtime does export these. -const MOUNTED_APPIMAGE_ENV = { APPIMAGE: '/opt/orca/Orca.AppImage', APPDIR: '/tmp/.mount_ab12' } function rewriteAsIndexDoes(argv: string[]): string[] { return argvRequestsServeMode(argv) ? normalizeServeModeArgv(argv) : argv } -describe('serve argv rewrite vs AppImage CLI redirect ordering', () => { - const launchArgv = ['/opt/orca/orca-ide', '--no-sandbox', 'serve', '--port', '7777', '--json'] +describe('serve argv rewrite vs CLI launch redirect ordering', () => { + const launchArgv = [ + '/opt/orca/orca-ide', + '--disable-features=Vulkan', + 'serve', + '--port', + '7777', + '--json' + ] - it('keeps clean serve validation on the CLI path', () => { - expect(getAppImageCliArgs(launchArgv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual([ - 'serve', - '--port', - '7777', - '--json' - ]) + it('leaves direct serve in the main process', () => { + expect(getCliLaunchArgs(launchArgv, CLI_ENTRY_PATH, REDIRECT_OPTIONS)).toBeNull() }) - it('keeps an injected Chromium switch in Electron before argv rewriting', () => { - const injected = [...launchArgv.slice(0, 2), '--disable-features=FedCm', ...launchArgv.slice(2)] - expect(getAppImageCliArgs(injected, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toBeNull() - }) - - it('loses the redirect if the rewrite runs first', () => { + it('rewrites direct serve into the in-process flag shape', () => { const rewritten = rewriteAsIndexDoes(launchArgv) + expect(rewritten).toContain('--disable-features=Vulkan') expect(rewritten).toContain('--serve') - expect(getAppImageCliArgs(rewritten, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toBeNull() + expect(rewritten).toContain('--serve-port') + expect(getCliLaunchArgs(rewritten, CLI_ENTRY_PATH, REDIRECT_OPTIONS)).toBeNull() }) it('leaves non-serve CLI commands redirectable either way', () => { const argv = ['/opt/orca/orca-ide', 'status'] expect(rewriteAsIndexDoes(argv)).toEqual(argv) - expect(getAppImageCliArgs(argv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual(['status']) + expect(getCliLaunchArgs(argv, CLI_ENTRY_PATH, REDIRECT_OPTIONS)).toEqual(['status']) + }) + + it('redirects serve help instead of binding a server', () => { + const argv = ['/opt/orca/orca-ide', 'serve', '--help'] + expect(rewriteAsIndexDoes(argv)).toEqual(argv) + expect(getCliLaunchArgs(argv, CLI_ENTRY_PATH, REDIRECT_OPTIONS)).toEqual(['serve', '--help']) }) // Why source text: the ordering is the preflight phase's executable statement order, and the // cases above stay green if it is reversed — nothing else would catch the regression. - it('keeps the preflight running both CLI redirects before the argv rewrite', () => { + it('keeps the preflight running the CLI redirect before the argv rewrite', () => { const source = readFileSync( join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), 'utf8' ) - const packagedRedirect = source.indexOf('maybeRedirectPackagedCliEntryLaunch({') - const appImageRedirect = source.indexOf('maybeRedirectAppImageCliLaunch({') + const cliRedirect = source.indexOf('maybeRedirectCliLaunch({') const rewrite = source.indexOf('process.argv = normalizeServeModeArgv(process.argv)') const serveModeCheck = source.indexOf("state.isServeMode = process.argv.includes('--serve')") - expect(packagedRedirect).toBeGreaterThanOrEqual(0) - expect(appImageRedirect).toBeGreaterThanOrEqual(0) - expect(rewrite).toBeGreaterThan(packagedRedirect) - expect(rewrite).toBeGreaterThan(appImageRedirect) + expect(cliRedirect).toBeGreaterThanOrEqual(0) + expect(rewrite).toBeGreaterThan(cliRedirect) // The rewrite is pointless unless it lands before the flag it exists to inject is read. expect(serveModeCheck).toBeGreaterThan(rewrite) }) diff --git a/src/main/startup/serve-mode-argv.test.ts b/src/main/startup/serve-mode-argv.test.ts index 13c340c8e10..53b0bb616a1 100644 --- a/src/main/startup/serve-mode-argv.test.ts +++ b/src/main/startup/serve-mode-argv.test.ts @@ -25,6 +25,19 @@ describe('serve-mode-argv', () => { expect(findServeSubcommandIndex(['app', '--user-data-dir', '/tmp/x', 'serve'])).toBe(3) }) + it('skips a space-separated Chromium switch value while locating serve', () => { + const argv = ['/AppRun', '--disable-features', 'Vulkan', 'serve', '--port', '6768'] + expect(findServeSubcommandIndex(argv)).toBe(3) + expect(normalizeServeModeArgv(argv)).toEqual([ + '/AppRun', + '--disable-features', + 'Vulkan', + '--serve', + '--serve-port', + '6768' + ]) + }) + it('refuses a help launch instead of binding a server', () => { // Why: `--help` is not a serve flag, so it used to be swallowed and the launch bound a // network-exposed runtime server with pairing on. The AppImage redirect routes help to the CLI. @@ -151,6 +164,14 @@ describe('serve-mode-argv', () => { ).toEqual(['/AppRun', '--serve', '--serve-port', '9090', '--serve-pairing-address', '0.0.0.0']) }) + it('keeps equals-form values that start with a flag marker intact', () => { + expect(normalizeServeModeArgv(['/AppRun', 'serve', '--pairing-address=--no-pairng'])).toEqual([ + '/AppRun', + '--serve', + '--serve-pairing-address=--no-pairng' + ]) + }) + it('translates serve flags in the mixed `--serve --port` form', () => { // Why: leaving these untranslated silently kept pairing enabled despite --no-pairing. expect(normalizeServeModeArgv(['orca', '--serve', '--port', '9090', '--no-pairing'])).toEqual([ diff --git a/src/main/startup/serve-mode-argv.ts b/src/main/startup/serve-mode-argv.ts index 6b406faf80e..8441f116805 100644 --- a/src/main/startup/serve-mode-argv.ts +++ b/src/main/startup/serve-mode-argv.ts @@ -26,13 +26,14 @@ const CLI_TO_SERVE_VALUE_FLAG = new Map([ * Residual class: a flag outside this list whose space-separated value is literally `serve` would * read as the subcommand. Include switches that may arrive in either argv shape. */ -const VALUE_TAKING_FLAGS = new Set([ +export const VALUE_TAKING_FLAGS = new Set([ ...CLI_TO_SERVE_VALUE_FLAG.keys(), '--serve-port', '--serve-pairing-address', '--serve-project-root', '--disable-features', '--user-data-dir', + '--proxy-server', '--environment', '--pairing-code' ]) @@ -135,8 +136,7 @@ export function normalizeServeModeArgv(argv: readonly string[]): string[] { next.push(...argv.slice(i)) break } - // Why: the CLI accepts `--port=6768` as well as `--port 6768`, but - // getServeOptions only reads the next token, so `=` must be split apart. + // Why: keep the internal argv shape canonical even though getServeOptions accepts both forms. const eq = token.indexOf('=') const name = eq === -1 ? token : token.slice(0, eq) // Why only the bare form: the CLI reads its serve booleans as `flags.get(name) === true` @@ -154,7 +154,14 @@ export function normalizeServeModeArgv(argv: readonly string[]): string[] { continue } if (eq !== -1) { - next.push(valueFlag, token.slice(eq + 1)) + const value = token.slice(eq + 1) + // Preserve the unambiguous `=` form when its value starts with `--`; splitting + // it would make the value look like a second option to the direct parser. + if (value.startsWith('--')) { + next.push(`${valueFlag}=${value}`) + } else { + next.push(valueFlag, value) + } continue } next.push(valueFlag) diff --git a/src/main/startup/serve-options.test.ts b/src/main/startup/serve-options.test.ts new file mode 100644 index 00000000000..9e2bb06919d --- /dev/null +++ b/src/main/startup/serve-options.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' +import { getServeOptions } from './serve-options' +import { normalizeServeModeArgv } from './serve-mode-argv' + +describe('getServeOptions', () => { + it('parses a valid launch', () => { + expect( + getServeOptions(['/AppRun', '--serve', '--serve-port', '6768', '--serve-no-pairing']) + ).toEqual({ + json: false, + wsPort: 6768, + pairingAddress: null, + noPairing: true, + mobilePairing: false, + recipeJson: false, + projectRoot: null + }) + }) + + it('accepts equals-form values in the normalized shape', () => { + expect( + getServeOptions([ + '/AppRun', + '--serve', + '--serve-port=6768', + '--serve-pairing-address=127.0.0.1', + '--serve-project-root=/tmp/repo' + ]) + ).toMatchObject({ + wsPort: 6768, + pairingAddress: '127.0.0.1', + projectRoot: '/tmp/repo' + }) + }) + + it('uses the final occurrence of each value flag', () => { + expect( + getServeOptions([ + '/AppRun', + '--serve', + '--serve-port', + '6768', + '--serve-port=6769', + '--serve-pairing-address', + 'first.example', + '--serve-pairing-address=last.example', + '--serve-project-root', + '/first', + '--serve-project-root=/last' + ]) + ).toMatchObject({ + wsPort: 6769, + pairingAddress: 'last.example', + projectRoot: '/last' + }) + }) + + it('applies missing or invalid values only to the final occurrence', () => { + expect( + getServeOptions(['/AppRun', '--serve', '--serve-port', '--serve-port', '6768']).wsPort + ).toBe(6768) + expect(() => + getServeOptions(['/AppRun', '--serve', '--serve-port', '6768', '--serve-port']) + ).toThrow('Missing value for --serve-port.') + expect(() => + getServeOptions(['/AppRun', '--serve', '--serve-port', '6768', '--serve-port=bad']) + ).toThrow('Invalid --serve-port value: bad') + }) + + it('uses the final value of mixed boolean aliases', () => { + expect( + getServeOptions(['/AppRun', '--serve', '--serve-no-pairing', '--no-pairing=false']).noPairing + ).toBe(false) + expect( + getServeOptions(['/AppRun', '--serve', '--no-pairing=false', '--serve-no-pairing']).noPairing + ).toBe(true) + expect( + getServeOptions(['/AppRun', '--serve', '--serve-mobile-pairing', '--mobile-pairing=0']) + .mobilePairing + ).toBe(false) + expect( + getServeOptions(['/AppRun', '--serve', '--serve-recipe-json', '--recipe-json=false']) + .recipeJson + ).toBe(false) + }) + + it('keeps JSON enabled for an equals-form global flag', () => { + expect(getServeOptions(['/AppRun', '--serve', '--json=false']).json).toBe(true) + }) + + it('accepts an equals-form value that resembles a pairing flag', () => { + const argv = normalizeServeModeArgv(['/AppRun', 'serve', '--pairing-address=--no-pairng']) + expect(getServeOptions(argv).pairingAddress).toBe('--no-pairng') + }) + + it('shares cross-flag validation with the CLI-form launch', () => { + const argv = normalizeServeModeArgv([ + '/opt/orca/orca-ide', + 'serve', + '--no-pairing', + '--mobile-pairing' + ]) + expect(() => getServeOptions(argv)).toThrow(/either --mobile-pairing or --no-pairing/i) + }) + + it('rejects recipe JSON without runtime pairing and a project root', () => { + expect(() => + getServeOptions([ + '/AppRun', + '--serve', + '--serve-recipe-json', + '--serve-no-pairing', + '--serve-project-root', + '/tmp/repo' + ]) + ).toThrow(/requires runtime pairing.*--no-pairing/i) + expect(() => getServeOptions(['/AppRun', '--serve', '--serve-recipe-json'])).toThrow( + /requires --project-root/i + ) + }) + + it('rejects a security-shaped typo while allowing Chromium switches', () => { + const normalized = normalizeServeModeArgv(['/AppRun', 'serve', '--no-pairng']) + expect(() => getServeOptions(normalized)).toThrow(/Unknown flag --no-pairng.*--no-pairing/i) + expect( + getServeOptions(['/AppRun', '--serve', '--disable-gpu', '--disable-features=Vulkan']) + .noPairing + ).toBe(false) + }) + + it('still rejects a flag-shaped space value, as the CLI does', () => { + expect(() => + getServeOptions(['/AppRun', '--serve', '--serve-pairing-address', '--no-pairng']) + ).toThrow(/Unknown flag --no-pairng.*--no-pairing/i) + }) + + it('ignores serve-looking arguments after the terminator', () => { + expect( + getServeOptions(['/AppRun', '--serve', '--', '--serve-port', '1', '--serve-no-pairing']) + ).toEqual({ + json: false, + pairingAddress: null, + noPairing: false, + mobilePairing: false, + recipeJson: false, + projectRoot: null + }) + }) + + it('requires a port value', () => { + expect(() => getServeOptions(['/AppRun', '--serve', '--serve-port'])).toThrow( + 'Missing value for --serve-port.' + ) + }) + + it.each(['', '--serve-json', '--'])('rejects an unusable port value %j', (value) => { + expect(() => getServeOptions(['/AppRun', '--serve', '--serve-port', value])).toThrow( + 'Missing value for --serve-port.' + ) + }) +}) diff --git a/src/main/startup/serve-options.ts b/src/main/startup/serve-options.ts new file mode 100644 index 00000000000..c0398123797 --- /dev/null +++ b/src/main/startup/serve-options.ts @@ -0,0 +1,134 @@ +import { + getServeFlagTypoError, + getServeOptionValidationError +} from '../../shared/serve-option-validation' + +export type ServeOptions = { + json: boolean + wsPort?: number + pairingAddress: string | null + noPairing: boolean + mobilePairing: boolean + recipeJson: boolean + projectRoot: string | null +} + +function optionsBeforeTerminator(argv: readonly string[]): readonly string[] { + const terminatorIndex = argv.indexOf('--') + return terminatorIndex === -1 ? argv : argv.slice(0, terminatorIndex) +} + +function optionName(token: string): string { + const equalsIndex = token.indexOf('=') + return equalsIndex === -1 ? token : token.slice(0, equalsIndex) +} + +function lastValueOccurrence( + argv: readonly string[], + flags: readonly string[] +): string | null | undefined { + const flagNames = new Set(flags) + let value: string | null | undefined + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]! + const name = optionName(token) + if (!flagNames.has(name)) { + continue + } + + const equalsIndex = token.indexOf('=') + if (equalsIndex !== -1) { + const assigned = token.slice(equalsIndex + 1) + value = assigned || null + continue + } + + const next = argv[index + 1] + if (next !== undefined && !next.startsWith('--')) { + value = next || null + index += 1 + } else { + value = null + } + } + return value +} + +function valueAfter( + argv: readonly string[], + flags: readonly string[], + required: boolean, + displayFlag: string +): string | null { + const value = lastValueOccurrence(argv, flags) + if (value === undefined || value === null) { + if (required && value !== undefined) { + throw new Error(`Missing value for ${displayFlag}.`) + } + return null + } + return value +} + +function lastBooleanValue(argv: readonly string[], flags: readonly string[]): boolean { + const flagNames = new Set(flags) + let value = false + for (const token of argv) { + const name = optionName(token) + if (!flagNames.has(name)) { + continue + } + // CLI boolean flags are true only in bare form; `--flag=...` is a string value. + value = !token.includes('=') + } + return value +} + +function hasFlag(argv: readonly string[], flags: readonly string[]): boolean { + const flagNames = new Set(flags) + return argv.some((token) => flagNames.has(optionName(token))) +} + +export function getServeOptions(argv: readonly string[]): ServeOptions { + const optionsArgv = optionsBeforeTerminator(argv) + const typoError = getServeFlagTypoError(optionsArgv) + if (typoError) { + throw new Error(typoError) + } + + const rawPort = valueAfter(optionsArgv, ['--serve-port', '--port'], true, '--serve-port') + let wsPort: number | undefined + if (rawPort) { + const parsedPort = Number(rawPort) + if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { + throw new Error(`Invalid --serve-port value: ${rawPort}`) + } + wsPort = parsedPort + } + + const options: ServeOptions = { + // The CLI uses `flags.has('json')`, so even `--json=false` enables JSON output. + json: hasFlag(optionsArgv, ['--serve-json', '--json']), + ...(wsPort !== undefined ? { wsPort } : {}), + pairingAddress: valueAfter( + optionsArgv, + ['--serve-pairing-address', '--pairing-address'], + false, + '--serve-pairing-address' + ), + noPairing: lastBooleanValue(optionsArgv, ['--serve-no-pairing', '--no-pairing']), + mobilePairing: lastBooleanValue(optionsArgv, ['--serve-mobile-pairing', '--mobile-pairing']), + recipeJson: lastBooleanValue(optionsArgv, ['--serve-recipe-json', '--recipe-json']), + projectRoot: valueAfter( + optionsArgv, + ['--serve-project-root', '--project-root'], + false, + '--serve-project-root' + ) + } + const validationError = getServeOptionValidationError(options) + if (validationError) { + throw new Error(validationError) + } + return options +} diff --git a/src/main/startup/serve-signal-handlers.test.ts b/src/main/startup/serve-signal-handlers.test.ts index 36250cd4146..35c70ef87de 100644 --- a/src/main/startup/serve-signal-handlers.test.ts +++ b/src/main/startup/serve-signal-handlers.test.ts @@ -11,9 +11,11 @@ describe('registerServeSignalHandlers', () => { signalSource.emit('SIGINT') signalSource.emit('SIGINT') signalSource.emit('SIGTERM') + signalSource.emit('SIGHUP') - expect(quitApplication).toHaveBeenCalledTimes(3) + expect(quitApplication).toHaveBeenCalledTimes(4) expect(signalSource.listenerCount('SIGINT')).toBe(1) expect(signalSource.listenerCount('SIGTERM')).toBe(1) + expect(signalSource.listenerCount('SIGHUP')).toBe(1) }) }) diff --git a/src/main/startup/serve-signal-handlers.ts b/src/main/startup/serve-signal-handlers.ts index 3022d935ac5..0df48d5ea35 100644 --- a/src/main/startup/serve-signal-handlers.ts +++ b/src/main/startup/serve-signal-handlers.ts @@ -1,12 +1,13 @@ type ServeSignalSource = { - on(event: 'SIGINT' | 'SIGTERM', listener: () => void): unknown + on(event: 'SIGINT' | 'SIGTERM' | 'SIGHUP', listener: () => void): unknown } export function registerServeSignalHandlers( signalSource: ServeSignalSource, quitApplication: () => void ): void { - // Keep both listeners installed so duplicate delivery cannot fall through to default termination. + // Keep every listener installed so duplicate delivery cannot fall through to default termination. signalSource.on('SIGINT', quitApplication) signalSource.on('SIGTERM', quitApplication) + signalSource.on('SIGHUP', quitApplication) } diff --git a/src/main/startup/single-instance-lock-exit.electron.test.ts b/src/main/startup/single-instance-lock-exit.electron.test.ts index 15623c2459f..8c60f276216 100644 --- a/src/main/startup/single-instance-lock-exit.electron.test.ts +++ b/src/main/startup/single-instance-lock-exit.electron.test.ts @@ -6,11 +6,8 @@ import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE } from './single-instance-lock' -// Why #11935: the lock-loss gate runs before Electron `ready`, where `app.quit()` is deferred, so a -// duplicate headless `orca serve` kept executing the rest of startup, reached Linux Ozone/X11 init -// with no display, died with SIGSEGV, and systemd restarted it until the leaked AppImage FUSE mounts -// hit the kernel's 1000-mount ceiling. This runs the gate's own termination statement, lifted out of -// `src/main/index.ts`, under the real Electron binary. +// Why: `app.quit()` is deferred before Electron `ready`, so fatal startup gates must use the +// synchronous `app.exit()`. Run their shipped termination statements under the real binary. // // Why not a live lock race: Chromium's Linux ProcessSingleton only answers a second process once the // browser IO thread is up, which needs `ready` and therefore a display. On a display-less CI runner @@ -19,10 +16,10 @@ import { SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE } from './single-instance-loc // only a real process can settle is what the loser does next, which is what this file pins. const electronBinary = createRequire(import.meta.url)('electron') as string -const LOCK_LOST = 'LOCK_LOST' +const GATE_ENTERED = 'GATE_ENTERED' const CONTINUED_INTO_STARTUP = 'CONTINUED_INTO_STARTUP' const REACHED_TAIL = 'REACHED_TAIL' -const MARKER_ENV = 'ORCA_LOCK_FIXTURE_MARKER' +const MARKER_ENV = 'ORCA_PRE_READY_EXIT_FIXTURE_MARKER' const fixtureRoots: string[] = [] @@ -32,13 +29,13 @@ afterAll(() => { } }) -/** The `app.*` call the shipped lock-loss gate executes, so a revert to `app.quit()` fails here. */ -function readLockLossTermination(): string { +/** Read the `app.*` termination statement from a pre-ready gate in the shipped entrypoint. */ +function readPreReadyTermination(gate: string): string { const source = readFileSync( join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), 'utf8' ) - const start = source.indexOf('if (!hasLock) {') + const start = source.indexOf(gate) expect(start).toBeGreaterThanOrEqual(0) const end = source.indexOf('\n }', start) expect(end).toBeGreaterThan(start) @@ -59,7 +56,7 @@ function buildFixtureMain(termination: string): string { `const marker = process.env.${MARKER_ENV}`, `const mark = (name) => appendFileSync(marker, name + '\\n')`, `const SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE = ${SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE}`, - `mark('${LOCK_LOST}')`, + `mark('${GATE_ENTERED}')`, termination, `mark('${CONTINUED_INTO_STARTUP}')`, // Why: stand in for the rest of `src/main/index.ts`, which on the reported host was display init. @@ -70,15 +67,15 @@ function buildFixtureMain(termination: string): string { type FixtureRun = { status: number | null; markers: string[] } -function runLockLossGate(termination: string): FixtureRun { - const root = mkdtempSync(join(tmpdir(), 'orca-lock-loss-')) +function runPreReadyGate(termination: string): FixtureRun { + const root = mkdtempSync(join(tmpdir(), 'orca-pre-ready-exit-')) fixtureRoots.push(root) const dir = join(root, 'fixture') const marker = join(root, 'markers.log') mkdirSync(dir, { recursive: true }) writeFileSync( join(dir, 'package.json'), - '{ "name": "orca-lock-loss-fixture", "main": "main.js" }' + '{ "name": "orca-pre-ready-exit-fixture", "main": "main.js" }' ) writeFileSync(join(dir, 'main.js'), buildFixtureMain(termination)) writeFileSync(marker, '') @@ -96,23 +93,34 @@ function runLockLossGate(termination: string): FixtureRun { } } -describe('#11935 pre-ready lock-loss termination under real Electron', () => { +describe('pre-ready termination under real Electron', () => { it('stops the duplicate launch before any further startup runs, with the already-running code', () => { - const termination = readLockLossTermination() + const termination = readPreReadyTermination('if (!hasLock) {') // Why: an empty slice would let the fixture fall through to its own exit and pass vacuously. expect(termination).not.toBe('') - const run = runLockLossGate(termination) + const run = runPreReadyGate(termination) - expect(run.markers).toEqual([LOCK_LOST]) + expect(run.markers).toEqual([GATE_ENTERED]) expect(run.status).toBe(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) }, 90_000) + it('#17615 stops serve when display setup fails instead of entering Chromium startup', () => { + const termination = readPreReadyTermination( + 'if (state.isServeMode && !state.headlessBrowserDisplayAvailable) {' + ) + + const run = runPreReadyGate(termination) + + expect(run.markers).toEqual([GATE_ENTERED]) + expect(run.status).toBe(1) + }, 90_000) + it('reproduces the deferred graceful quit that let the doomed launch keep booting', () => { - const run = runLockLossGate('app.quit()') + const run = runPreReadyGate('app.quit()') // Why: pins the Electron semantic the fix rests on — pre-`ready` `quit()` schedules, it does not stop. - expect(run.markers).toEqual([LOCK_LOST, CONTINUED_INTO_STARTUP, REACHED_TAIL]) + expect(run.markers).toEqual([GATE_ENTERED, CONTINUED_INTO_STARTUP, REACHED_TAIL]) expect(run.status).not.toBe(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) }, 90_000) }) diff --git a/src/main/updater-events.test.ts b/src/main/updater-events.test.ts index 7a24483ca70..6a643ae64a5 100644 --- a/src/main/updater-events.test.ts +++ b/src/main/updater-events.test.ts @@ -1,14 +1,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { UpdateStatus } from '../shared/update-status-types' import type { registerAutoUpdaterHandlers } from './updater-events' -const { appMock, nativeUpdaterMock, getLinuxRootPackageTypeMock } = vi.hoisted(() => ({ +const { + appMock, + nativeUpdaterMock, + getLinuxPackageTypeMock, + getLinuxRootPackageTypeMock, + isExternallyManagedLinuxInstallMock +} = vi.hoisted(() => ({ appMock: { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: vi.fn() }, nativeUpdaterMock: { on: vi.fn() }, - getLinuxRootPackageTypeMock: vi.fn<() => 'deb' | 'rpm' | null>(() => 'deb') + getLinuxPackageTypeMock: vi.fn<() => 'deb' | 'rpm' | 'non-root' | 'unusable'>(() => 'deb'), + getLinuxRootPackageTypeMock: vi.fn<() => 'deb' | 'rpm' | null>(() => 'deb'), + isExternallyManagedLinuxInstallMock: vi.fn<() => boolean>(() => false) })) vi.mock('electron', () => ({ @@ -19,7 +28,9 @@ vi.mock('electron', () => ({ // Why: only the packaged-marker resolver is faked so the real artifact tracking runs. vi.mock('./linux-update-package-type', () => ({ - getLinuxRootPackageType: getLinuxRootPackageTypeMock + getLinuxPackageType: getLinuxPackageTypeMock, + getLinuxRootPackageType: getLinuxRootPackageTypeMock, + isExternallyManagedLinuxInstall: isExternallyManagedLinuxInstallMock })) vi.mock('./updater-changelog', () => ({ fetchChangelog: vi.fn().mockResolvedValue(null) })) @@ -59,7 +70,9 @@ function createContext(overrides?: Partial): HandlerContext { consumeMissingManifestPrereleaseFallbackResult: vi.fn(() => null), getPublishingWindowLastGoodCheck: vi.fn(() => null), getMissingManifestPrereleaseFallbackUserInitiated: vi.fn(() => null), - getCurrentStatus: vi.fn(() => ({ state: 'checking' }) as never), + getCurrentStatus: vi.fn( + () => ({ state: 'downloading', percent: 42, version: '1.0.61' }) as never + ), getActiveUpdateCheckEventAttemptId: vi.fn(() => 1), getKnownReleaseUrl: vi.fn(() => undefined), getPendingInstallVersion: vi.fn(() => '1.0.61'), @@ -107,7 +120,10 @@ describe('registerAutoUpdaterHandlers linux package artifact tracking', () => { appMock.on.mockReset() nativeUpdaterMock.on.mockReset() appMock.getVersion.mockReset().mockReturnValue('1.0.51') + getLinuxPackageTypeMock.mockReset().mockReturnValue('deb') getLinuxRootPackageTypeMock.mockReset().mockReturnValue('deb') + isExternallyManagedLinuxInstallMock.mockReset().mockReturnValue(false) + appMock.isPackaged = true }) const register = async ( @@ -142,6 +158,94 @@ describe('registerAutoUpdaterHandlers linux package artifact tracking', () => { }) }) + it.each(['deb', 'rpm'] as const)( + 'publishes manual-install recovery after a %s download', + async (packageType) => { + getLinuxPackageTypeMock.mockReturnValue(packageType) + getLinuxRootPackageTypeMock.mockReturnValue(packageType) + const { emit, context } = await register() + const fileName = packageType === 'deb' ? 'orca.deb' : 'orca.rpm' + + emit( + 'update-downloaded', + downloadedEvent({ + downloadedFile: `/home/tester/.cache/orca-updater/pending/${fileName}`, + files: [{ url: fileName, sha512: DEB_SHA512 }] + }) + ) + + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'error', + message: 'Quit Orca before running the system package install command.', + recovery: { + kind: 'linux-package-install', + packageType, + reason: 'manual-install-required', + version: '1.0.61' + } + }) + } + ) + + it.each([ + ['missing', [{ url: 'orca-ide_1.0.61_amd64.deb' }]], + ['malformed', [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: 'not-a-digest' }]] + ])('does not offer recovery when the package digest is %s', async (_kind, files) => { + const { emit, context, getArtifact } = await register() + + emit('update-downloaded', downloadedEvent({ files })) + + const status = { + state: 'error', + message: + 'The downloaded package metadata could not be verified. Quit Orca before downloading and installing the update from the official release page.', + version: '1.0.61', + retryable: false + } + expect(context.sendStatus).toHaveBeenLastCalledWith(status) + expect(context.sendStatus).not.toHaveBeenCalledWith( + expect.objectContaining({ recovery: expect.anything() }) + ) + expect(getArtifact()).toBeNull() + }) + + it('publishes the normal downloaded state for AppImage builds', async () => { + getLinuxPackageTypeMock.mockReturnValue('non-root') + getLinuxRootPackageTypeMock.mockReturnValue(null) + const { emit, context } = await register() + + emit('update-downloaded', downloadedEvent()) + if (process.platform === 'darwin') { + const handler = nativeUpdaterMock.on.mock.calls.find( + ([eventName]) => eventName === 'update-downloaded' + )?.[1] as (() => void) | undefined + handler?.() + } + + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'downloaded', + version: '1.0.61', + releaseUrl: undefined + }) + }) + + it('blocks downloaded-state handling when the packaged marker is unusable', async () => { + getLinuxPackageTypeMock.mockReturnValue('unusable') + getLinuxRootPackageTypeMock.mockReturnValue(null) + const { emit, context, getArtifact } = await register() + + emit('update-downloaded', downloadedEvent()) + + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'error', + message: + 'Orca could not verify the installed Linux package format, so it will not install this update automatically. Download the update from the official release page and install it manually.', + version: '1.0.61', + retryable: false + }) + expect(getArtifact()).toBeNull() + }) + it('passes the actual updater error into the install-failure handler', async () => { const handleQuitAndInstallFailure = vi.fn<(error?: unknown) => boolean>(() => true) const { emit, context } = await register({ handleQuitAndInstallFailure }) @@ -155,13 +259,64 @@ describe('registerAutoUpdaterHandlers linux package artifact tracking', () => { expect(context.sendErrorStatus).not.toHaveBeenCalled() }) - it('drops the artifact once the update resolves as not available', async () => { - const { emit, getArtifact } = await register() + it('keeps manual-install recovery when a later check finds no newer release', async () => { + const { emit, context, getArtifact } = await register() emit('update-downloaded', downloadedEvent()) + // Why: the download already produced this exact status, so the assertion below could pass + // on that call alone. Clear it so only the second emit can satisfy it. + vi.mocked(context.sendStatus).mockClear() + + emit('update-not-available') + + expect(getArtifact()).toEqual(expect.objectContaining({ version: '1.0.61' })) + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'error', + message: 'Quit Orca before running the system package install command.', + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'manual-install-required', + version: '1.0.61' + } + }) + }) + + it('keeps manual-install recovery when a later check finds only the installed release', async () => { + const { emit, context, getArtifact } = await register() + emit('update-downloaded', downloadedEvent()) + + // Why: the download already produced this exact status, so the assertion below could pass + // on that call alone. Clear it so only the second emit can satisfy it. + vi.mocked(context.sendStatus).mockClear() + + emit('update-available', { version: '1.0.51' }) + + expect(getArtifact()).toEqual(expect.objectContaining({ version: '1.0.61' })) + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'error', + message: 'Quit Orca before running the system package install command.', + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'manual-install-required', + version: '1.0.61' + } + }) + }) + + it('clears recovery when a newer update takes over before no-update settles', async () => { + const { emit, context, getArtifact } = await register() + emit('update-downloaded', downloadedEvent()) + + emit('update-available', { version: '1.0.62' }) emit('update-not-available') expect(getArtifact()).toBeNull() + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'not-available', + userInitiated: undefined + }) }) it('drops the artifact when another version takes over the cycle', async () => { @@ -173,6 +328,74 @@ describe('registerAutoUpdaterHandlers linux package artifact tracking', () => { expect(getArtifact()).toBeNull() }) + it('ignores a downloaded event for an older target', async () => { + const { emit, context, getArtifact } = await register({ + getCurrentStatus: vi.fn(() => ({ state: 'available', version: '1.0.62' }) as never), + getPendingInstallVersion: vi.fn(() => '1.0.62') + }) + + emit('update-downloaded', downloadedEvent()) + + expect(getArtifact()).toBeNull() + expect(context.sendStatus).not.toHaveBeenCalled() + }) + + it.each([ + ['idle', { state: 'idle' }], + ['not-available', { state: 'not-available' }], + ['check error', { state: 'error', message: 'check failed' }] + ] as const)( + 'ignores a downloaded event after the target is no longer active (%s)', + async (_name, status) => { + const { emit, context, getArtifact } = await register({ + getCurrentStatus: vi.fn(() => status as never), + getPendingInstallVersion: vi.fn(() => '') + }) + + emit('update-downloaded', downloadedEvent()) + + expect(getArtifact()).toBeNull() + expect(context.sendStatus).not.toHaveBeenCalled() + } + ) + + it('accepts a matching event when the pending cache target was cleared', async () => { + const { emit, context, getArtifact } = await register({ + getCurrentStatus: vi.fn( + () => ({ state: 'downloading', percent: 42, version: '1.0.61' }) as never + ), + getPendingInstallVersion: vi.fn(() => '') + }) + + emit('update-downloaded', downloadedEvent()) + + expect(getArtifact()).toEqual(expect.objectContaining({ version: '1.0.61' })) + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'error', + message: 'Quit Orca before running the system package install command.', + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'manual-install-required', + version: '1.0.61' + } + }) + }) + + it('ignores a downloaded event when the active status and pending target disagree', async () => { + const { emit, context, getArtifact } = await register({ + getCurrentStatus: vi.fn( + () => ({ state: 'downloading', percent: 42, version: '1.0.62' }) as never + ), + getPendingInstallVersion: vi.fn(() => '1.0.62') + }) + + emit('update-downloaded', downloadedEvent()) + + expect(getArtifact()).toBeNull() + expect(context.sendStatus).not.toHaveBeenCalled() + }) + it('drops the artifact when progress reports a different pending version', async () => { const { emit, getArtifact } = await register({ getPendingInstallVersion: vi.fn(() => '1.0.62') @@ -184,13 +407,38 @@ describe('registerAutoUpdaterHandlers linux package artifact tracking', () => { expect(getArtifact()).toBeNull() }) - it('keeps the artifact through a same-version recheck', async () => { - const { emit, getArtifact } = await register() - emit('update-downloaded', downloadedEvent()) + // #17702: the externallyManaged flag is spread onto the fallback object only, so a retained + // manual-install status must still win. Cross-version case: the host could self-update when it + // downloaded, and cannot now. + it.each([false, true])( + 'keeps manual-install recovery through a same-version recheck (externallyManaged=%s)', + async (externallyManaged) => { + isExternallyManagedLinuxInstallMock.mockReturnValue(externallyManaged) + let status: UpdateStatus = { state: 'downloading', percent: 100, version: '1.0.61' } + const { emit, context, getArtifact } = await register({ + getCurrentStatus: vi.fn(() => status) + }) + emit('update-downloaded', downloadedEvent()) - emit('update-available', { version: '1.0.61' }) - emit('download-progress', { percent: 100 }) + // Why: the download already emitted the manual-install status, so waitFor would pass on that + // call alone. Clear it so the assertion can only be satisfied by the recheck. + vi.mocked(context.sendStatus).mockClear() + status = { state: 'checking' } + emit('update-available', { version: '1.0.61' }) - expect(getArtifact()).toEqual(expect.objectContaining({ version: '1.0.61', path: DEB_PATH })) - }) + expect(getArtifact()).toEqual(expect.objectContaining({ version: '1.0.61', path: DEB_PATH })) + await vi.waitFor(() => + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'error', + message: 'Quit Orca before running the system package install command.', + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'manual-install-required', + version: '1.0.61' + } + }) + ) + } + ) }) diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index b77cd8a8cc5..d2b7f2a1e83 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -1,11 +1,8 @@ -import { app, autoUpdater as nativeUpdater } from 'electron' +import { app } from 'electron' import type { UpdateStatus } from '../shared/update-status-types' import { - consumeMacInstallGuardBypass, - deferMacQuitUntilInstallerReady, - handleMacInstallerReady, isMacInstallerReady, - isMacQuitAndInstallInFlight, + registerMacUpdaterEvents, resetMacInstallState } from './updater-mac-install' import { compareVersions } from './updater-fallback' @@ -13,10 +10,12 @@ import { fetchChangelog } from './updater-changelog' import type { ElectronAutoUpdater } from './electron-updater-loader' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { - captureLinuxPackageArtifact, - clearTrackedLinuxPackageArtifact, - clearTrackedLinuxPackageArtifactForOtherVersion -} from './linux-package-update-recovery' + getRetainedLinuxPackageManualInstallStatus, + resolveLinuxPackageDownloadedStatus, + shouldIgnoreDownloadedUpdateEvent +} from './linux-package-downloaded-status' +import { isExternallyManagedLinuxInstall } from './linux-update-package-type' +import * as linuxPackageRecovery from './linux-package-update-recovery' const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 @@ -101,47 +100,14 @@ export function registerAutoUpdaterHandlers({ setAvailableVersion, setUserInitiatedCheck }: UpdaterHandlerContext): void { - // Why: electron-updater fires 'update-downloaded' before Squirrel.Mac finishes; track readiness to avoid a premature "ready". - if (process.platform === 'darwin') { - nativeUpdater.on('update-downloaded', () => { - const hasInstallableVersion = hasInstallableDownloadedVersion() - handleMacInstallerReady(hasInstallableVersion, performQuitAndInstall, () => { - // Send the held status only while its staged build is still installable. - sendStatus({ - state: 'downloaded', - version: getPendingInstallVersion(), - releaseUrl: getKnownReleaseUrl() - }) - }) - }) - } - - app.on('before-quit', (event) => { - if (!shouldDeferMacQuitForInstall()) { - return - } - if (consumeMacInstallGuardBypass()) { - recordUpdaterLifecycle('macos_before_quit_guard_bypassed') - return - } - if (isMacQuitAndInstallInFlight()) { - return - } - - // Why: quitting before Squirrel.Mac finishes staging leaves nothing to install; hold the quit until it's ready. - if ( - deferMacQuitUntilInstallerReady( - getCurrentStatus(), - hasInstallableDownloadedVersion(), - getPendingInstallVersion, - sendStatus - ) - ) { - recordUpdaterLifecycle('macos_before_quit_deferred', { - version: getPendingInstallVersion() - }) - event.preventDefault() - } + registerMacUpdaterEvents({ + getCurrentStatus, + hasInstallableDownloadedVersion, + getPendingInstallVersion, + getKnownReleaseUrl, + performQuitAndInstall, + shouldDeferMacQuitForInstall, + sendStatus }) autoUpdater.on('checking-for-update', () => { @@ -185,13 +151,18 @@ export function registerAutoUpdaterHandlers({ scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) } } - sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined }) + sendStatus( + getRetainedLinuxPackageManualInstallStatus() ?? { + state: 'not-available', + userInitiated: wasUserInitiated || undefined + } + ) return } // Why: only a genuinely newer offer supersedes the retained package; a publishing-window blip that // momentarily resolves an older tag must not destroy a still-valid recovery path. - clearTrackedLinuxPackageArtifactForOtherVersion(info.version) + linuxPackageRecovery.clearTrackedLinuxPackageArtifactForOtherVersion(info.version) // Why: fetch the changelog in main to avoid renderer-side CORS on onorca.dev. markUpdateAvailableEventPending(attemptId) @@ -228,7 +199,15 @@ export function registerAutoUpdaterHandlers({ } } - sendStatus({ state: 'available', version: info.version, changelog }) + sendStatus( + getRetainedLinuxPackageManualInstallStatus() ?? { + state: 'available', + version: info.version, + changelog, + // Why: the offer is real, but this host can never apply it — say so before a download is offered. + ...(isExternallyManagedLinuxInstall() ? { externallyManaged: true } : {}) + } + ) } finally { clearUpdateAvailableEventPending(attemptId) } @@ -241,7 +220,7 @@ export function registerAutoUpdaterHandlers({ } clearBackgroundCheckLaunchPending() resetMacInstallState() - clearTrackedLinuxPackageArtifact() + const retainedStatus = getRetainedLinuxPackageManualInstallStatus() const missingManifestFallback = consumeMissingManifestPrereleaseFallbackResult() const publishingWindowLastGoodCheck = getPublishingWindowLastGoodCheck() const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck() @@ -262,7 +241,11 @@ export function registerAutoUpdaterHandlers({ } } } - sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined }) + // Why: a later check can report no newer release while a verified deb/rpm is still waiting for + // the user to install it outside Orca. Keep both the artifact and its recovery card reachable. + sendStatus( + retainedStatus ?? { state: 'not-available', userInitiated: wasUserInitiated || undefined } + ) if (localBuildCheck || pinnedBuildCheck) { restoreReleaseUpdateSource() } @@ -271,7 +254,7 @@ export function registerAutoUpdaterHandlers({ autoUpdater.on('download-progress', (progress) => { clearBackgroundCheckLaunchPending() const version = getPendingInstallVersion() - clearTrackedLinuxPackageArtifactForOtherVersion(version) + linuxPackageRecovery.clearTrackedLinuxPackageArtifactForOtherVersion(version) sendStatus({ state: 'downloading', percent: Math.round(progress.percent), @@ -280,6 +263,16 @@ export function registerAutoUpdaterHandlers({ }) autoUpdater.on('update-downloaded', (info) => { + // Why: an earlier download can finish after a newer target replaced it; uncached pre-staged events have no target to compare. + if ( + shouldIgnoreDownloadedUpdateEvent( + getCurrentStatus(), + info.version, + getPendingInstallVersion() + ) + ) { + return + } clearBackgroundCheckLaunchPending() // Release downloads remain newer-only; the local source was validated before checking, and a pinned jump is explicit. if ( @@ -288,14 +281,17 @@ export function registerAutoUpdaterHandlers({ compareVersions(info.version, app.getVersion()) <= 0 ) { clearAvailableUpdateContext() - clearTrackedLinuxPackageArtifact() + linuxPackageRecovery.clearTrackedLinuxPackageArtifact() sendStatus({ state: 'not-available' }) return } - // Why: retain the verified artifact now — the 'error' event after a failed install no longer carries it. - captureLinuxPackageArtifact(info) const macInstallerReady = process.platform === 'darwin' ? isMacInstallerReady() : true recordUpdaterLifecycle('update_downloaded', { version: info.version, macInstallerReady }) + const linuxPackageStatus = resolveLinuxPackageDownloadedStatus(info) + if (linuxPackageStatus) { + sendStatus(linuxPackageStatus) + return + } // On macOS, defer 'downloaded' until Squirrel.Mac finishes processing; other platforms are ready immediately. if (process.platform === 'darwin' && !macInstallerReady) { // Keep the UI at 100% downloaded while Squirrel processes, to avoid a premature "ready to install". diff --git a/src/main/updater-fallback.ts b/src/main/updater-fallback.ts index 22cfb049555..62ec3ff3b8c 100644 --- a/src/main/updater-fallback.ts +++ b/src/main/updater-fallback.ts @@ -49,13 +49,12 @@ export function statusesEqual(left: UpdateStatus, right: UpdateStatus): boolean return ( right.state === 'error' && left.message === right.message && + left.version === right.version && + left.retryable === right.retryable && left.userInitiated === right.userInitiated && left.activeNudgeId === right.activeNudgeId && - // Why: clearing recovery must reach the renderer even when the message is unchanged, or dead actions stay enabled. - left.recovery?.kind === right.recovery?.kind && - left.recovery?.packageType === right.recovery?.packageType && - left.recovery?.reason === right.recovery?.reason && - left.recovery?.version === right.recovery?.version + // Recovery identity fences async actions, so same-valued recaptures must reach the renderer. + left.recovery === right.recovery ) } } diff --git a/src/main/updater-linux-package-recovery-actions.test.ts b/src/main/updater-linux-package-recovery-actions.test.ts index ff0b974bf7d..9a546dd8fac 100644 --- a/src/main/updater-linux-package-recovery-actions.test.ts +++ b/src/main/updater-linux-package-recovery-actions.test.ts @@ -10,8 +10,8 @@ const { getTrackedLinuxPackageArtifactMock, recordUpdaterLifecycleMock, resolveLinuxPackageInstallInstructionsMock, - revalidateLinuxPackageForInstallMock, - revealLinuxPackageMock, + resolveLinuxPackageRevealTargetMock, + showItemInFolderMock, resetHandlers } = vi.hoisted(() => { const updaterHandlers = new Map void)[]>() @@ -44,8 +44,8 @@ const { getTrackedLinuxPackageArtifactMock: vi.fn(), recordUpdaterLifecycleMock: vi.fn(), resolveLinuxPackageInstallInstructionsMock: vi.fn(), - revalidateLinuxPackageForInstallMock: vi.fn(), - revealLinuxPackageMock: vi.fn(), + resolveLinuxPackageRevealTargetMock: vi.fn(), + showItemInFolderMock: vi.fn(), resetHandlers: () => updaterHandlers.clear() } }) @@ -55,6 +55,7 @@ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: vi.fn(() => []) }, autoUpdater: { on: vi.fn() }, powerMonitor: { on: vi.fn() }, + shell: { showItemInFolder: showItemInFolderMock }, net: { fetch: vi.fn() } })) @@ -78,15 +79,18 @@ vi.mock('./update-install-exit-watchdog', () => ({ vi.mock('./updater-lifecycle-diagnostics', () => ({ recordUpdaterLifecycle: recordUpdaterLifecycleMock })) -vi.mock('./linux-update-package-type', () => ({ getLinuxRootPackageType: () => 'deb' })) +vi.mock('./linux-update-package-type', () => ({ + getLinuxPackageType: () => 'deb', + getLinuxRootPackageType: () => 'deb', + isExternallyManagedLinuxInstall: () => false +})) vi.mock('./linux-package-update-recovery', () => ({ - captureLinuxPackageArtifact: vi.fn(), + captureLinuxPackageArtifact: vi.fn(() => getTrackedLinuxPackageArtifactMock()), clearTrackedLinuxPackageArtifact: clearTrackedLinuxPackageArtifactMock, clearTrackedLinuxPackageArtifactForOtherVersion: vi.fn(), getTrackedLinuxPackageArtifact: getTrackedLinuxPackageArtifactMock, resolveLinuxPackageInstallInstructions: resolveLinuxPackageInstallInstructionsMock, - revalidateLinuxPackageForInstall: revalidateLinuxPackageForInstallMock, - revealLinuxPackage: revealLinuxPackageMock + resolveLinuxPackageRevealTarget: resolveLinuxPackageRevealTargetMock })) const ARTIFACT = { @@ -95,6 +99,16 @@ const ARTIFACT = { path: '/home/tester/.cache/orca-updater/pending/orca-ide_1.0.61_amd64.deb', sha512: 'LHlL7dKoqg98gS2nfQv878dK+UoktbAkm4M20/hoJ2Qr0Kqsa3MSL4VmWy/Lll/MYjQFkpvOxduQ/vswentozA==' } +const MANUAL_INSTALL_STATUS = { + state: 'error', + message: 'Quit Orca before running the system package install command.', + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'manual-install-required', + version: '1.0.61' + } +} as const satisfies UpdateStatus warmUpdaterModule() @@ -108,7 +122,7 @@ describe('linux package recovery actions', () => { vi.useFakeTimers() resetHandlers() autoUpdaterMock.checkForUpdates.mockReset().mockResolvedValue(null) - autoUpdaterMock.downloadUpdate.mockReset() + autoUpdaterMock.downloadUpdate.mockReset().mockResolvedValue([]) autoUpdaterMock.quitAndInstall.mockReset() autoUpdaterMock.setFeedURL.mockReset() autoUpdaterMock.on.mockClear() @@ -119,8 +133,10 @@ describe('linux package recovery actions', () => { resolveLinuxPackageInstallInstructionsMock .mockReset() .mockResolvedValue({ ok: true, command: "sudo apt install -- ''", packageFileName: 'p' }) - revalidateLinuxPackageForInstallMock.mockReset().mockResolvedValue({ ok: true }) - revealLinuxPackageMock.mockReset().mockResolvedValue({ ok: true }) + resolveLinuxPackageRevealTargetMock + .mockReset() + .mockResolvedValue({ ok: true, path: ARTIFACT.path }) + showItemInFolderMock.mockReset() }) const startUpdater = async (): Promise<{ @@ -135,13 +151,19 @@ describe('linux package recovery actions', () => { return { send, updater } } - /** Drives a pre-commit install failure so the status carries the recovery discriminant. */ - const failInstall = async (updater: typeof UpdaterModule): Promise => { - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.emit('error', new Error('Command failed, exited with code 127')) + const activateRecovery = async ( + updater: typeof UpdaterModule, + version = '1.0.61' + ): Promise => { + autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => autoUpdaterMock.emit('update-available', { version })) + return Promise.resolve(null) }) - updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + updater.downloadUpdate() + autoUpdaterMock.emit('update-downloaded', { version }) } type ErrorStatus = Extract @@ -162,12 +184,12 @@ describe('linux package recovery actions', () => { 'No package install recovery is available.' ) expect(resolveLinuxPackageInstallInstructionsMock).not.toHaveBeenCalled() - expect(revealLinuxPackageMock).not.toHaveBeenCalled() + expect(resolveLinuxPackageRevealTargetMock).not.toHaveBeenCalled() }) - it('revalidates the retained package on every invocation', async () => { + it('validates the retained package on every invocation', async () => { const { updater } = await startUpdater() - await failInstall(updater) + await activateRecovery(updater) await expect(updater.getLinuxPackageInstallInstructions()).resolves.toEqual({ ok: true, @@ -181,16 +203,74 @@ describe('linux package recovery actions', () => { const recovery = { kind: 'linux-package-install', packageType: 'deb', - reason: 'package-install-failed', + reason: 'manual-install-required', version: '1.0.61' } expect(resolveLinuxPackageInstallInstructionsMock.mock.calls).toEqual([[recovery], [recovery]]) - expect(revealLinuxPackageMock.mock.calls).toEqual([[recovery], [recovery]]) + expect(resolveLinuxPackageRevealTargetMock.mock.calls).toEqual([[recovery], [recovery]]) + expect(showItemInFolderMock).toHaveBeenCalledTimes(2) + }) + + it('restores recovery after a recheck resolves without a terminal event', async () => { + const { send, updater } = await startUpdater() + await activateRecovery(updater) + send.mockClear() + + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(1_000) + + expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS) + await expect(updater.showLinuxPackage()).resolves.toBeUndefined() + }) + + it('restores recovery after a recheck fails', async () => { + const { send, updater } = await startUpdater() + await activateRecovery(updater) + autoUpdaterMock.checkForUpdates.mockRejectedValueOnce(new Error('offline')) + send.mockClear() + + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + + expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS) + await expect(updater.getLinuxPackageInstallInstructions()).resolves.toEqual({ + ok: true, + command: "sudo apt install -- ''", + packageFileName: 'p' + }) + }) + + it('restores recovery when a pinned check resolves to the current version', async () => { + const { send, updater } = await startUpdater() + await activateRecovery(updater) + send.mockClear() + + updater.checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'v1.0.51' }) + await vi.advanceTimersByTimeAsync(0) + + expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS) + await expect(updater.showLinuxPackage()).resolves.toBeUndefined() + }) + + it('restores recovery when resolving a pinned check fails', async () => { + const { send, updater } = await startUpdater() + await activateRecovery(updater) + send.mockClear() + + updater.checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'not-a-release-tag' }) + await vi.advanceTimersByTimeAsync(0) + + expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS) + await expect(updater.getLinuxPackageInstallInstructions()).resolves.toEqual({ + ok: true, + command: "sudo apt install -- ''", + packageFileName: 'p' + }) }) it('replaces the structured status when revalidation fails so stale actions die', async () => { const { send, updater } = await startUpdater() - await failInstall(updater) + await activateRecovery(updater) resolveLinuxPackageInstallInstructionsMock.mockResolvedValue({ ok: false, reason: 'hash-mismatch' @@ -203,6 +283,7 @@ describe('linux package recovery actions', () => { expect(clearTrackedLinuxPackageArtifactMock).toHaveBeenCalledTimes(1) const latest = errorStatuses(send).at(-1) expect(latest?.state === 'error' && latest.recovery).toBeUndefined() + expect(latest?.version).toBe('1.0.61') expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( 'linux_package_recovery_unavailable', { reason: 'hash-mismatch', packageType: 'deb', version: '1.0.61' }, @@ -212,13 +293,13 @@ describe('linux package recovery actions', () => { await expect(updater.showLinuxPackage()).rejects.toThrow( 'No package install recovery is available.' ) - expect(revealLinuxPackageMock).not.toHaveBeenCalled() + expect(resolveLinuxPackageRevealTargetMock).not.toHaveBeenCalled() }) it('clears recovery for both actions once the package is gone', async () => { const { updater } = await startUpdater() - await failInstall(updater) - revealLinuxPackageMock.mockResolvedValue({ ok: false, reason: 'missing' }) + await activateRecovery(updater) + resolveLinuxPackageRevealTargetMock.mockResolvedValue({ ok: false, reason: 'missing' }) await expect(updater.showLinuxPackage()).rejects.toThrow('no longer in the update cache') @@ -231,7 +312,7 @@ describe('linux package recovery actions', () => { 'resolves %s as a result and keeps the card usable instead of rejecting', async (reason) => { const { send, updater } = await startUpdater() - await failInstall(updater) + await activateRecovery(updater) resolveLinuxPackageInstallInstructionsMock.mockResolvedValue({ ok: false, reason }) const statusesBefore = errorStatuses(send).length @@ -256,8 +337,10 @@ describe('linux package recovery actions', () => { it('keeps recovery available after a transient read failure', async () => { const { send, updater } = await startUpdater() - await failInstall(updater) - revealLinuxPackageMock.mockResolvedValue({ ok: false, reason: 'read-failed' }) + await activateRecovery(updater) + showItemInFolderMock.mockImplementationOnce(() => { + throw new Error('no file manager available') + }) const statusesBefore = errorStatuses(send).length await expect(updater.showLinuxPackage()).rejects.toThrow( @@ -267,13 +350,12 @@ describe('linux package recovery actions', () => { // Why: a read error is not evidence the artifact is bad, so retrying must stay possible. expect(clearTrackedLinuxPackageArtifactMock).not.toHaveBeenCalled() expect(errorStatuses(send)).toHaveLength(statusesBefore) - revealLinuxPackageMock.mockResolvedValue({ ok: true }) await expect(updater.showLinuxPackage()).resolves.toBeUndefined() }) - it('ignores a stale mismatch verdict once a newer recovery replaced the card', async () => { + it('ignores a stale mismatch after the same package cycle is captured again', async () => { const { send, updater } = await startUpdater() - await failInstall(updater) + await activateRecovery(updater) let settleValidation!: (result: { ok: false; reason: 'hash-mismatch' }) => void resolveLinuxPackageInstallInstructionsMock.mockReturnValue( new Promise((resolve) => { @@ -283,12 +365,51 @@ describe('linux package recovery actions', () => { const pending = updater.getLinuxPackageInstallInstructions() // A 160 MB hash outlives the cycle it started in; a newer download takes over meanwhile. - getTrackedLinuxPackageArtifactMock.mockReturnValue({ ...ARTIFACT, version: '1.0.62' }) - await failInstall(updater) + getTrackedLinuxPackageArtifactMock.mockReturnValue({ ...ARTIFACT }) + await activateRecovery(updater) settleValidation({ ok: false, reason: 'hash-mismatch' }) - await expect(pending).rejects.toThrow('no longer matches the verified release') + await expect(pending).rejects.toThrow('Package install recovery is no longer current.') expect(clearTrackedLinuxPackageArtifactMock).not.toHaveBeenCalled() - expect(errorStatuses(send).at(-1)?.recovery?.version).toBe('1.0.62') + expect(errorStatuses(send).at(-1)?.recovery?.version).toBe('1.0.61') + }) + + it('does not return stale instructions after a same-version recapture', async () => { + const { send, updater } = await startUpdater() + await activateRecovery(updater) + let settleValidation!: (result: { ok: true; command: string; packageFileName: string }) => void + resolveLinuxPackageInstallInstructionsMock.mockReturnValue( + new Promise((resolve) => { + settleValidation = resolve + }) + ) + + const pending = updater.getLinuxPackageInstallInstructions() + getTrackedLinuxPackageArtifactMock.mockReturnValue({ ...ARTIFACT }) + await activateRecovery(updater) + const statusesBefore = errorStatuses(send).length + settleValidation({ ok: true, command: 'stale command', packageFileName: 'stale.deb' }) + + await expect(pending).rejects.toThrow('Package install recovery is no longer current.') + expect(errorStatuses(send)).toHaveLength(statusesBefore) + }) + + it('does not reveal a stale path after a same-version recapture', async () => { + const { updater } = await startUpdater() + await activateRecovery(updater) + let settleValidation!: (result: { ok: true; path: string }) => void + resolveLinuxPackageRevealTargetMock.mockReturnValue( + new Promise((resolve) => { + settleValidation = resolve + }) + ) + + const pending = updater.showLinuxPackage() + getTrackedLinuxPackageArtifactMock.mockReturnValue({ ...ARTIFACT }) + await activateRecovery(updater) + settleValidation({ ok: true, path: ARTIFACT.path }) + + await expect(pending).rejects.toThrow('Package install recovery is no longer current.') + expect(showItemInFolderMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/updater-mac-install.ts b/src/main/updater-mac-install.ts index e2441d64180..234cdc0b2c4 100644 --- a/src/main/updater-mac-install.ts +++ b/src/main/updater-mac-install.ts @@ -1,9 +1,66 @@ -import { app } from 'electron' +import { app, autoUpdater as nativeUpdater } from 'electron' import type { UpdateStatus } from '../shared/update-status-types' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' const MAC_INSTALL_READY_TIMEOUT_MS = 15000 +export function registerMacUpdaterEvents({ + getCurrentStatus, + hasInstallableDownloadedVersion, + getPendingInstallVersion, + getKnownReleaseUrl, + performQuitAndInstall, + shouldDeferMacQuitForInstall, + sendStatus +}: { + getCurrentStatus: () => UpdateStatus + hasInstallableDownloadedVersion: () => boolean + getPendingInstallVersion: () => string + getKnownReleaseUrl: () => string | undefined + performQuitAndInstall: () => void | Promise + shouldDeferMacQuitForInstall: () => boolean + sendStatus: (status: UpdateStatus) => void +}): void { + if (process.platform === 'darwin') { + nativeUpdater.on('update-downloaded', () => { + const hasInstallableVersion = hasInstallableDownloadedVersion() + handleMacInstallerReady(hasInstallableVersion, performQuitAndInstall, () => { + sendStatus({ + state: 'downloaded', + version: getPendingInstallVersion(), + releaseUrl: getKnownReleaseUrl() + }) + }) + }) + } + + app.on('before-quit', (event) => { + if (!shouldDeferMacQuitForInstall()) { + return + } + if (consumeMacInstallGuardBypass()) { + recordUpdaterLifecycle('macos_before_quit_guard_bypassed') + return + } + if (isMacQuitAndInstallInFlight()) { + return + } + if ( + deferMacQuitUntilInstallerReady( + getCurrentStatus(), + hasInstallableDownloadedVersion(), + getPendingInstallVersion, + sendStatus + ) + ) { + recordUpdaterLifecycle('macos_before_quit_deferred', { + version: getPendingInstallVersion() + }) + event.preventDefault() + } + }) +} + /** Whether Squirrel.Mac has finished downloading the update from the localhost proxy. */ let squirrelReady = false /** Remembers a user/app quit request that arrived before Squirrel.Mac had a diff --git a/src/main/updater-test-harness.ts b/src/main/updater-test-harness.ts index 4a3315862f3..36687d0a79e 100644 --- a/src/main/updater-test-harness.ts +++ b/src/main/updater-test-harness.ts @@ -4,6 +4,7 @@ import { clearTrackedRealTimers, trackRealTimers } from './updater-test-timer-tr /** Loose spy signature for the electron/electron-updater calls the suites only assert on. */ type UpdaterSpy = Mock<(...args: unknown[]) => unknown> +type LinuxPackageType = 'deb' | 'rpm' | 'non-root' | 'unusable' type AutoUpdaterMock = { autoDownload: boolean @@ -44,7 +45,11 @@ type UpdaterModuleFactories = { electronUpdaterLoader: () => { loadElectronAutoUpdater: () => AutoUpdaterMock } electronToolkitUtils: () => { is: { dev: boolean } } ipcPty: () => { killAllPty: UpdaterSpy } - linuxUpdatePackageType: () => { getLinuxRootPackageType: Mock<() => 'deb' | 'rpm' | null> } + linuxUpdatePackageType: () => { + getLinuxPackageType: Mock<() => LinuxPackageType> + getLinuxRootPackageType: Mock<() => 'deb' | 'rpm' | null> + isExternallyManagedLinuxInstall: Mock<() => boolean> + } updaterLifecycleDiagnostics: () => { recordUpdaterLifecycle: UpdaterSpy } updaterChangelog: () => { fetchChangelog: UpdaterSpy } updaterNudge: () => { fetchNudge: UpdaterSpy; shouldApplyNudge: UpdaterSpy } @@ -68,7 +73,9 @@ export type UpdaterMocks = { isMock: { dev: boolean } killAllPtyMock: UpdaterSpy powerMonitorOnMock: UpdaterSpy + getLinuxPackageTypeMock: Mock<() => LinuxPackageType> getLinuxRootPackageTypeMock: Mock<() => 'deb' | 'rpm' | null> + isExternallyManagedLinuxInstallMock: Mock<() => boolean> recordUpdaterLifecycleMock: UpdaterSpy fetchChangelogMock: UpdaterSpy fetchNudgeMock: UpdaterSpy @@ -206,6 +213,10 @@ export function createUpdaterMocks(): UpdaterMocks { const killAllPtyMock = vi.fn() const powerMonitorOnMock = vi.fn() const getLinuxRootPackageTypeMock = vi.fn<() => 'deb' | 'rpm' | null>(() => null) + const getLinuxPackageTypeMock = vi.fn<() => LinuxPackageType>(() => { + return getLinuxRootPackageTypeMock() ?? 'non-root' + }) + const isExternallyManagedLinuxInstallMock = vi.fn<() => boolean>(() => false) const recordUpdaterLifecycleMock = vi.fn() const fetchChangelogMock = vi.fn() const fetchNudgeMock = vi.fn() @@ -232,7 +243,11 @@ export function createUpdaterMocks(): UpdaterMocks { electronToolkitUtils: () => ({ is: isMock }), ipcPty: () => ({ killAllPty: killAllPtyMock }), // Why: only the marker resolver is faked so the real artifact capture/redaction path stays under test. - linuxUpdatePackageType: () => ({ getLinuxRootPackageType: getLinuxRootPackageTypeMock }), + linuxUpdatePackageType: () => ({ + getLinuxPackageType: getLinuxPackageTypeMock, + getLinuxRootPackageType: getLinuxRootPackageTypeMock, + isExternallyManagedLinuxInstall: isExternallyManagedLinuxInstallMock + }), updaterLifecycleDiagnostics: () => ({ recordUpdaterLifecycle: recordUpdaterLifecycleMock }), updaterChangelog: () => ({ fetchChangelog: fetchChangelogMock }), updaterNudge: () => ({ fetchNudge: fetchNudgeMock, shouldApplyNudge: shouldApplyNudgeMock }), @@ -276,6 +291,10 @@ export function createUpdaterMocks(): UpdaterMocks { disarmExitWatchdogMock.mockReset() powerMonitorOnMock.mockReset() getLinuxRootPackageTypeMock.mockReset().mockReturnValue(null) + getLinuxPackageTypeMock.mockReset().mockImplementation(() => { + return getLinuxRootPackageTypeMock() ?? 'non-root' + }) + isExternallyManagedLinuxInstallMock.mockReset().mockReturnValue(false) recordUpdaterLifecycleMock.mockReset() fetchNudgeMock.mockReset().mockResolvedValue(null) shouldApplyNudgeMock.mockReset().mockReturnValue(false) @@ -306,7 +325,9 @@ export function createUpdaterMocks(): UpdaterMocks { isMock, killAllPtyMock, powerMonitorOnMock, + getLinuxPackageTypeMock, getLinuxRootPackageTypeMock, + isExternallyManagedLinuxInstallMock, recordUpdaterLifecycleMock, fetchChangelogMock, fetchNudgeMock, diff --git a/src/main/updater.fallback.test.ts b/src/main/updater.fallback.test.ts index 9cbcc97c85e..767fef421e3 100644 --- a/src/main/updater.fallback.test.ts +++ b/src/main/updater.fallback.test.ts @@ -86,6 +86,23 @@ describe('statusesEqual', () => { ).toBe(false) expect(statusesEqual(withRecovery, { ...withRecovery })).toBe(true) }) + + it('delivers a same-valued recovery recaptured for a new package cycle', () => { + expect(statusesEqual(withRecovery, { ...withRecovery, recovery: { ...recovery } })).toBe(false) + }) + + it('separates generic errors by version and retryability', () => { + const error: UpdateStatus = { + state: 'error', + message: 'package unavailable', + version: '1.0.61', + retryable: false + } + + expect(statusesEqual(error, { ...error, version: '1.0.62' })).toBe(false) + expect(statusesEqual(error, { ...error, retryable: true })).toBe(false) + expect(statusesEqual(error, { ...error })).toBe(true) + }) }) describe('isReleaseAssetsPublishingFailure', () => { diff --git a/src/main/updater.headless-serve-install.test.ts b/src/main/updater.headless-serve-install.test.ts index 08e2f519861..bae6474cc66 100644 --- a/src/main/updater.headless-serve-install.test.ts +++ b/src/main/updater.headless-serve-install.test.ts @@ -77,6 +77,11 @@ vi.mock('electron', () => ({ vi.mock('electron-updater', () => ({ autoUpdater: autoUpdaterMock })) vi.mock('./electron-updater-loader', () => ({ loadElectronAutoUpdater: () => autoUpdaterMock })) +vi.mock('./linux-update-package-type', () => ({ + getLinuxPackageType: () => 'non-root', + getLinuxRootPackageType: () => null, + isExternallyManagedLinuxInstall: () => false +})) vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } })) vi.mock('./ipc/pty', () => ({ killAllPty: killAllPtyMock })) vi.mock('./updater-changelog', () => ({ fetchChangelog: vi.fn().mockResolvedValue(null) })) @@ -166,6 +171,7 @@ describe('headless serve update install handoff', () => { checkForUpdatesFromMenu() await vi.advanceTimersByTimeAsync(0) + autoUpdaterMock.emit('download-progress', { percent: 100 }) autoUpdaterMock.emit('update-downloaded', { version: pendingInstaller.version }) const nativeReadyHandler = nativeUpdaterMock.on.mock.calls.find( ([event]) => event === 'update-downloaded' diff --git a/src/main/updater.install-failure-cause.test.ts b/src/main/updater.install-failure-cause.test.ts index 6344a79d13b..bef63d6912a 100644 --- a/src/main/updater.install-failure-cause.test.ts +++ b/src/main/updater.install-failure-cause.test.ts @@ -101,6 +101,11 @@ vi.mock('./updater-nudge', () => ({ vi.mock('./updater-lifecycle-diagnostics', () => ({ recordUpdaterLifecycle: recordUpdaterLifecycleMock })) +vi.mock('./linux-update-package-type', () => ({ + getLinuxPackageType: () => 'non-root', + getLinuxRootPackageType: () => null, + isExternallyManagedLinuxInstall: () => false +})) // The real electron-updater DebUpdater failure text when elevation is impossible. const DEB_ELEVATION_ERROR = @@ -155,6 +160,8 @@ async function reachDownloaded(): Promise { autoUpdaterMock.emit('checking-for-update') autoUpdaterMock.emit('update-available', { version: '1.4.163' }) await new Promise((resolve) => setTimeout(resolve, 0)) + autoUpdaterMock.downloadUpdate.mockResolvedValue([]) + updater.downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.4.163' }) expect(updater.getUpdateStatus().state).toBe('downloaded') return updater diff --git a/src/main/updater.linux-externally-managed.test.ts b/src/main/updater.linux-externally-managed.test.ts new file mode 100644 index 00000000000..0b63a8ec9ed --- /dev/null +++ b/src/main/updater.linux-externally-managed.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as UpdaterModule from './updater' +import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader' +import type { LinuxRootPackageType, UpdateStatus } from '../shared/update-status-types' + +const { + autoUpdaterMock, + getLinuxRootPackageTypeMock, + isExternallyManagedLinuxInstallMock, + recordUpdaterLifecycleMock, + fetchNewerReleaseTagsMock, + moduleFactories, + resetUpdaterMocks +} = await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks()) + +vi.mock('electron', () => moduleFactories.electron()) +vi.mock('electron-updater', () => moduleFactories.electronUpdater()) +vi.mock('./electron-updater-loader', () => moduleFactories.electronUpdaterLoader()) +vi.mock('@electron-toolkit/utils', () => moduleFactories.electronToolkitUtils()) +vi.mock('./ipc/pty', () => moduleFactories.ipcPty()) +vi.mock('./linux-update-package-type', () => moduleFactories.linuxUpdatePackageType()) +vi.mock('./updater-lifecycle-diagnostics', () => moduleFactories.updaterLifecycleDiagnostics()) +vi.mock('./updater-changelog', () => moduleFactories.updaterChangelog()) +vi.mock('./updater-nudge', () => moduleFactories.updaterNudge()) +vi.mock('./update-install-exit-watchdog', () => moduleFactories.updateInstallExitWatchdog()) +vi.mock('./updater-prerelease-feed', () => moduleFactories.updaterPrereleaseFeed()) +vi.mock('./local-builds/local-build-switch', () => moduleFactories.localBuildSwitch()) +vi.mock('./local-builds/local-build-feed-server', () => moduleFactories.localBuildFeedServer()) + +const EXTERNALLY_MANAGED_MESSAGE = + 'This copy of Orca is managed by your system package manager, so Orca cannot install updates itself. Update Orca through your distribution instead.' + +/** #17702: a repackaged install (AUR, Nix, container rebuild) inherits the .deb `package-type` + * marker but has no package manager that can apply an Orca-downloaded package. */ +warmUpdaterModule() + +describe('updater externally managed Linux installs', () => { + beforeEach(() => { + resetUpdaterMocks() + }) + + async function startUpdater(options: { + packageType: LinuxRootPackageType | null + externallyManaged: boolean + }): Promise<{ send: ReturnType; updater: typeof UpdaterModule }> { + getLinuxRootPackageTypeMock.mockReturnValue(options.packageType) + isExternallyManagedLinuxInstallMock.mockReturnValue(options.externallyManaged) + vi.useFakeTimers() + fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' })) + return Promise.resolve(undefined) + }) + const send = vi.fn() + const updater = await loadUpdaterModule() + updater.setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now(), + installMode: 'interactive' + }) + return { send, updater } + } + + function lastStatus(send: ReturnType): UpdateStatus | undefined { + return send.mock.calls.findLast(([channel]) => channel === 'updater:status')?.[1] + } + + it('still reports the available release so the user can update through their distribution', async () => { + const { send, updater } = await startUpdater({ packageType: 'deb', externallyManaged: true }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + + expect(lastStatus(send)).toEqual({ + state: 'available', + version: '1.0.61', + changelog: null, + externallyManaged: true + }) + }) + + it('does not flag a real deb host', async () => { + const { send, updater } = await startUpdater({ packageType: 'deb', externallyManaged: false }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + + const status = lastStatus(send) + expect(status).toEqual({ state: 'available', version: '1.0.61', changelog: null }) + expect(status && 'externallyManaged' in status).toBe(false) + }) + + it('refuses the download instead of spending it on a package it can never install', async () => { + const { send, updater } = await startUpdater({ packageType: 'deb', externallyManaged: true }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + + updater.downloadUpdate() + await vi.advanceTimersByTimeAsync(0) + + expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled() + expect(lastStatus(send)).toEqual({ + state: 'error', + message: EXTERNALLY_MANAGED_MESSAGE, + version: '1.0.61', + retryable: false + }) + }) + + it('marks the refusal non-retryable so the card offers no Retry Download', async () => { + const { send, updater } = await startUpdater({ packageType: 'deb', externallyManaged: true }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + updater.downloadUpdate() + await vi.advanceTimersByTimeAsync(0) + + const status = lastStatus(send) + expect(status?.state === 'error' && status.retryable).toBe(false) + }) + + it('records the blocked download for field diagnosis', async () => { + const { updater } = await startUpdater({ packageType: 'deb', externallyManaged: true }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + updater.downloadUpdate() + await vi.advanceTimersByTimeAsync(0) + + expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( + 'linux_package_externally_managed_download_blocked', + { version: '1.0.61' } + ) + }) + + it('leaves an ordinary deb host able to download', async () => { + const { updater } = await startUpdater({ packageType: 'deb', externallyManaged: false }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + autoUpdaterMock.downloadUpdate.mockResolvedValue([]) + + updater.downloadUpdate() + await vi.advanceTimersByTimeAsync(0) + + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalled() + }) + + it('leaves an AppImage host able to download', async () => { + const { updater } = await startUpdater({ packageType: null, externallyManaged: false }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + autoUpdaterMock.downloadUpdate.mockResolvedValue([]) + + updater.downloadUpdate() + await vi.advanceTimersByTimeAsync(0) + + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalled() + }) +}) diff --git a/src/main/updater.linux-root-package-install.test.ts b/src/main/updater.linux-root-package-install.test.ts index b52bf40f4c8..01f489bd8ef 100644 --- a/src/main/updater.linux-root-package-install.test.ts +++ b/src/main/updater.linux-root-package-install.test.ts @@ -1,12 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createHash } from 'node:crypto' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { join } from 'node:path' +import { tmpdir } from 'node:os' import type * as UpdaterModule from './updater' -import type * as RecoveryModule from './linux-package-update-recovery' -import type { UpdateStatus } from '../shared/update-status-types' -import { PRE_COMMIT_INSTALL_FAILURE } from './updater-test-harness' +import type { LinuxRootPackageType, UpdateStatus } from '../shared/update-status-types' import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader' const { @@ -14,10 +10,9 @@ const { nativeUpdaterMock, autoUpdaterMock, killAllPtyMock, + getLinuxPackageTypeMock, getLinuxRootPackageTypeMock, recordUpdaterLifecycleMock, - armExitWatchdogMock, - disarmExitWatchdogMock, fetchNewerReleaseTagsMock, moduleFactories, resetUpdaterMocks @@ -37,687 +32,222 @@ vi.mock('./updater-prerelease-feed', () => moduleFactories.updaterPrereleaseFeed vi.mock('./local-builds/local-build-switch', () => moduleFactories.localBuildSwitch()) vi.mock('./local-builds/local-build-feed-server', () => moduleFactories.localBuildFeedServer()) -type RevalidationVerdict = Awaited< - ReturnType -> +const packageSha512 = Buffer.alloc(64).toString('base64') -// Captured before any vi.useFakeTimers() call: the only handle left that still yields to libuv. -const realSetTimeout = globalThis.setTimeout - -type StagedLinuxPackages = { - cacheRoot: string - debPath: string - debSha512: string - rpmPath: string - rpmSha512: string -} - -/** - * Stages real packages inside a real updater cache: every install re-proves the retained digest by - * streaming the file off disk, so a path that never existed would abort before reaching the native - * updater. Returns the actual digests for the download events. - */ -function stageLinuxUpdateCache(): StagedLinuxPackages { - const cacheRoot = mkdtempSync(join(tmpdir(), 'orca-updater-cache-')) - const pendingDir = join(cacheRoot, 'orca-updater', 'pending') - mkdirSync(pendingDir, { recursive: true }) - const stagePackage = (fileName: string): { path: string; sha512: string } => { - const packagePath = join(pendingDir, fileName) - const bytes = Buffer.from(`orca test package ${fileName}`) - writeFileSync(packagePath, bytes) - return { path: packagePath, sha512: createHash('sha512').update(bytes).digest('base64') } - } - const deb = stagePackage('orca-ide_1.0.61_amd64.deb') - const rpm = stagePackage('orca-ide-1.0.61.x86_64.rpm') +function downloadedEvent(packageType: LinuxRootPackageType): Record { + const fileName = + packageType === 'deb' ? 'orca-ide_1.0.61_amd64.deb' : 'orca-ide-1.0.61.x86_64.rpm' return { - cacheRoot, - debPath: deb.path, - debSha512: deb.sha512, - rpmPath: rpm.path, - rpmSha512: rpm.sha512 - } -} - -type RevalidationProbe = { - /** Switch to held mode, where a verdict only lands when the test says so. Must precede startUpdater. */ - hold: () => void - settle: (verdict: RevalidationVerdict) => void - fail: (error: Error) => void - invocationCount: () => number - /** Resolves once every re-proof this test started has finished. */ - drain: () => Promise -} - -/** One outstanding re-proof; `awaitable` stays false while a held verdict has no way to settle. */ -type OutstandingRevalidation = { promise: Promise; awaitable: boolean } - -/** - * Wraps the pre-install re-proof so tests can await the real disk read instead of budgeting - * event-loop turns, and can hold a verdict open at an exact point in the cycle. Only that one call - * is wrapped — the artifact state stays real. - */ -function probeRevalidation(): RevalidationProbe { - type Artifact = Parameters[0] - let held = false - let invocationCount = 0 - let pending: { - resolve: (verdict: RevalidationVerdict) => void - reject: (error: Error) => void - entry: OutstandingRevalidation - } | null = null - const outstanding: OutstandingRevalidation[] = [] - - const track = (verdict: Promise, awaitable: boolean) => { - const noop = (): void => undefined - const entry: OutstandingRevalidation = { promise: verdict.then(noop, noop), awaitable } - outstanding.push(entry) - return entry - } - - vi.doMock('./linux-package-update-recovery', async () => { - const actual = await vi.importActual('./linux-package-update-recovery') - return { - ...actual, - revalidateLinuxPackageForInstall: vi.fn((artifact: Artifact) => { - invocationCount += 1 - if (!held) { - const verdict = actual.revalidateLinuxPackageForInstall(artifact) - track(verdict, true) - return verdict - } - let resolve!: (verdict: RevalidationVerdict) => void - let reject!: (error: Error) => void - const verdict = new Promise((res, rej) => { - resolve = res - reject = rej - }) - pending = { resolve, reject, entry: track(verdict, false) } - return verdict - }) - } - }) - - const release = (): typeof pending => { - const current = pending - if (current) { - current.entry.awaitable = true - pending = null - } - return current - } - - return { - hold: () => { - held = true - }, - settle: (verdict) => release()?.resolve(verdict), - fail: (error) => release()?.reject(error), - invocationCount: () => invocationCount, - drain: async () => { - // A verdict still held open can never settle on its own, so draining skips it. - let ready = outstanding.filter((entry) => entry.awaitable) - while (ready.length > 0) { - for (const entry of ready) { - outstanding.splice(outstanding.indexOf(entry), 1) - } - await Promise.all(ready.map((entry) => entry.promise)) - ready = outstanding.filter((entry) => entry.awaitable) - } - } + version: '1.0.61', + downloadedFile: join(tmpdir(), 'orca-updater', 'pending', fileName), + files: [{ url: fileName, sha512: packageSha512 }] } } warmUpdaterModule() -describe('updater', () => { +describe('updater Linux root packages', () => { beforeEach(() => { resetUpdaterMocks() }) - describe('linux root package install recovery', () => { - let staged: StagedLinuxPackages - let EXIT_127: string - let revalidation: RevalidationProbe + async function startUpdater( + packageType: LinuxRootPackageType | null, + installMode: UpdaterModule.UpdateInstallMode = 'interactive' + ): Promise<{ send: ReturnType; updater: typeof UpdaterModule }> { + getLinuxRootPackageTypeMock.mockReturnValue(packageType) + vi.useFakeTimers() + fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' })) + return Promise.resolve(undefined) + }) + const send = vi.fn() + const updater = await loadUpdaterModule() + updater.setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now(), + installMode + }) + return { send, updater } + } - // Why: the quit timer needs fake time, and the work it starts needs real event-loop turns — - // fake timers never advance libuv. The re-proof itself is awaited rather than counted out - // (#15243): its disk read is wall-clock bound, so a loaded runner outlasts any turn budget and - // the tail lands in the next test. - const settleQuitAndInstall = async (): Promise => { - await vi.advanceTimersByTimeAsync(100) - await revalidation.drain() - // Full Node 26 shards can briefly starve the libuv poll phase while other workers transform - // tests; keep the operation alive long enough to avoid leaking it into the next test. - for (let turn = 0; turn < 200; turn += 1) { - await new Promise((resolve) => realSetTimeout(resolve, 0)) - } - await vi.advanceTimersByTimeAsync(0) + function lastStatus(send: ReturnType): UpdateStatus | undefined { + return send.mock.calls.findLast(([channel]) => channel === 'updater:status')?.[1] + } + + function markMacInstallerReady(): void { + if (process.platform !== 'darwin') { + return } + const handler = nativeUpdaterMock.on.mock.calls.find( + ([eventName]) => eventName === 'update-downloaded' + )?.[1] as (() => void) | undefined + handler?.() + } - beforeEach(() => { - staged = stageLinuxUpdateCache() - vi.stubEnv('XDG_CACHE_HOME', staged.cacheRoot) - EXIT_127 = `Command failed: /usr/bin/pkexec /usr/bin/dpkg -i ${staged.debPath}, exited with code 127` - revalidation = probeRevalidation() - }) - - afterEach(async () => { - // Why: an unfinished re-proof keeps running against this test's module instance, whose mocks - // are the same singletons the next test asserts on — it would double every install-path count. - await revalidation.drain() - vi.doUnmock('./linux-package-update-recovery') - vi.unstubAllEnvs() - rmSync(staged.cacheRoot, { recursive: true, force: true }) - }) - - const lastStatus = (send: ReturnType): UpdateStatus | undefined => - send.mock.calls.findLast(([channel]) => channel === 'updater:status')?.[1] - - const PRE_COMMIT_FAILURE_MESSAGE = PRE_COMMIT_INSTALL_FAILURE - const AGENT_STDERR = - 'pkexec: Error executing command as another user: No authentication agent found.' - - const downloadedEvent = (overrides?: Record): Record => ({ - version: '1.0.61', - downloadedFile: staged.debPath, - files: [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: staged.debSha512 }], - ...overrides - }) - - const rpmDownloadedEvent = (): Record => - downloadedEvent({ - downloadedFile: staged.rpmPath, - files: [{ url: 'orca-ide-1.0.61.x86_64.rpm', sha512: staged.rpmSha512 }] - }) - - const startUpdater = async ( - packageType: 'deb' | 'rpm' | null - ): Promise<{ send: ReturnType; updater: typeof UpdaterModule }> => { - getLinuxRootPackageTypeMock.mockReturnValue(packageType) - vi.useFakeTimers() - fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' }) - autoUpdaterMock.checkForUpdates.mockImplementation(() => { - autoUpdaterMock.emit('checking-for-update') - queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' })) - return Promise.resolve(undefined) - }) - const send = vi.fn() - const updater = await loadUpdaterModule() - updater.setupAutoUpdater({ webContents: { send } } as never, { - getLastUpdateCheckAt: () => Date.now() - }) - return { send, updater } + async function reachDownloaded( + updater: typeof UpdaterModule, + event: Record, + markInstallerReady = false + ): Promise { + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + autoUpdaterMock.downloadUpdate.mockResolvedValue([]) + updater.downloadUpdate() + autoUpdaterMock.emit('update-downloaded', event) + if (markInstallerReady) { + markMacInstallerReady() } + await vi.advanceTimersByTimeAsync(0) + } - const reachDownloaded = async ( - updater: typeof UpdaterModule, - event: Record - ): Promise => { - updater.checkForUpdatesFromMenu() - await vi.advanceTimersByTimeAsync(0) - autoUpdaterMock.emit('update-downloaded', event) - if (process.platform === 'darwin') { - const nativeReady = nativeUpdaterMock.on.mock.calls.find( - ([eventName]) => eventName === 'update-downloaded' - )?.[1] as (() => void) | undefined - nativeReady?.() - } - await vi.advanceTimersByTimeAsync(0) - } - - it('disables install-on-quit for deb and rpm root packages', async () => { - for (const packageType of ['deb', 'rpm'] as const) { - vi.resetModules() - autoUpdaterMock.autoInstallOnAppQuit = true - getLinuxRootPackageTypeMock.mockReturnValue(packageType) - const { setupAutoUpdater } = await loadUpdaterModule() - - setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { - getLastUpdateCheckAt: () => Date.now(), - installMode: 'interactive' - }) - - expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) - } - }) - - it('keeps interactive install-on-quit when no root-package marker is present', async () => { - autoUpdaterMock.autoInstallOnAppQuit = false - const { setupAutoUpdater } = await loadUpdaterModule() - - setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { - getLastUpdateCheckAt: () => Date.now(), - installMode: 'interactive' - }) - - expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true) - }) - - it('leaves headless serve installs supervisor-controlled', async () => { - for (const installMode of [ - 'supervised-headless-serve', - 'unsupported-headless-serve' - ] as const) { - vi.resetModules() - autoUpdaterMock.autoInstallOnAppQuit = true - const { setupAutoUpdater } = await loadUpdaterModule() - - setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { - getLastUpdateCheckAt: () => Date.now(), - installMode - }) - - expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) - } - }) - - it('sends structured recovery when quitAndInstall throws synchronously', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${staged.debPath}`) - throw new Error(EXIT_127) - }) - - updater.quitAndInstall() - await settleQuitAndInstall() - - // Why: the sync throw ends capture before the catch, so the stashed text must survive. - expect(send).toHaveBeenCalledWith('updater:status', { - state: 'error', - message: `${AGENT_STDERR} target `, - recovery: { - kind: 'linux-package-install', - packageType: 'deb', - reason: 'authentication-agent-unavailable', - version: '1.0.61' - } - }) - }) - - it('recovers an event-driven pre-commit failure without tearing down the session', async () => { + it.each(['deb', 'rpm'] as const)( + 'hands off %s installs without invoking the native updater', + async (packageType) => { const openWindow = { removeAllListeners: vi.fn() } browserWindowMock.getAllWindows.mockReturnValue([openWindow] as never) - const { send, updater } = await startUpdater('rpm') - await reachDownloaded(updater, rpmDownloadedEvent()) - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.emit('error', new Error('Command failed, exited with code 1')) - }) + const { send, updater } = await startUpdater(packageType) - updater.quitAndInstall() - await settleQuitAndInstall() + await reachDownloaded(updater, downloadedEvent(packageType)) - expect(send).toHaveBeenCalledWith('updater:status', { + expect(lastStatus(send)).toEqual({ state: 'error', - message: 'Command failed, exited with code 1', + message: 'Quit Orca before running the system package install command.', recovery: { kind: 'linux-package-install', - packageType: 'rpm', - reason: 'package-install-failed', + packageType, + reason: 'manual-install-required', version: '1.0.61' } }) + + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() expect(killAllPtyMock).not.toHaveBeenCalled() expect(openWindow.removeAllListeners).not.toHaveBeenCalled() expect(updater.isQuittingForUpdate()).toBe(false) - expect(disarmExitWatchdogMock).toHaveBeenCalled() - }) - - it('keeps the generic install-failure copy when no artifact was retained', async () => { - const { send, updater } = await startUpdater('deb') - // Release metadata without a digest must not enable cached-package recovery. - await reachDownloaded( - updater, - downloadedEvent({ files: [{ url: 'orca-ide_1.0.61_amd64.deb' }] }) - ) - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.emit('error', new Error(EXIT_127)) - }) - - updater.quitAndInstall() - await settleQuitAndInstall() - - expect(send).toHaveBeenCalledWith('updater:status', { - state: 'error', - message: `${PRE_COMMIT_FAILURE_MESSAGE} (${EXIT_127})` - }) - expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith( - 'linux_package_install_failed', - expect.anything(), - expect.anything() - ) - }) - - it('advises a restart only for a failure before the native invoke', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - // The source calls this out as the pre-native "cleanup/tracing exception" case. - recordUpdaterLifecycleMock.mockImplementation((event: unknown) => { - if (event === 'quit_and_install_invoking_native') { - throw new Error('tracing sink unavailable') - } - }) - - updater.quitAndInstall() - await settleQuitAndInstall() - - expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() - expect(send).toHaveBeenCalledWith('updater:status', { - state: 'error', - message: 'Could not restart to install the update. Quit and reopen Orca, then try again.' - }) - expect(updater.isQuittingForUpdate()).toBe(false) - }) - - it('keeps a committed install intact when post-commit cleanup throws', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - // Why: spawnSync already installed the package; a teardown throw must not be reported as failure. - killAllPtyMock.mockImplementation(() => { - throw new Error('pty teardown failed') - }) - send.mockClear() - - updater.quitAndInstall() - await settleQuitAndInstall() - - expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( - 'post_commit_cleanup_failed', - { errorType: 'Error' }, - expect.objectContaining({ level: 'warn' }) - ) - expect(send).not.toHaveBeenCalled() - expect(updater.isQuittingForUpdate()).toBe(true) - expect(armExitWatchdogMock).toHaveBeenCalledTimes(1) - expect(disarmExitWatchdogMock).not.toHaveBeenCalled() - }) - - it('still suppresses late post-commit errors while an artifact is retained', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - - updater.quitAndInstall() - await settleQuitAndInstall() - expect(killAllPtyMock).toHaveBeenCalledTimes(1) - - send.mockClear() - autoUpdaterMock.emit('error', new Error(EXIT_127)) - - expect(send).not.toHaveBeenCalled() - expect(updater.isQuittingForUpdate()).toBe(true) - }) - - it('retries the automatic install without redownloading the package', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.emit('error', new Error(EXIT_127)) - }) - - updater.quitAndInstall() - await settleQuitAndInstall() - updater.quitAndInstall() - await settleQuitAndInstall() - - // Why: a retry usually fails identically; a deduped status would strand the preload restart relay. - expect( - send.mock.calls.filter( - ([channel, status]) => - channel === 'updater:status' && - (status as { recovery?: { kind?: string } })?.recovery?.kind === 'linux-package-install' - ) - ).toHaveLength(2) - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(2) - expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled() - expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith('linux_package_recovery_requested', { - action: 'retry-automatic', - packageType: 'deb', - version: '1.0.61' - }) - }) - - // Why: the cache path is user-writable, so the bytes verified when the recovery card - // rendered are not necessarily the bytes a root package manager would read on retry. - it('aborts the retry when the retained package no longer matches its digest', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - - // The escalation fails, which is what puts the recovery card (and its retry) on screen. - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.emit('error', new Error(EXIT_127)) - }) - updater.quitAndInstall() - await settleQuitAndInstall() - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) - - // A local process swaps the verified package for its own between failure and retry. - writeFileSync(staged.debPath, Buffer.from('attacker supplied package')) - send.mockClear() - killAllPtyMock.mockClear() - - updater.quitAndInstall() - await settleQuitAndInstall() - - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) - expect(killAllPtyMock).not.toHaveBeenCalled() - expect(updater.isQuittingForUpdate()).toBe(false) - expect(send).toHaveBeenCalledWith('updater:status', { - state: 'error', - message: - 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.' - }) - expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( - 'linux_package_revalidation_failed', - expect.objectContaining({ action: 'retry-automatic', reason: 'hash-mismatch' }), - expect.anything() - ) - }) - - // Why: "Restart to Update" is the common path and can sit unclicked for hours, so the same - // user-writable package reaches a root installer with a far longer window than any retry. - it('aborts the first install when the downloaded package was swapped', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - writeFileSync(staged.debPath, Buffer.from('attacker supplied package')) - send.mockClear() - - updater.quitAndInstall() - await settleQuitAndInstall() - - expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() - expect(killAllPtyMock).not.toHaveBeenCalled() - expect(updater.isQuittingForUpdate()).toBe(false) - expect(send).toHaveBeenCalledWith('updater:status', { - state: 'error', - message: - 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.' - }) expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( - 'linux_package_revalidation_failed', - expect.objectContaining({ action: 'restart-to-install', reason: 'hash-mismatch' }), - expect.anything() + 'linux_package_manual_install_required', + { packageType, version: '1.0.61' } ) - }) + } + ) - it('installs normally when the retained package still matches its digest', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) + it('guards the native boundary even when no package artifact was retained', async () => { + const { send, updater } = await startUpdater('deb') - updater.quitAndInstall() - await settleQuitAndInstall() + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) - expect(killAllPtyMock).toHaveBeenCalledTimes(1) - // Why: an abort push here would clear the restart flag mid-quit and re-arm the dirty-buffer - // prompt against the install that is already committed. - expect(send).not.toHaveBeenCalledWith('updater:quitAndInstallAborted') - expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith( - 'linux_package_revalidation_failed', - expect.anything(), - expect.anything() - ) - }) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + }) - // Why: hashing 160 MB outlives the cycle it started in, and Check for Updates stays enabled - // while it runs — a verdict from the old cycle must not replace the card that took over. - it('drops an abort verdict once a newer check replaced the card', async () => { - revalidation.hold() - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - - updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) - // The user gives up waiting and checks again; that check owns the card from here. - updater.checkForUpdatesFromMenu() - await vi.advanceTimersByTimeAsync(0) - expect(lastStatus(send)).toMatchObject({ state: 'available', version: '1.0.61' }) - - revalidation.settle({ ok: false, reason: 'hash-mismatch' }) - await settleQuitAndInstall() - - // The install is still abandoned — only the stale status is withheld. - expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() - expect(lastStatus(send)).toMatchObject({ state: 'available', version: '1.0.61' }) - // Withholding the status must not also withhold the abort: the renderer armed its restart and - // would otherwise skip its unsaved-work prompt for the rest of the session. - expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') - expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( - 'linux_package_revalidation_failed', - expect.objectContaining({ reason: 'hash-mismatch' }), - expect.anything() - ) - }) - - // Why: EMFILE/EIO during the stream says nothing about the bytes, so the copy must not claim - // the package changed and the card must keep the actions that still work. - it('keeps the recovery card usable when the re-proof cannot read the package', async () => { - revalidation.hold() - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.emit('error', new Error(EXIT_127)) - }) - updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) - revalidation.settle({ ok: true }) - await settleQuitAndInstall() - send.mockClear() - - updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) - revalidation.settle({ ok: false, reason: 'read-failed' }) - await settleQuitAndInstall() - - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) - expect(lastStatus(send)).toEqual({ - state: 'error', - message: - 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.', - recovery: { - kind: 'linux-package-install', - packageType: 'deb', - reason: 'package-install-failed', - version: '1.0.61' - } - }) - }) - - // Why: the re-proof runs before performQuitAndInstall's own error handling, so a rejection - // there would strand the quit timer and make every later install a silent no-op. - it('stays installable after a re-proof that rejects outright', async () => { - revalidation.hold() - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - - updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) - revalidation.fail(new Error('hash worker crashed')) - await settleQuitAndInstall() - - // Fails closed: an unprovable package is not handed to a root package manager. - expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() - expect(lastStatus(send)).toMatchObject({ - state: 'error', - message: - 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.' - }) - - updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) - revalidation.settle({ ok: true }) - await settleQuitAndInstall() - - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) - }) - - // Why: a second click during the multi-second hash must not schedule a parallel install. - it('ignores a second install request while the digest re-proof runs', async () => { - revalidation.hold() - const { updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - - updater.quitAndInstall() - // Fires the quit timer, which starts the re-proof; its verdict is still outstanding. - await vi.advanceTimersByTimeAsync(100) - expect(revalidation.invocationCount()).toBe(1) - updater.quitAndInstall() - // Advancing here proves the second request never scheduled its own quit timer. - await vi.advanceTimersByTimeAsync(100) - expect(revalidation.invocationCount()).toBe(1) - revalidation.settle({ ok: true }) - await settleQuitAndInstall() - - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) - }) - - it('records classification-only lifecycle data for a package install failure', async () => { - const { updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${staged.debPath}`) - autoUpdaterMock.emit('error', new Error(EXIT_127)) - }) - - updater.quitAndInstall() - await settleQuitAndInstall() - - const failure = recordUpdaterLifecycleMock.mock.calls.find( - ([event]) => event === 'linux_package_install_failed' - ) - expect(failure?.[1]).toEqual({ - packageType: 'deb', - reason: 'authentication-agent-unavailable', - exitCode: 127, + it('preserves the normal AppImage install path when no root-package marker is present', async () => { + const { send, updater } = await startUpdater(null) + await reachDownloaded( + updater, + { version: '1.0.61', - errorType: 'Error' - }) - const durable = JSON.stringify(recordUpdaterLifecycleMock.mock.calls) - expect(durable).not.toContain(staged.debPath) - expect(durable).not.toContain('authentication agent') - }) + downloadedFile: join(tmpdir(), 'Orca-1.0.61.AppImage'), + files: [] + }, + true + ) - it('omits exitCode from lifecycle data when the child status is unparseable', async () => { - const { updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.emit('error', new Error('dpkg was interrupted')) - }) + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) - updater.quitAndInstall() - await settleQuitAndInstall() - - const failure = recordUpdaterLifecycleMock.mock.calls.find( - ([event]) => event === 'linux_package_install_failed' + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(killAllPtyMock).toHaveBeenCalledTimes(1) + expect(send).not.toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect( + send.mock.calls.some( + ([channel, status]) => + channel === 'updater:status' && + (status as UpdateStatus).state === 'error' && + (status as Extract).recovery?.kind === + 'linux-package-install' ) - // Why: an absent key, not an explicit null, keeps the breadcrumb schema honest. - expect(failure?.[1]).toEqual({ - packageType: 'deb', - reason: 'package-install-failed', - version: '1.0.61', - errorType: 'Error' + ).toBe(false) + }) + + it.each(['deb', 'rpm'] as const)( + 'disables install-on-quit and remote automatic control for %s builds', + async (packageType) => { + autoUpdaterMock.autoInstallOnAppQuit = true + const { updater } = await startUpdater(packageType) + + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) + expect(updater.getRemoteServerUpdateSupport()).toEqual({ + installMode: 'interactive', + automatic: false, + reason: 'manual-service-update-required' }) - expect(Object.keys(failure?.[1] as object)).not.toContain('exitCode') + expect(() => updater.checkForRemoteServerUpdate('runtime-1')).toThrow( + 'remote_update_manual_required' + ) + } + ) + + it('keeps interactive install-on-quit and remote control for non-root packages', async () => { + autoUpdaterMock.autoInstallOnAppQuit = false + const { updater } = await startUpdater(null) + + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true) + expect(updater.getRemoteServerUpdateSupport()).toEqual({ + installMode: 'interactive', + automatic: true, + reason: 'available' }) }) + + it('fails closed for an unusable packaged marker', async () => { + getLinuxPackageTypeMock.mockReturnValue('unusable') + getLinuxRootPackageTypeMock.mockReturnValue(null) + autoUpdaterMock.autoInstallOnAppQuit = true + const { send, updater } = await startUpdater(null) + + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) + expect(updater.getRemoteServerUpdateSupport()).toEqual({ + installMode: 'interactive', + automatic: false, + reason: 'manual-service-update-required' + }) + + await reachDownloaded(updater, { + version: '1.0.61', + downloadedFile: join(tmpdir(), 'orca-updater', 'pending', 'orca-ide_1.0.61_amd64.deb'), + files: [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: packageSha512 }] + }) + expect(lastStatus(send)).toEqual({ + state: 'error', + message: + 'Orca could not verify the installed Linux package format, so it will not install this update automatically. Download the update from the official release page and install it manually.', + version: '1.0.61', + retryable: false + }) + + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + }) + + it('leaves headless serve installs supervisor-controlled', async () => { + for (const installMode of [ + 'supervised-headless-serve', + 'unsupported-headless-serve' + ] as const) { + resetUpdaterMocks() + autoUpdaterMock.autoInstallOnAppQuit = true + await startUpdater(null, installMode) + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) + } + }) }) diff --git a/src/main/updater.mac-install.test.ts b/src/main/updater.mac-install.test.ts index e4fe26296a0..563b8562fd6 100644 --- a/src/main/updater.mac-install.test.ts +++ b/src/main/updater.mac-install.test.ts @@ -134,6 +134,7 @@ describe('updater mac install handoff', () => { appMock.isPackaged = true isMock.dev = false killAllPtyMock.mockReset() + autoUpdaterMock.downloadUpdate.mockResolvedValue([]) vi.unstubAllGlobals() vi.useRealTimers() }) @@ -145,7 +146,7 @@ describe('updater mac install handoff', () => { const mainWindow = { webContents: { send: sendMock } } autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) - const { setupAutoUpdater } = await loadUpdaterModule() + const { setupAutoUpdater, downloadUpdate } = await loadUpdaterModule() setupAutoUpdater(mainWindow as never) await vi.waitFor(() => { @@ -156,6 +157,7 @@ describe('updater mac install handoff', () => { // Why: the update-available handler is now async (it awaits fetchChangelog). // Flush microtasks so setAvailableVersion runs before update-downloaded fires. await new Promise((r) => setTimeout(r, 0)) + downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) const preventDefault = vi.fn() @@ -197,7 +199,7 @@ describe('updater mac install handoff', () => { const mainWindow = { webContents: { send: vi.fn() } } autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) - const { setupAutoUpdater, quitAndInstall } = await loadUpdaterModule() + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() setupAutoUpdater(mainWindow as never, { onBeforeQuit }) await vi.waitFor(() => { @@ -206,6 +208,7 @@ describe('updater mac install handoff', () => { autoUpdaterMock.emit('checking-for-update') autoUpdaterMock.emit('update-available', { version: '1.0.61' }) await vi.advanceTimersByTimeAsync(0) + downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) const preventDefault = vi.fn() @@ -279,7 +282,7 @@ describe('updater mac install handoff', () => { const mainWindow = { webContents: { send: sendMock } } autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) - const { setupAutoUpdater } = await loadUpdaterModule() + const { setupAutoUpdater, downloadUpdate } = await loadUpdaterModule() setupAutoUpdater(mainWindow as never) await vi.waitFor(() => { @@ -290,6 +293,7 @@ describe('updater mac install handoff', () => { // Why: the update-available handler is now async (it awaits fetchChangelog). // Flush microtasks so setAvailableVersion runs before update-downloaded fires. await vi.advanceTimersByTimeAsync(0) + downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) const preventDefault = vi.fn() diff --git a/src/main/updater.quit-and-install.test.ts b/src/main/updater.quit-and-install.test.ts index 0d381ea473a..0080db58991 100644 --- a/src/main/updater.quit-and-install.test.ts +++ b/src/main/updater.quit-and-install.test.ts @@ -290,6 +290,7 @@ describe('updater', () => { }) }) + autoUpdaterMock.emit('download-progress', { percent: 100 }) autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) // Why: on macOS install commits only once Squirrel is ready; mark it ready so this test covers the post-commit path on all platforms. diff --git a/src/main/updater.startup-scheduling.test.ts b/src/main/updater.startup-scheduling.test.ts index 46190de47da..ef72af27def 100644 --- a/src/main/updater.startup-scheduling.test.ts +++ b/src/main/updater.startup-scheduling.test.ts @@ -31,6 +31,7 @@ warmUpdaterModule() describe('updater', () => { beforeEach(() => { resetUpdaterMocks() + vi.useFakeTimers() }) it('does not load or configure electron-updater during dev setup', async () => { diff --git a/src/main/updater/updater-build-selection.ts b/src/main/updater/updater-build-selection.ts index 63222fb3101..a533b776a84 100644 --- a/src/main/updater/updater-build-selection.ts +++ b/src/main/updater/updater-build-selection.ts @@ -111,7 +111,7 @@ export abstract class UpdaterBuildSelection extends UpdaterMenuChecks { try { const target = resolveTargetBuild(channel, tag) if (compareVersions(target.version, app.getVersion()) === 0) { - this.sendStatus({ state: 'not-available', userInitiated: true }) + this.sendSettledCheckStatus({ state: 'not-available', userInitiated: true }) return } this.closeLocalBuildFeed() @@ -140,7 +140,7 @@ export abstract class UpdaterBuildSelection extends UpdaterMenuChecks { this.userInitiatedCheck = false this.clearAvailableUpdateContext() this.restoreReleaseUpdateSource() - this.sendStatus({ + this.sendSettledCheckStatus({ state: 'error', message: String((error as Error)?.message ?? error), userInitiated: true diff --git a/src/main/updater/updater-check-failure.ts b/src/main/updater/updater-check-failure.ts index 8ee2500c6a9..742897af6fd 100644 --- a/src/main/updater/updater-check-failure.ts +++ b/src/main/updater/updater-check-failure.ts @@ -34,7 +34,7 @@ export abstract class UpdaterCheckFailure extends UpdaterReleaseFeed { // Why: a failed pinned jump must hand the feed back before surfacing the error, or the pin blocks background checks for the process lifetime. this.clearAvailableUpdateContext() this.restoreReleaseUpdateSource() - this.sendStatus({ state: 'error', message, userInitiated }) + this.sendSettledCheckStatus({ state: 'error', message, userInitiated }) return } const failureKey = this.getCheckFailureKey(message, userInitiated) @@ -80,18 +80,19 @@ export abstract class UpdaterCheckFailure extends UpdaterReleaseFeed { this.scheduleAutomaticUpdateCheck(this.getAutomaticRetryInterval()) if (userInitiated) { // Why: a user click needs visible feedback (idle looks broken); distinguish incomplete releases from transport failures. - this.sendErrorStatus( - this.isStableReleaseNotReadyFailure(sourceError) + this.sendSettledCheckStatus({ + state: 'error', + message: this.isStableReleaseNotReadyFailure(sourceError) ? "A newer release isn't available for this device yet. Check again later." : "Couldn't reach the update server. Try again in a few minutes.", - true - ) + userInitiated: true + }) } else { if (this.isRetryableReleaseFeedPreflightFailure(sourceError)) { // Why: release probes can fail transiently; keep the campaign pending so the short retry can still show it. this.deferPendingUpdateNudgeUntilRetry() } - this.sendStatus({ state: 'idle' }) + this.sendSettledCheckStatus({ state: 'idle' }) } return } @@ -100,7 +101,7 @@ export abstract class UpdaterCheckFailure extends UpdaterReleaseFeed { if (!userInitiated) { this.scheduleAutomaticUpdateCheck(this.getAutomaticRetryInterval()) } - this.sendErrorStatus(message, userInitiated) + this.sendSettledCheckStatus({ state: 'error', message, userInitiated }) } this.pendingCheckFailureKey = failureKey diff --git a/src/main/updater/updater-check-state.ts b/src/main/updater/updater-check-state.ts index 8905029c75d..d7380c17305 100644 --- a/src/main/updater/updater-check-state.ts +++ b/src/main/updater/updater-check-state.ts @@ -1,6 +1,7 @@ import { writeMainThreadDiagnosticMarker } from '../diagnostics/main-thread-churn-probe' import { isWindowsSignatureCheckUnavailableFailure } from '../../shared/updater-windows-signature-check' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { getRetainedLinuxPackageManualInstallStatus } from '../linux-package-downloaded-status' import type { UpdateCheckOptions, UpdateStatus } from '../../shared/update-status-types' import type { UpdateCheckVariant } from './updater-types' import { UpdaterStatus } from './updater-status' @@ -229,7 +230,7 @@ export abstract class UpdaterCheckState extends UpdaterStatus { this.deferPendingUpdateNudgeUntilRetry() return } - this.sendStatus({ state: 'not-available', userInitiated }) + this.sendSettledCheckStatus({ state: 'not-available', userInitiated }) } } return @@ -239,7 +240,7 @@ export abstract class UpdaterCheckState extends UpdaterStatus { this.backgroundCheckPromotedToUserInitiated = false this.userInitiatedCheck = false this.completeSilentUpdateCheck(userInitiated) - this.sendStatus({ state: 'not-available', userInitiated }) + this.sendSettledCheckStatus({ state: 'not-available', userInitiated }) } protected handleSettledUpdateCheckPromise(attemptId: number): void { @@ -284,6 +285,21 @@ export abstract class UpdaterCheckState extends UpdaterStatus { this.sendStatus({ state: 'error', message, userInitiated }) } + /** + * Settles a check without discarding a retained manual-install card. A distro-managed host has a + * downloaded package it can still be told about, and the ordinary settle status would erase it. + */ + protected sendSettledCheckStatus(status: UpdateStatus): void { + const retainedStatus = getRetainedLinuxPackageManualInstallStatus() + if (retainedStatus) { + this.sendStatus(retainedStatus) + } else if (status.state === 'error') { + this.sendErrorStatus(status.message, status.userInitiated) + } else { + this.sendStatus(status) + } + } + protected abstract consumeMissingManifestPrereleaseFallbackResult(): { userInitiated: boolean } | null diff --git a/src/main/updater/updater-download-install.ts b/src/main/updater/updater-download-install.ts index a1627d3895c..455e56e6efa 100644 --- a/src/main/updater/updater-download-install.ts +++ b/src/main/updater/updater-download-install.ts @@ -1,5 +1,7 @@ import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady } from '../updater-mac-install' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { isExternallyManagedLinuxInstall } from '../linux-update-package-type' +import { LINUX_PACKAGE_EXTERNALLY_MANAGED_MESSAGE } from '../linux-package-downloaded-status' import { QUIT_AND_INSTALL_DELAY_MS } from './updater-state' import { UpdaterRemoteStatus } from './updater-remote-status' @@ -10,22 +12,11 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress || this.pendingQuitAndInstallTimer || - this.quitAndInstallInProgress || - // Why: the quit timer is already cleared while the pre-install digest re-proof streams, so without this a second click would schedule a parallel install of the same package. - this.linuxPackageRevalidationInFlight + this.quitAndInstallInProgress ) { return } - const retriedRecovery = this.getActiveLinuxPackageRecovery() - if (retriedRecovery) { - recordUpdaterLifecycle('linux_package_recovery_requested', { - action: 'retry-automatic', - packageType: retriedRecovery.packageType, - version: retriedRecovery.version - }) - } - if (this.deferHeadlessServeInstall('install', this.getPendingInstallVersion())) { return } @@ -66,6 +57,25 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { if (!version) { return } + // Why: main owns this verdict, not the card — an older renderer or a direct IPC call must not be + // able to spend a package download that this host could never install. + if (isExternallyManagedLinuxInstall()) { + recordUpdaterLifecycle('linux_package_externally_managed_download_blocked', { + version + }) + // Why: a pinned jump resolves to 'release' on Linux (no dev-channel artifact is built for it), + // so refusing without unwinding would strand isPinnedBuildActive and silently kill every + // background check for the rest of the process. A no-op on the ordinary release path. + this.clearAvailableUpdateContext() + this.restoreReleaseUpdateSource() + this.sendStatus({ + state: 'error', + message: LINUX_PACKAGE_EXTERNALLY_MANAGED_MESSAGE, + version, + retryable: false + }) + return + } if (this.deferHeadlessServeInstall('download', version)) { return } diff --git a/src/main/updater/updater-install-execution.ts b/src/main/updater/updater-install-execution.ts index 4522e155865..257f8e4fa93 100644 --- a/src/main/updater/updater-install-execution.ts +++ b/src/main/updater/updater-install-execution.ts @@ -4,19 +4,15 @@ import { withUpdaterSpan } from '../observability/instrumentation' import { runWithLaunchPath } from '../startup/hydrate-shell-path' import { markMacQuitAndInstallInFlight, isMacInstallerReady } from '../updater-mac-install' import { armUpdateInstallExitWatchdog } from '../update-install-exit-watchdog' -import { getLinuxRootPackageType } from '../linux-update-package-type' -import { - beginLinuxPackageInstallDiagnosticCapture, - endLinuxPackageInstallDiagnosticCapture -} from '../linux-package-install-diagnostic' -import { getTrackedLinuxPackageArtifact } from '../linux-package-update-recovery' +import { getLinuxPackageType } from '../linux-update-package-type' +import { LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE } from '../linux-package-downloaded-status' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' import { requestServeUpdateHandoff, failServeUpdateHandoff } from '../serve-update-handoff' import { UpdaterPackageRecovery } from './updater-package-recovery' export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { protected async performQuitAndInstall(): Promise { - if (this.quitAndInstallInProgress || this.linuxPackageRevalidationInFlight) { + if (this.quitAndInstallInProgress) { recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' }) return } @@ -30,20 +26,31 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { if (this.deferHeadlessServeInstall('install', pendingVersion)) { return } - // Why: the retained .deb/.rpm sits on a user-writable path that a root package manager is about - // to read, and nothing re-checks it after download. Re-prove it here — before any teardown — so a - // swapped or vanished package aborts instead of being installed as root. The synchronous guard - // keeps every non-Linux install on its existing timing. - if ( - getTrackedLinuxPackageArtifact() && - !(await this.proveRetainedLinuxPackage(pendingVersion)) - ) { - // Why: the renderer armed its restart before invoking, and it infers the abort from the error - // status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where - // it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt. + const linuxPackageType = getLinuxPackageType() + if (linuxPackageType === 'deb' || linuxPackageType === 'rpm') { + recordUpdaterLifecycle('linux_package_manual_install_required', { + packageType: linuxPackageType, + version: pendingVersion || null + }) + // The preload prepares renderer state before invoking; explicitly release it when main refuses. this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted') return } + if (linuxPackageType === 'unusable') { + recordUpdaterLifecycle( + 'linux_package_marker_unusable', + { version: pendingVersion || null }, + { level: 'warn', message: 'Linux package marker is unusable; native install blocked' } + ) + // The preload prepares renderer state before invoking; release it when the marker is unknown. + this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted') + this.sendInstallFailureStatus({ + state: 'error', + message: LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE, + ...(pendingVersion ? { version: pendingVersion } : {}) + }) + return + } this.quitAndInstallInProgress = true markMacQuitAndInstallInFlight() @@ -98,22 +105,14 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { } // Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure. this.quitAndInstallNativeInvoked = true - // Why: invoke before killAllPty/removing close listeners so a sync 'error' (the "no filepath" path) can recover while windows and PTYs are intact. + // Why: invoke before killAllPty/removing close listeners so a sync 'error' can recover while windows and PTYs are intact. const supervisorOwnsRelaunch = this.updateInstallMode === 'supervised-headless-serve' - // Why: BaseUpdater logs child stderr but drops it from the 'error' event, so retain it for the span of this call. - beginLinuxPackageInstallDiagnosticCapture(getTrackedLinuxPackageArtifact()?.path ?? null) - try { - runWithLaunchPath(() => - this.getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) - ) - } finally { - const diagnostic = endLinuxPackageInstallDiagnosticCapture() - // Why: a synchronous 'error' already consumed and reset this attempt; re-stashing would leak it into the next one. - this.lastInstallAttemptDiagnostic = this.quitAndInstallInProgress ? diagnostic : null - } + runWithLaunchPath(() => + this.getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) + ) span.addEvent('native_quit_and_install_invoked') - // Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran. + // Why: quitAndInstall can synchronously clear quitAndInstallInProgress via dispatchError; skip destructive prep if it already ran. if (!this.quitAndInstallInProgress) { // Why: recovery already wrote the reason to currentStatus; a bare return would exit this span Success. span.fail( @@ -124,14 +123,6 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { return } - // Why: DebUpdater/RpmUpdater install through spawnSync, so a normal return already means the - // package is installed. Commit here or a throw in the cleanup below is reported as an install - // failure — offering a recovery card, and stale stderr, for an update that actually succeeded. - if (getLinuxRootPackageType() !== null) { - this.updateInstallCommitted = true - armUpdateInstallExitWatchdog() - } - killAllPty() span.addEvent('local_pty_kill_all') @@ -153,9 +144,7 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { } }) } catch (error) { - // Why: on Linux the package is already installed once quitAndInstall returns, and the installer is - // waiting for this process to exit. Tearing down here would disarm the exit watchdog (#4438), clear - // quittingForUpdate mid-quit, and tell the user an install failed that actually succeeded. + // Past commit the installer is waiting for this process to exit; keep the handoff and watchdog intact. if (this.updateInstallCommitted) { recordUpdaterLifecycle( 'post_commit_cleanup_failed', @@ -167,12 +156,7 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { ) return } - // Why: a pre-native cleanup/tracing exception is not a package install failure and must not be labelled as one. const quitAndInstallNativeInvokedBeforeReset = this.quitAndInstallNativeInvoked - const recoveryStatus = - quitAndInstallNativeInvokedBeforeReset && !this.updateInstallCommitted - ? this.buildLinuxPackageInstallFailureStatus(error) - : null failServeUpdateHandoff('Could not invoke the native updater.') this.resetQuitForUpdateState() recordUpdaterLifecycle( @@ -183,16 +167,13 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { message: 'Could not start update install' } ) - this.sendInstallFailureStatus( - recoveryStatus ?? { - state: 'error', - // Why: past the native invoke this is the same pre-commit failure the event path reports, so it gets the same copy; only a pre-native exception can be helped by a restart. - // A synchronous throw out of quitAndInstall carries the same installer text the 'error' event would have. - message: quitAndInstallNativeInvokedBeforeReset - ? this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) - : 'Could not restart to install the update. Quit and reopen Orca, then try again.' - } - ) + this.sendInstallFailureStatus({ + state: 'error', + // A synchronous throw carries the same installer text the 'error' event would have. + message: quitAndInstallNativeInvokedBeforeReset + ? this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) + : 'Could not restart to install the update. Quit and reopen Orca, then try again.' + }) } } @@ -205,10 +186,8 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { ) { return false } - const recoveryStatus = this.buildLinuxPackageInstallFailureStatus(error) failServeUpdateHandoff('The native updater rejected the install request.') this.resetQuitForUpdateState() - // Durable data carries classification only — the cause text stays on the status the user can read. recordUpdaterLifecycle( 'quit_and_install_failed_via_event', { errorType: error instanceof Error ? error.name : typeof error }, @@ -217,12 +196,10 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { message: 'Update install could not start; recovered app state' } ) - this.sendInstallFailureStatus( - recoveryStatus ?? { - state: 'error', - message: this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) - } - ) + this.sendInstallFailureStatus({ + state: 'error', + message: this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) + }) return true } } diff --git a/src/main/updater/updater-install-support.ts b/src/main/updater/updater-install-support.ts index 048f293f01e..175362dad05 100644 --- a/src/main/updater/updater-install-support.ts +++ b/src/main/updater/updater-install-support.ts @@ -35,6 +35,12 @@ export abstract class UpdaterInstallSupport extends UpdaterCheckState { if (this.currentStatus.state === 'downloading' || this.currentStatus.state === 'downloaded') { return this.currentStatus.version } + if ( + this.currentStatus.state === 'error' && + this.currentStatus.recovery?.kind === 'linux-package-install' + ) { + return this.currentStatus.recovery.version + } return '' } @@ -70,7 +76,6 @@ export abstract class UpdaterInstallSupport extends UpdaterCheckState { this.quittingForUpdate = false this.updateInstallCommitted = false this.quitAndInstallNativeInvoked = false - this.lastInstallAttemptDiagnostic = null disarmUpdateInstallExitWatchdog() resetMacInstallState() } diff --git a/src/main/updater/updater-menu-checks.ts b/src/main/updater/updater-menu-checks.ts index b76d5c19710..5e5294f29dc 100644 --- a/src/main/updater/updater-menu-checks.ts +++ b/src/main/updater/updater-menu-checks.ts @@ -74,7 +74,7 @@ export abstract class UpdaterMenuChecks extends UpdaterScheduling { this.userInitiatedCheck = false this.finishActiveUpdateCheckAttempt() this.recordCompletedUpdateCheck() - this.sendStatus({ state: 'not-available', userInitiated: true }) + this.sendSettledCheckStatus({ state: 'not-available', userInitiated: true }) return false } return launch() diff --git a/src/main/updater/updater-package-recovery.ts b/src/main/updater/updater-package-recovery.ts index 870d601a5f3..f33b5044e48 100644 --- a/src/main/updater/updater-package-recovery.ts +++ b/src/main/updater/updater-package-recovery.ts @@ -1,22 +1,16 @@ +import { shell } from 'electron' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' import { getTrackedLinuxPackageArtifact, clearTrackedLinuxPackageArtifact, - revalidateLinuxPackageForInstall, resolveLinuxPackageInstallInstructions, - revealLinuxPackage, + resolveLinuxPackageRevealTarget, type LinuxPackageArtifact, type LinuxPackageRecoveryUnavailableReason } from '../linux-package-update-recovery' -import { - getLinuxPackageInstallDiagnostic, - parseLinuxPackageInstallExitCode, - redactLinuxPackageInstallText -} from '../linux-package-install-diagnostic' import type { LinuxPackageInstallInstructions, - LinuxPackageInstallRecovery, - UpdateStatus + LinuxPackageInstallRecovery } from '../../shared/update-status-types' import { UpdaterInstallSupport } from './updater-install-support' @@ -67,131 +61,47 @@ export abstract class UpdaterPackageRecovery extends UpdaterInstallSupport { ) } + /** Whether the card this action was invoked from still owns both the status and the artifact. */ + protected isCurrentLinuxPackageRecovery( + recovery: LinuxPackageInstallRecovery, + artifact: LinuxPackageArtifact | null + ): boolean { + return ( + this.getActiveLinuxPackageRecovery() === recovery && + getTrackedLinuxPackageArtifact() === artifact + ) + } + + protected assertCurrentLinuxPackageRecovery( + recovery: LinuxPackageInstallRecovery, + artifact: LinuxPackageArtifact | null + ): void { + if (!this.isCurrentLinuxPackageRecovery(recovery, artifact)) { + throw new Error('Package install recovery is no longer current.') + } + } + protected failLinuxPackageRecovery( recovery: LinuxPackageInstallRecovery, + artifact: LinuxPackageArtifact | null, reason: LinuxPackageRecoveryUnavailableReason ): never { + this.assertCurrentLinuxPackageRecovery(recovery, artifact) this.recordLinuxPackageRecoveryUnavailable(recovery, reason) const message = LINUX_PACKAGE_RECOVERY_MESSAGES[reason] - // Why: hashing 160 MB takes long enough for a new cycle to land. Acting on a stale verdict would - // destroy the newer artifact and clobber whatever card replaced this one. - const active = this.getActiveLinuxPackageRecovery() - const stillCurrent = - active?.version === recovery.version && active?.packageType === recovery.packageType - if (stillCurrent && RECOVERY_CLEARING_REASONS.includes(reason)) { + if (RECOVERY_CLEARING_REASONS.includes(reason)) { clearTrackedLinuxPackageArtifact() - this.sendStatus({ state: 'error', message }) + this.sendStatus({ state: 'error', message, version: recovery.version }) } throw new Error(message) } - /** - * Identifies the update cycle an install belongs to, so a verdict produced by a multi-second hash - * can be dropped when a newer cycle already replaced the card it would otherwise overwrite. - */ - protected getInstallCycleSignature(): string { - const recovery = this.getActiveLinuxPackageRecovery() - if (recovery) { - return `recovery:${recovery.packageType}:${recovery.version}` - } - return this.currentStatus.state === 'downloaded' - ? `downloaded:${this.currentStatus.version}` - : `state:${this.currentStatus.state}` - } - - /** - * Re-proves the retained package before the install starts. Returns false when the install must be - * abandoned; the artifact is only re-read here, so callers still own every teardown decision. - */ - protected async proveRetainedLinuxPackage(pendingVersion: string): Promise { - const artifact = getTrackedLinuxPackageArtifact() - if (!artifact) { - return true - } - // Why: an artifact retained from another cycle says nothing about the file electron-updater is - // about to install, so proving it would block a legitimate install on an unrelated digest. - if (pendingVersion && pendingVersion !== artifact.version) { - return true - } - const recovery = this.getActiveLinuxPackageRecovery() - const cycle = this.getInstallCycleSignature() - const reason = await this.revalidateRetainedLinuxPackage(artifact) - if (!reason) { - return true - } - this.reportLinuxPackageRevalidationFailure({ artifact, recovery, reason, cycle }) - return false - } - - /** The failing reason, or null when the retained package still matches its release digest. */ - protected async revalidateRetainedLinuxPackage( - artifact: LinuxPackageArtifact - ): Promise { - this.linuxPackageRevalidationInFlight = true - try { - const verdict = await revalidateLinuxPackageForInstall(artifact) - return verdict.ok ? null : verdict.reason - } catch (error) { - recordUpdaterLifecycle( - 'linux_package_revalidation_errored', - { errorType: error instanceof Error ? error.name : typeof error }, - { level: 'warn', message: 'Could not re-verify the retained update package' } - ) - // Why: fail closed — bytes we could not read are bytes we cannot hand to a root installer. - return 'read-failed' - } finally { - // Why: the invariant every install path depends on — a wedged flag would make quitAndInstall - // early-return for the rest of the session. - this.linuxPackageRevalidationInFlight = false - } - } - - protected reportLinuxPackageRevalidationFailure({ - artifact, - recovery, - reason, - cycle - }: { - artifact: LinuxPackageArtifact - recovery: LinuxPackageInstallRecovery | null - reason: LinuxPackageRecoveryUnavailableReason - cycle: string - }): void { - recordUpdaterLifecycle( - 'linux_package_revalidation_failed', - { - action: recovery ? 'retry-automatic' : 'restart-to-install', - packageType: artifact.packageType, - version: artifact.version, - reason - }, - { level: 'warn', message: 'Retained update package failed its pre-install digest check' } - ) - // Why: a package proven bad must not stay tracked, but a download that landed during the hash - // owns the slot now and destroying it would force a needless 160 MB redownload. - const clearsArtifact = RECOVERY_CLEARING_REASONS.includes(reason) - if (clearsArtifact && getTrackedLinuxPackageArtifact() === artifact) { - clearTrackedLinuxPackageArtifact() - } - // Why: same reasoning as failLinuxPackageRecovery — a verdict from a cycle that has since been - // replaced must not clobber whatever card the user is looking at now. - if (this.getInstallCycleSignature() !== cycle) { - return - } - this.sendInstallFailureStatus({ - state: 'error', - message: LINUX_PACKAGE_RECOVERY_MESSAGES[reason], - // Why: an unreadable file is not evidence the bytes changed, so the recovery card and its - // Copy/Show actions survive a transient I/O failure exactly as they do elsewhere. - ...(recovery && !clearsArtifact ? { recovery } : {}) - }) - } - protected async getLinuxPackageInstallInstructions(): Promise { const recovery = this.getActiveLinuxPackageRecovery() if (!recovery) { throw new Error('No package install recovery is available.') } + const artifact = getTrackedLinuxPackageArtifact() recordUpdaterLifecycle('linux_package_recovery_requested', { action: 'copy-command', packageType: recovery.packageType, @@ -202,6 +112,7 @@ export abstract class UpdaterPackageRecovery extends UpdaterInstallSupport { // Why: the renderer must distinguish "this machine has no package manager" (keep the card, promote // Show Package) from "the artifact is gone" (recovery is cleared and the card unmounts). if (result.reason === 'no-sudo' || result.reason === 'no-package-manager') { + this.assertCurrentLinuxPackageRecovery(recovery, artifact) this.recordLinuxPackageRecoveryUnavailable(recovery, result.reason) return { ok: false, @@ -209,8 +120,9 @@ export abstract class UpdaterPackageRecovery extends UpdaterInstallSupport { message: LINUX_PACKAGE_RECOVERY_MESSAGES[result.reason] } } - this.failLinuxPackageRecovery(recovery, result.reason) + this.failLinuxPackageRecovery(recovery, artifact, result.reason) } + this.assertCurrentLinuxPackageRecovery(recovery, artifact) return { ok: true, command: result.command, packageFileName: result.packageFileName } } @@ -219,56 +131,22 @@ export abstract class UpdaterPackageRecovery extends UpdaterInstallSupport { if (!recovery) { throw new Error('No package install recovery is available.') } + const artifact = getTrackedLinuxPackageArtifact() recordUpdaterLifecycle('linux_package_recovery_requested', { action: 'show-package', packageType: recovery.packageType, version: recovery.version }) - const result = await revealLinuxPackage(recovery) + const result = await resolveLinuxPackageRevealTarget(recovery) if (!result.ok) { - this.failLinuxPackageRecovery(recovery, result.reason) + this.failLinuxPackageRecovery(recovery, artifact, result.reason) } - } - - /** Builds a recoverable status when the native Linux package installer rejects a retained artifact. */ - protected buildLinuxPackageInstallFailureStatus(error: unknown): UpdateStatus | null { - const artifact = getTrackedLinuxPackageArtifact() - if (!artifact) { - return null - } - const pendingVersion = this.getPendingInstallVersion() - if (pendingVersion && pendingVersion !== artifact.version) { - return null - } - const diagnostic = getLinuxPackageInstallDiagnostic() ?? this.lastInstallAttemptDiagnostic - const reason = diagnostic?.reason ?? 'package-install-failed' - const exitCode = parseLinuxPackageInstallExitCode(error) - recordUpdaterLifecycle( - 'linux_package_install_failed', - { - packageType: artifact.packageType, - reason, - ...(exitCode === null ? {} : { exitCode }), - version: artifact.version, - errorType: error instanceof Error ? error.name : typeof error - }, - { level: 'warn', message: 'Linux package install failed; cached package retained' } - ) - const message = - diagnostic?.message ?? - (error instanceof Error - ? redactLinuxPackageInstallText(error.message, artifact.path) - : null) ?? - 'The system package installer did not start.' - return { - state: 'error', - message, - recovery: { - kind: 'linux-package-install', - packageType: artifact.packageType, - reason, - version: artifact.version - } + this.assertCurrentLinuxPackageRecovery(recovery, artifact) + // Why: this cache path belongs to the installed app host, not a workspace's SSH or WSL host. + try { + shell.showItemInFolder(result.path) + } catch { + this.failLinuxPackageRecovery(recovery, artifact, 'read-failed') } } } diff --git a/src/main/updater/updater-remote-status.ts b/src/main/updater/updater-remote-status.ts index 6e3db76b0c8..fc34d40b6d3 100644 --- a/src/main/updater/updater-remote-status.ts +++ b/src/main/updater/updater-remote-status.ts @@ -7,6 +7,7 @@ import type { RemoteServerUpdateSupport } from '../../shared/remote-server-update' import { hasServeUpdateSupervisor } from '../serve-update-handoff' +import { getLinuxPackageType } from '../linux-update-package-type' import { UpdaterNudge } from './updater-nudge' import type { UpdateInstallMode } from './updater-state' @@ -31,7 +32,13 @@ export abstract class UpdaterRemoteStatus extends UpdaterNudge { reason: 'updater-unavailable' } } - if (this.updateInstallMode === 'unsupported-headless-serve') { + const linuxPackageType = getLinuxPackageType() + if ( + this.updateInstallMode === 'unsupported-headless-serve' || + linuxPackageType === 'deb' || + linuxPackageType === 'rpm' || + linuxPackageType === 'unusable' + ) { return { installMode: this.updateInstallMode, automatic: false, diff --git a/src/main/updater/updater-setup.ts b/src/main/updater/updater-setup.ts index 21354f97af3..d60444d449a 100644 --- a/src/main/updater/updater-setup.ts +++ b/src/main/updater/updater-setup.ts @@ -12,7 +12,7 @@ import type { RemoteServerUpdaterSnapshot, RemoteServerUpdateSupport } from '../../shared/remote-server-update' -import { getLinuxRootPackageType } from '../linux-update-package-type' +import { getLinuxPackageType } from '../linux-update-package-type' import { createUpdaterDiagnosticLogger } from '../linux-package-install-diagnostic' import { registerAutoUpdaterHandlers } from '../updater-events' import { getServeUpdateHandoffFailure } from '../serve-update-handoff' @@ -143,9 +143,9 @@ export class UpdaterSetup extends UpdaterDownloadInstall { autoUpdater.disableDifferentialDownload = false } // Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly. - // Root Linux packages also opt out: an implicit quit-time escalation would fail after the UI is gone, leaving no recovery surface. + // Only an explicit AppImage/non-root marker may opt into electron-updater's implicit quit install. autoUpdater.autoInstallOnAppQuit = - this.updateInstallMode === 'interactive' && getLinuxRootPackageType() === null + this.updateInstallMode === 'interactive' && getLinuxPackageType() === 'non-root' // Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner. autoUpdater.autoRunAppAfterInstall = this.updateInstallMode === 'interactive' // Why: our only on-machine window into electron-updater; otherwise an unexpected update-not-available or failed fetch is invisible. diff --git a/src/main/updater/updater-state.ts b/src/main/updater/updater-state.ts index a410c5e10b8..d15ce42520c 100644 --- a/src/main/updater/updater-state.ts +++ b/src/main/updater/updater-state.ts @@ -1,6 +1,5 @@ import type { BrowserWindow } from 'electron' import type { ElectronAutoUpdater } from '../electron-updater-loader' -import type { LinuxPackageInstallDiagnostic } from '../linux-package-install-diagnostic' import type { LocalBuildFeed } from '../local-builds/local-build-feed-server' import type { UpdateSource, UpdateStatus } from '../../shared/update-status-types' import type { ReleaseChannel } from '../../shared/release-channel' @@ -54,9 +53,6 @@ export abstract class UpdaterState { protected nudgeCheckTimer: ReturnType | null = null protected pendingQuitAndInstallTimer: ReturnType | null = null protected quitAndInstallInProgress = false - // Why: the pre-install digest re-proof streams the whole package, so a second install request can - // arrive while it runs — after the quit timer was cleared but before the handoff owns the process. - protected linuxPackageRevalidationInFlight = false protected updateInstallMode: UpdateInstallMode = 'interactive' protected lastInstallDeferralVersion = { download: null as string | null, @@ -66,8 +62,6 @@ export abstract class UpdaterState { protected updateInstallCommitted = false // Why: recovery must only run after the native quitAndInstall call; pre-native errors must not clear quittingForUpdate or look like install recovery. protected quitAndInstallNativeInvoked = false - // Why: a synchronous throw out of quitAndInstall ends diagnostic capture before the catch runs, so stash the redacted text for it. - protected lastInstallAttemptDiagnostic: LinuxPackageInstallDiagnostic | null = null protected persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null protected _getLastUpdateCheckAt: (() => number | null) | null = null protected backgroundCheckLaunchPending = false diff --git a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx index 0c44cf153e5..8e1bc86d729 100644 --- a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx +++ b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx @@ -3,16 +3,21 @@ import { act, cleanup, fireEvent, render, screen, type RenderResult } from '@tes import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { LinuxPackageInstallRecovery } from '../../../shared/update-status-types' + +const { toastSuccess } = vi.hoisted(() => ({ toastSuccess: vi.fn() })) +vi.mock('sonner', () => ({ toast: { success: toastSuccess } })) + import { LinuxPackageInstallRecoveryCard } from './LinuxPackageInstallRecoveryCard' const RELEASE_URL = 'https://github.com/stablyai/orca/releases/tag/v1.4.200' const DIAGNOSTIC = 'pkexec: no polkit authentication agent found' const INSTALL_COMMAND = 'sudo apt-get install -y /tmp/orca-updates/orca_1.4.200_amd64.deb' const PACKAGE_FILE_NAME = 'orca_1.4.200_amd64.deb' -const SUMMARY = 'Orca downloaded the update but could not install the system package automatically.' +const SUMMARY = + 'Orca downloaded the system package. Quit Orca before finishing the update from a terminal.' const COPIED_NOTE = - `Command copied. Run it in a system terminal to install ${PACKAGE_FILE_NAME}, ` + - 'then quit and reopen Orca.' + `Command copied. Quit Orca, run it in a system terminal to install ${PACKAGE_FILE_NAME}, ` + + 'then reopen Orca.' const INSTRUCTIONS = { ok: true as const, command: INSTALL_COMMAND, @@ -26,17 +31,16 @@ const NO_PACKAGE_MANAGER = { const getInstructions = vi.fn() const showLinuxPackage = vi.fn() -const quitAndInstall = vi.fn() const writeClipboardText = vi.fn() const openUrl = vi.fn() const onClose = vi.fn() const allMocks = [ getInstructions, showLinuxPackage, - quitAndInstall, writeClipboardText, openUrl, - onClose + onClose, + toastSuccess ] function makeRecovery( @@ -45,7 +49,7 @@ function makeRecovery( return { kind: 'linux-package-install', packageType: 'deb', - reason: 'package-install-failed', + reason: 'manual-install-required', version: '1.4.200', ...overrides } @@ -68,14 +72,6 @@ function renderCard(options: CardOptions = {}): RenderResult { return render(cardElement(options)) } -/** - * Main force-sends a new recovery object on every attempt, which re-renders this card rather than - * remounting it. That push is the only signal a retry failed — quitAndInstall already resolved. - */ -function pushFreshRecovery(view: RenderResult, options: CardOptions = {}): void { - view.rerender(cardElement({ ...options, recovery: makeRecovery(options.recovery) })) -} - // Why: each action chains several promises; drain them without depending on timer faking. async function flushActions(): Promise { await act(async () => { @@ -85,12 +81,18 @@ async function flushActions(): Promise { }) } -function deferred(): { promise: Promise; resolve: (value: T) => void } { +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} { let resolve!: (value: T) => void - const promise = new Promise((res) => { + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { resolve = res + reject = rej }) - return { promise, resolve } + return { promise, resolve, reject } } function button(name: string): HTMLElement { @@ -120,8 +122,8 @@ beforeEach(() => { allMocks.forEach((mock) => mock.mockReset()) getInstructions.mockResolvedValue(INSTRUCTIONS) showLinuxPackage.mockResolvedValue(undefined) - quitAndInstall.mockResolvedValue(undefined) writeClipboardText.mockResolvedValue(undefined) + openUrl.mockResolvedValue(undefined) Object.defineProperty(window, 'api', { configurable: true, value: { @@ -129,8 +131,7 @@ beforeEach(() => { ui: { writeClipboardText }, updater: { getLinuxPackageInstallInstructions: getInstructions, - showLinuxPackage, - quitAndInstall + showLinuxPackage } } }) @@ -142,27 +143,27 @@ afterEach(() => { }) describe('LinuxPackageInstallRecoveryCard copy', () => { - it('leads with the recovery copy and the three dedicated actions', () => { + it('leads with the manual-install copy and recovery actions', () => { renderCard() - expect(screen.getByText('Automatic Install Failed')).toBeTruthy() + expect(screen.getByText('Manual Install Required')).toBeTruthy() expect(screen.getByText(SUMMARY)).toBeTruthy() expect( screen.getByText(/a system terminal on the computer where Orca is installed/) ).toBeTruthy() - expect(screen.getByText(/quit and reopen Orca to run the new version/)).toBeTruthy() + expect(screen.getByText(/Copy the command, quit Orca/)).toBeTruthy() expect(button('Copy Install Command')).toBeTruthy() - expect(button('Try Automatic Install Again')).toBeTruthy() expect(button('Show Package')).toBeTruthy() + expect(button('Download Manually')).toBeTruthy() + expect(screen.queryByRole('button', { name: /Automatic Install/ })).toBeNull() }) it('never offers the generic Retry Download action', () => { renderCard() expect(screen.queryByRole('button', { name: 'Retry Download' })).toBeNull() - // The release fallback only appears once no command can be built. - expect(screen.queryByRole('button', { name: 'Download Manually' })).toBeNull() + expect(button('Download Manually')).toBeTruthy() }) it('minimizes to the status bar from the header control', () => { @@ -182,73 +183,12 @@ describe('LinuxPackageInstallRecoveryCard copy action', () => { expect(getInstructions).toHaveBeenCalledTimes(1) expect(writeClipboardText).toHaveBeenCalledWith(INSTALL_COMMAND) - // The confirmation names the artifact and never replaces the button's own label. - expect(footnoteText()).toBe(COPIED_NOTE) - expect(footnoteText()).toContain(PACKAGE_FILE_NAME) + expect(toastSuccess).toHaveBeenCalledWith(COPIED_NOTE) + expect(footnoteElement()).toBeNull() expect(button('Copy Install Command')).toBeTruthy() expect(screen.queryByRole('button', { name: 'Command copied' })).toBeNull() }) - it('announces through the card, not a nested live region', async () => { - renderCard() - - fireEvent.click(button('Copy Install Command')) - await flushActions() - - expect(footnoteText()).toBe(COPIED_NOTE) - expect(footnoteElement()?.hasAttribute('role')).toBe(false) - expect(screen.queryByRole('status')).toBeNull() - }) - - it('clears the confirmation when another action starts', async () => { - renderCard() - - fireEvent.click(button('Copy Install Command')) - await flushActions() - expect(footnoteText()).toBe(COPIED_NOTE) - - fireEvent.click(button('Show Package')) - await flushActions() - - expect(footnoteElement()).toBeNull() - }) - - it('clears the confirmation when the automatic install is retried', async () => { - renderCard() - - fireEvent.click(button('Copy Install Command')) - await flushActions() - expect(footnoteText()).toBe(COPIED_NOTE) - - fireEvent.click(button('Try Automatic Install Again')) - await flushActions() - - expect(quitAndInstall).toHaveBeenCalledTimes(1) - expect(footnoteElement()).toBeNull() - }) - - it('retires the copy confirmation after the transient window', async () => { - // Why: happy-dom's window timers escape Vitest's fake clock, so capture the scheduled callback. - const scheduled: { handler: () => void; delay?: number }[] = [] - vi.spyOn(window, 'setTimeout').mockImplementation(((handler: () => void, delay?: number) => { - scheduled.push({ handler, delay }) - return scheduled.length - }) as unknown as typeof window.setTimeout) - vi.spyOn(window, 'clearTimeout').mockImplementation(() => undefined) - renderCard() - - fireEvent.click(button('Copy Install Command')) - await flushActions() - expect(footnoteText()).toBe(COPIED_NOTE) - - const expiry = scheduled.find((entry) => entry.delay === 4_000) - expect(expiry).toBeTruthy() - act(() => expiry?.handler()) - - expect(footnoteElement()).toBeNull() - expect(button('Copy Install Command')).toBeTruthy() - }) - it('keeps the copy path when the instruction call rejects', async () => { getInstructions.mockRejectedValue( new Error( @@ -266,8 +206,8 @@ describe('LinuxPackageInstallRecoveryCard copy action', () => { expect(footnoteElement()?.className).toContain('text-destructive') // Why: only main can rule out a command; a rejection must not push the 160 MB redownload. expect(button('Copy Install Command').dataset.variant).toBe('default') - expect(screen.getByText(/Copy the command and run it/)).toBeTruthy() - expect(screen.queryByRole('button', { name: 'Download Manually' })).toBeNull() + expect(screen.getByText(/Copy the command, quit Orca/)).toBeTruthy() + expect(button('Download Manually')).toBeTruthy() expect(writeClipboardText).not.toHaveBeenCalled() }) @@ -281,9 +221,9 @@ describe('LinuxPackageInstallRecoveryCard copy action', () => { await flushActions() expect(footnoteText()).toBe('The downloaded package no longer matches the verified release.') - expect(screen.getByText('Automatic Install Failed')).toBeTruthy() + expect(screen.getByText('Manual Install Required')).toBeTruthy() expect(button('Copy Install Command').dataset.variant).toBe('default') - expect(button('Show Package').dataset.variant).toBe('link') + expect(button('Show Package').dataset.variant).toBe('outline') }) it('retries the instruction call after a rejection', async () => { @@ -297,7 +237,8 @@ describe('LinuxPackageInstallRecoveryCard copy action', () => { await flushActions() expect(getInstructions).toHaveBeenCalledTimes(2) - expect(footnoteText()).toBe(COPIED_NOTE) + expect(footnoteElement()).toBeNull() + expect(toastSuccess).toHaveBeenCalledWith(COPIED_NOTE) }) it('keeps the copy path when only the clipboard write fails', async () => { @@ -313,9 +254,9 @@ describe('LinuxPackageInstallRecoveryCard copy action', () => { const copyButton = button('Copy Install Command') expect(copyButton.dataset.variant).toBe('default') expect(isAriaDisabled(copyButton)).toBe(false) - expect(button('Show Package').dataset.variant).toBe('link') - expect(screen.getByText(/Copy the command and run it/)).toBeTruthy() - expect(screen.queryByRole('button', { name: 'Download Manually' })).toBeNull() + expect(button('Show Package').dataset.variant).toBe('outline') + expect(screen.getByText(/Copy the command, quit Orca/)).toBeTruthy() + expect(button('Download Manually')).toBeTruthy() }) it('recovers from a clipboard failure on the next copy attempt', async () => { @@ -329,7 +270,69 @@ describe('LinuxPackageInstallRecoveryCard copy action', () => { await flushActions() expect(writeClipboardText).toHaveBeenCalledTimes(2) - expect(footnoteText()).toBe(COPIED_NOTE) + expect(footnoteElement()).toBeNull() + expect(toastSuccess).toHaveBeenCalledWith(COPIED_NOTE) + }) + + it('does not write a command after the card unmounts', async () => { + const pending = deferred() + getInstructions.mockReturnValue(pending.promise) + const { unmount } = renderCard() + + fireEvent.click(button('Copy Install Command')) + unmount() + pending.resolve(INSTRUCTIONS) + await flushActions() + + expect(writeClipboardText).not.toHaveBeenCalled() + expect(toastSuccess).not.toHaveBeenCalled() + }) + + it('does not write a command for a recovery the card has replaced', async () => { + const pending = deferred() + getInstructions.mockReturnValue(pending.promise) + const view = renderCard({ recovery: makeRecovery() }) + + fireEvent.click(button('Copy Install Command')) + view.rerender(cardElement({ recovery: makeRecovery({ version: '1.4.201' }) })) + pending.resolve(INSTRUCTIONS) + await flushActions() + + expect(writeClipboardText).not.toHaveBeenCalled() + expect(toastSuccess).not.toHaveBeenCalled() + }) + + it('ignores an instruction rejection from a same-version recovery cycle', async () => { + const pending = deferred() + const recovery = makeRecovery() + getInstructions.mockReturnValue(pending.promise) + const view = renderCard({ recovery }) + + fireEvent.click(button('Copy Install Command')) + view.rerender(cardElement({ recovery: { ...recovery } })) + pending.reject(new Error('Package install recovery is no longer current.')) + await flushActions() + + expect(footnoteElement()).toBeNull() + expect(isAriaDisabled(button('Copy Install Command'))).toBe(false) + }) + + it('ignores a clipboard rejection from a replaced recovery cycle', async () => { + const pending = deferred() + const recovery = makeRecovery() + writeClipboardText.mockReturnValue(pending.promise) + const view = renderCard({ recovery }) + + fireEvent.click(button('Copy Install Command')) + await flushActions() + expect(writeClipboardText).toHaveBeenCalledWith(INSTALL_COMMAND) + + view.rerender(cardElement({ recovery: { ...recovery } })) + pending.reject(new Error('Clipboard is unavailable.')) + await flushActions() + + expect(footnoteElement()).toBeNull() + expect(toastSuccess).not.toHaveBeenCalled() }) }) @@ -344,21 +347,18 @@ describe('LinuxPackageInstallRecoveryCard hashing state', () => { const checking = button('Checking package...') expect(isAriaDisabled(checking)).toBe(true) expect(isAriaDisabled(button('Show Package'))).toBe(true) - expect(isAriaDisabled(button('Try Automatic Install Again'))).toBe(true) fireEvent.click(checking) fireEvent.click(button('Show Package')) - fireEvent.click(button('Try Automatic Install Again')) // Why: the buttons stay clickable for focus reasons, so the handlers must do the refusing. expect(getInstructions).toHaveBeenCalledTimes(1) expect(showLinuxPackage).not.toHaveBeenCalled() - expect(quitAndInstall).not.toHaveBeenCalled() pending.resolve(INSTRUCTIONS) await flushActions() - expect(footnoteText()).toBe(COPIED_NOTE) + expect(toastSuccess).toHaveBeenCalledWith(COPIED_NOTE) expect(isAriaDisabled(button('Show Package'))).toBe(false) }) @@ -370,7 +370,7 @@ describe('LinuxPackageInstallRecoveryCard hashing state', () => { fireEvent.click(button('Copy Install Command')) // Why: ui/button styles only `disabled:`, so without these an inert action looks fully live. - for (const name of ['Checking package...', 'Try Automatic Install Again', 'Show Package']) { + for (const name of ['Checking package...', 'Show Package']) { expect(button(name).className).toContain('aria-disabled:opacity-50') expect(button(name).className).toContain('aria-disabled:cursor-default') } @@ -436,8 +436,11 @@ describe('LinuxPackageInstallRecoveryCard details', () => { fireEvent.click(button('Show details')) + expect(screen.getByText('Details')).toBeTruthy() + expect(screen.queryByText('Last error')).toBeNull() // Why: the digest check is a point-in-time claim, not a standing guarantee about the file. const detail = screen.getByText(/Orca checks the downloaded file against the release metadata/) + expect(detail.textContent).not.toContain(DIAGNOSTIC) expect(detail.textContent).toContain('at the moment it builds this command') expect(detail.textContent).toContain( 'The system package itself is not signature-checked, and Orca cannot vouch for the file ' + @@ -456,7 +459,10 @@ describe('LinuxPackageInstallRecoveryCard details', () => { it('scrolls long diagnostics instead of widening the card', () => { const long = `${DIAGNOSTIC} ${'diagnostic-overflow '.repeat(400)}` - const { container } = renderCard({ diagnostic: long }) + const { container } = renderCard({ + diagnostic: long, + recovery: makeRecovery({ reason: 'authentication-denied' }) + }) fireEvent.click(button('Show details')) @@ -468,97 +474,12 @@ describe('LinuxPackageInstallRecoveryCard details', () => { }) }) -describe('LinuxPackageInstallRecoveryCard retry', () => { - it('retries the automatic install through quitAndInstall', () => { - renderCard() - - fireEvent.click(button('Try Automatic Install Again')) - - expect(quitAndInstall).toHaveBeenCalledTimes(1) - expect(getInstructions).not.toHaveBeenCalled() - }) - - it('holds the busy slot while the quit is in flight', async () => { - renderCard() - - fireEvent.click(button('Try Automatic Install Again')) - await flushActions() - - // Why: the retry now re-proves the package digest before quitting, so the click must report - // progress instead of leaving three inert buttons for the length of the hash. - expect(isAriaDisabled(button('Checking package...'))).toBe(true) - // Why: quitAndInstall resolves as soon as main schedules the install, so a resolved promise is - // not an outcome — the slot stays held until a real status arrives. - expect(isAriaDisabled(button('Copy Install Command'))).toBe(true) - expect(isAriaDisabled(button('Show Package'))).toBe(true) - - fireEvent.click(button('Copy Install Command')) - fireEvent.click(button('Show Package')) - await flushActions() - - expect(getInstructions).not.toHaveBeenCalled() - expect(showLinuxPackage).not.toHaveBeenCalled() - expect(quitAndInstall).toHaveBeenCalledTimes(1) - }) - - it('releases the busy slot when a fresh recovery status arrives', async () => { - const view = renderCard() - - fireEvent.click(button('Try Automatic Install Again')) - await flushActions() - expect(isAriaDisabled(button('Copy Install Command'))).toBe(true) - - // A failed retry never rejects — main pushes a new recovery status a moment later. - pushFreshRecovery(view) - - expect(isAriaDisabled(button('Copy Install Command'))).toBe(false) - expect(isAriaDisabled(button('Show Package'))).toBe(false) - expect(isAriaDisabled(button('Try Automatic Install Again'))).toBe(false) - - fireEvent.click(button('Copy Install Command')) - await flushActions() - - expect(getInstructions).toHaveBeenCalledTimes(1) - expect(writeClipboardText).toHaveBeenCalledWith(INSTALL_COMMAND) - expect(footnoteText()).toBe(COPIED_NOTE) - }) - - it('leaves an in-flight hash job busy when a fresh recovery status arrives', () => { - const pending = deferred() - getInstructions.mockReturnValue(pending.promise) - const view = renderCard() - - fireEvent.click(button('Copy Install Command')) - pushFreshRecovery(view) - - // Why: the release is scoped to the retry slot — a running hash must keep its busy state. - expect(button('Checking package...')).toBeTruthy() - expect(isAriaDisabled(button('Show Package'))).toBe(true) - - pending.resolve(INSTRUCTIONS) - }) - - it('also releases the busy slot if the preload call itself rejects', async () => { - quitAndInstall.mockRejectedValue(new Error('Error: updater is not initialized')) - renderCard() - - fireEvent.click(button('Try Automatic Install Again')) - await flushActions() - - expect(footnoteText()).toBe('updater is not initialized') - expect(isAriaDisabled(button('Copy Install Command'))).toBe(false) - expect(isAriaDisabled(button('Show Package'))).toBe(false) - }) -}) - describe('LinuxPackageInstallRecoveryCard reveal', () => { - it('reveals the retained package from the link-style action', async () => { + it('reveals the retained package from the secondary action', async () => { renderCard() const show = button('Show Package') - expect(show.dataset.variant).toBe('link') - // The link-style action still has to meet the touch-target floor. - expect(show.className).toContain('min-h-[44px]') + expect(show.dataset.variant).toBe('outline') fireEvent.click(show) await flushActions() @@ -578,6 +499,17 @@ describe('LinuxPackageInstallRecoveryCard reveal', () => { // Why: a reveal failure is not a command-build failure, so the copy path must survive it. expect(button('Copy Install Command')).toBeTruthy() }) + + it('reports a failed official-release open in place', async () => { + openUrl.mockRejectedValue(new Error('Could not open the release page.')) + renderCard() + + fireEvent.click(button('Download Manually')) + await flushActions() + + expect(footnoteText()).toBe('Could not open the release page.') + expect(footnoteElement()?.className).toContain('text-destructive') + }) }) describe('LinuxPackageInstallRecoveryCard without a usable command', () => { @@ -590,10 +522,8 @@ describe('LinuxPackageInstallRecoveryCard without a usable command', () => { expect(screen.queryByRole('button', { name: 'Copy Install Command' })).toBeNull() expect(button('Show Package').dataset.variant).toBe('default') - expect(button('Try Automatic Install Again')).toBeTruthy() expect(footnoteText()).toBe(NO_PACKAGE_MANAGER.message) - // The copy-and-run explainer would be dead advice with no command to copy. - expect(screen.queryByText(/Copy the command and run it/)).toBeNull() + expect(screen.getByText(/Quit Orca before finishing the update/)).toBeTruthy() fireEvent.click(button('Download Manually')) expect(openUrl).toHaveBeenCalledWith(RELEASE_URL) @@ -627,43 +557,6 @@ describe('LinuxPackageInstallRecoveryCard without a usable command', () => { expect(showLinuxPackage).toHaveBeenCalledTimes(1) }) - - it('restores the copy path when the automatic install is retried', async () => { - getInstructions.mockResolvedValueOnce(NO_PACKAGE_MANAGER) - renderCard() - - fireEvent.click(button('Copy Install Command')) - await flushActions() - expect(screen.queryByRole('button', { name: 'Copy Install Command' })).toBeNull() - - // Why: a retry re-evaluates the machine, so the earlier "no command" verdict must not stick. - fireEvent.click(button('Try Automatic Install Again')) - await flushActions() - - expect(quitAndInstall).toHaveBeenCalledTimes(1) - expect(button('Copy Install Command').dataset.variant).toBe('default') - expect(button('Show Package').dataset.variant).toBe('link') - expect(screen.getByText(/Copy the command and run it/)).toBeTruthy() - expect(screen.queryByRole('button', { name: 'Download Manually' })).toBeNull() - }) - - it('copies again once a fresh recovery status follows a failed retry', async () => { - getInstructions.mockResolvedValueOnce(NO_PACKAGE_MANAGER) - const view = renderCard() - - fireEvent.click(button('Copy Install Command')) - await flushActions() - - fireEvent.click(button('Try Automatic Install Again')) - await flushActions() - pushFreshRecovery(view) - - fireEvent.click(button('Copy Install Command')) - await flushActions() - - expect(writeClipboardText).toHaveBeenCalledWith(INSTALL_COMMAND) - expect(footnoteText()).toBe(COPIED_NOTE) - }) }) describe('LinuxPackageInstallRecoveryCard keyboard', () => { @@ -690,8 +583,8 @@ describe('LinuxPackageInstallRecoveryCard keyboard', () => { 'Minimize to status bar', 'Show details', 'Copy Install Command', - 'Try Automatic Install Again', - 'Show Package' + 'Show Package', + 'Download Manually' ] for (const name of order) { await user.tab() diff --git a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx index 69a0f73d627..10980667055 100644 --- a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx +++ b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx @@ -1,17 +1,17 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useLayoutEffect, useRef, useState } from 'react' +import { toast } from 'sonner' import type { LinuxPackageInstallInstructions, LinuxPackageInstallRecovery } from '../../../shared/update-status-types' import { UpdateErrorCardContent } from './UpdateErrorCardContent' import { translate } from '@/i18n/i18n' - -const COPY_CONFIRMATION_MS = 4_000 +import { useMountedRef } from '@/hooks/useMountedRef' function copiedNote(packageFileName: string): string { return translate( 'auto.components.LinuxPackageInstallRecoveryCard.aa57fa4f80', - 'Command copied. Run it in a system terminal to install {{value0}}, then quit and reopen Orca.', + 'Command copied. Quit Orca, run it in a system terminal to install {{value0}}, then reopen Orca.', { value0: packageFileName } @@ -39,15 +39,15 @@ export function LinuxPackageInstallRecoveryCard({ // be resolved per render — at module scope they would freeze the whole card in English. const TITLE = translate( 'auto.components.LinuxPackageInstallRecoveryCard.53e1559f99', - 'Automatic Install Failed' + 'Manual Install Required' ) const SUMMARY = translate( 'auto.components.LinuxPackageInstallRecoveryCard.a7ac6ec78b', - 'Orca downloaded the update but could not install the system package automatically.' + 'Orca downloaded the system package. Quit Orca before finishing the update from a terminal.' ) const EXPLAINER = translate( 'auto.components.LinuxPackageInstallRecoveryCard.82c6dbea00', - 'Copy the command and run it in a system terminal on the computer where Orca is installed. After it finishes, quit and reopen Orca to run the new version.' + 'Copy the command, quit Orca, and run it in a system terminal on the computer where Orca is installed. Reopen Orca after it finishes.' ) const AGENT_NOTE = translate( 'auto.components.LinuxPackageInstallRecoveryCard.53c4b8e148', @@ -61,42 +61,23 @@ export function LinuxPackageInstallRecoveryCard({ 'auto.components.LinuxPackageInstallRecoveryCard.c732bcbf8f', 'Checking package...' ) - const [pendingAction, setPendingAction] = useState<'copy' | 'show' | 'retry' | null>(null) + const [pendingAction, setPendingAction] = useState<'copy' | 'show' | null>(null) const [actionError, setActionError] = useState(null) - const [copiedFileName, setCopiedFileName] = useState(null) // Why: the trusted system directories lack sudo or a package manager — no command can be offered at all. const [commandUnavailable, setCommandUnavailable] = useState(false) - const mountedRef = useRef(true) - - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - } - }, []) - - // Why: quitAndInstall resolves as soon as main schedules the install, so a failed retry never - // rejects — it arrives as a new recovery status. Without this the busy slot never clears and every - // action stays inert for the rest of the session. - useEffect(() => { - setPendingAction((current) => (current === 'retry' ? null : current)) + const mountedRef = useMountedRef() + const recoveryRef = useRef(recovery) + useLayoutEffect(() => { + recoveryRef.current = recovery }, [recovery]) + const isCurrentRecovery = (): boolean => mountedRef.current && recoveryRef.current === recovery - useEffect(() => { - if (!copiedFileName) { - return - } - const timer = window.setTimeout(() => setCopiedFileName(null), COPY_CONFIRMATION_MS) - return () => window.clearTimeout(timer) - }, [copiedFileName]) - - const handleCopyCommand = useCallback(() => { + const handleCopyCommand = (): void => { if (pendingAction) { return } setPendingAction('copy') setActionError(null) - setCopiedFileName(null) void (async () => { let instructions: LinuxPackageInstallInstructions try { @@ -104,26 +85,29 @@ export function LinuxPackageInstallRecoveryCard({ } catch (error) { // Why: only main knows whether the machine simply has no package manager; any other failure // (stale status, untrusted sender, invalid artifact) must not demote the copy path. - if (mountedRef.current) { + if (isCurrentRecovery()) { setActionError(toMessage(error)) } return } if (!instructions.ok) { - if (mountedRef.current) { + if (isCurrentRecovery()) { setCommandUnavailable(true) setActionError(instructions.message) } return } + if (!isCurrentRecovery()) { + return + } try { await window.api.ui.writeClipboardText(instructions.command) - if (mountedRef.current) { - setCopiedFileName(instructions.packageFileName) + if (isCurrentRecovery()) { + toast.success(copiedNote(instructions.packageFileName)) } } catch (error) { // Why: the command itself is valid — only the clipboard failed, so keep the copy action. - if (mountedRef.current) { + if (isCurrentRecovery()) { setActionError(toMessage(error)) } } @@ -132,19 +116,18 @@ export function LinuxPackageInstallRecoveryCard({ setPendingAction(null) } }) - }, [pendingAction]) + } - const handleShowPackage = useCallback(() => { + const handleShowPackage = (): void => { if (pendingAction) { return } setPendingAction('show') setActionError(null) - setCopiedFileName(null) void window.api.updater .showLinuxPackage() .catch((error: unknown) => { - if (mountedRef.current) { + if (isCurrentRecovery()) { setActionError(toMessage(error)) } }) @@ -153,29 +136,9 @@ export function LinuxPackageInstallRecoveryCard({ setPendingAction(null) } }) - }, [pendingAction]) + } - const handleRetryAutomatic = useCallback(() => { - // Why: guard in the handler, not only through the disabled prop, so no path can quit and install mid-hash. - if (pendingAction) { - return - } - // Why: the quit sequence owns the app from here; hold the busy slot so no other action starts - // work mid-quit. Released by the effect above when a fresh recovery status says Orca stayed open. - setPendingAction('retry') - setActionError(null) - setCopiedFileName(null) - // Why: a fresh install attempt re-evaluates the machine, so an earlier "no command" verdict must not stick. - setCommandUnavailable(false) - void window.api.updater.quitAndInstall().catch((error: unknown) => { - if (mountedRef.current) { - setActionError(toMessage(error)) - setPendingAction(null) - } - }) - }, [pendingAction]) - - // Why: the label keeps naming its action — the footnote below the buttons carries the confirmation. + // Why: the label keeps naming its action while the toast carries transient confirmation. const copyAction = { label: translate( 'auto.components.LinuxPackageInstallRecoveryCard.55c86654b7', @@ -193,40 +156,28 @@ export function LinuxPackageInstallRecoveryCard({ disabled: pendingAction !== null, onClick: handleShowPackage } - const retryAction = { - label: translate( - 'auto.components.LinuxPackageInstallRecoveryCard.3da99454c6', - 'Try Automatic Install Again' - ), - // Why: the retry re-proves the package digest before it quits, so the click is no longer - // instant — without this the card would just go inert for the length of the hash. - pendingLabel: CHECKING_LABEL, - isPending: pendingAction === 'retry', - disabled: pendingAction !== null, - onClick: handleRetryAutomatic - } - const officialReleaseAction = releaseUrl ? { label: translate('auto.components.UpdateCard.47126bcf57', 'Download Manually'), - onClick: () => void window.api.shell.openUrl(releaseUrl) + onClick: () => { + setActionError(null) + void window.api.shell.openUrl(releaseUrl).catch((error: unknown) => { + if (isCurrentRecovery()) { + setActionError(toMessage(error)) + } + }) + } } : undefined const detail = [ recovery.reason === 'authentication-agent-unavailable' ? AGENT_NOTE : null, - diagnostic, + recovery.reason === 'manual-install-required' ? null : diagnostic, TRUST_NOTE ] .filter(Boolean) .join(' ') - const footnote = actionError - ? { text: actionError, tone: 'destructive' as const } - : copiedFileName - ? { text: copiedNote(copiedFileName) } - : undefined - return ( ) diff --git a/src/renderer/src/components/UpdateCard.error-card.test.tsx b/src/renderer/src/components/UpdateCard.error-card.test.tsx index 4cb297b7ca4..1e6da6879f7 100644 --- a/src/renderer/src/components/UpdateCard.error-card.test.tsx +++ b/src/renderer/src/components/UpdateCard.error-card.test.tsx @@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, screen, type RenderResult } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { LinuxPackageInstallRecovery } from '../../../shared/update-status-types' +import type { LinuxPackageInstallRecovery, UpdateStatus } from '../../../shared/update-status-types' import { useAppStore } from '../store' import { UpdateCard } from './UpdateCard' @@ -19,17 +19,13 @@ const setSettings = vi.fn() const PACKAGE_RECOVERY: LinuxPackageInstallRecovery = { kind: 'linux-package-install', packageType: 'deb', - reason: 'authentication-agent-unavailable', + reason: 'manual-install-required', version: '1.4.200' } -function renderAfterAvailableStatus(): RenderResult { +function renderWithInitialStatus(updateStatus: UpdateStatus): RenderResult { useAppStore.setState({ - updateStatus: { - state: 'available', - version: '1.4.200', - changelog: null - }, + updateStatus, updateChangelog: null, dismissedUpdateVersion: null, updateCardCollapsed: false, @@ -38,6 +34,10 @@ function renderAfterAvailableStatus(): RenderResult { return render() } +function renderAfterAvailableStatus(): RenderResult { + return renderWithInitialStatus({ state: 'available', version: '1.4.200', changelog: null }) +} + function mockReducedMotion(matches: boolean): void { Object.defineProperty(window, 'matchMedia', { configurable: true, @@ -213,23 +213,58 @@ function showPackageRecovery(recovery = PACKAGE_RECOVERY): void { act(() => useAppStore.getState().setUpdateStatus({ state: 'error', - message: 'pkexec: no polkit authentication agent found', + message: 'Quit Orca before running the system package install command.', recovery }) ) } describe('UpdateCard Linux package-install recovery', () => { - it('routes package-install errors to the recovery card instead of the generic one', () => { + it('routes root-package downloads to the manual-install card instead of the generic one', () => { renderAfterAvailableStatus() showPackageRecovery() - expect(screen.getByText('Automatic Install Failed')).toBeTruthy() + expect(screen.getByText('Manual Install Required')).toBeTruthy() expect(screen.queryByText('Update Error')).toBeNull() expect(screen.queryByRole('button', { name: 'Retry Download' })).toBeNull() expect(screen.getByRole('button', { name: 'Copy Install Command' })).toBeTruthy() - expect(screen.getByRole('button', { name: 'Try Automatic Install Again' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Show Package' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Download Manually' })).toBeTruthy() + expect(quitAndInstall).not.toHaveBeenCalled() + }) + + it('renders an initial recovery snapshot with its versioned release fallback', () => { + renderWithInitialStatus({ + state: 'error', + message: 'Quit Orca before running the system package install command.', + recovery: PACKAGE_RECOVERY + }) + + expect(screen.getByText('Manual Install Required')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Download Manually' })) + expect(openUrl).toHaveBeenCalledWith('https://github.com/stablyai/orca/releases/tag/v1.4.200') + }) + + it('uses the recovery version when cached update state is stale', () => { + renderWithInitialStatus({ state: 'available', version: '1.4.199', changelog: null }) + showPackageRecovery() + + fireEvent.click(screen.getByRole('button', { name: 'Download Manually' })) + expect(openUrl).toHaveBeenCalledWith('https://github.com/stablyai/orca/releases/tag/v1.4.200') + }) + + it.each([ + 'authentication-agent-unavailable', + 'authentication-denied', + 'package-install-failed' + ] as const)('keeps recovery usable for the legacy %s reason', (reason) => { + renderAfterAvailableStatus() + + showPackageRecovery({ ...PACKAGE_RECOVERY, reason }) + + expect(screen.getByText('Manual Install Required')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Copy Install Command' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Show Package' })).toBeTruthy() }) @@ -262,13 +297,52 @@ describe('UpdateCard Linux package-install recovery', () => { expect(openUrl).toHaveBeenCalledWith('https://github.com/stablyai/orca/releases/tag/v1.4.200') }) + it('resets command discovery when a newer package cycle replaces the recovery', async () => { + getInstructions.mockResolvedValueOnce({ + ok: false, + reason: 'no-package-manager', + message: 'No supported package manager was found.' + }) + renderAfterAvailableStatus() + showPackageRecovery() + + fireEvent.click(screen.getByRole('button', { name: 'Copy Install Command' })) + await flushActions() + expect(screen.queryByRole('button', { name: 'Copy Install Command' })).toBeNull() + + showPackageRecovery({ ...PACKAGE_RECOVERY, version: '1.4.201' }) + + fireEvent.click(screen.getByRole('button', { name: 'Copy Install Command' })) + await flushActions() + expect(getInstructions).toHaveBeenCalledTimes(2) + expect(writeClipboardText).toHaveBeenCalledTimes(1) + }) + + it('links unusable package metadata to the release without offering a futile retry', () => { + const message = + 'The downloaded package metadata could not be verified. Quit Orca before downloading and installing the update from the official release page.' + renderWithInitialStatus({ + state: 'error', + message, + version: '1.4.200', + retryable: false + }) + + expect(screen.getByText('Update Error')).toBeTruthy() + expect(screen.getByText(message)).toBeTruthy() + expect(screen.queryByText('Manual Install Required')).toBeNull() + expect(screen.queryByRole('button', { name: 'Retry Download' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Download Manually' })) + expect(openUrl).toHaveBeenCalledWith('https://github.com/stablyai/orca/releases/tag/v1.4.200') + }) + it('keeps generic errors on the generic card when no recovery is attached', () => { renderAfterAvailableStatus() act(() => useAppStore.getState().setUpdateStatus({ state: 'error', message: 'ENOSPC' })) expect(screen.getByText('Update Error')).toBeTruthy() - expect(screen.queryByText('Automatic Install Failed')).toBeNull() + expect(screen.queryByText('Manual Install Required')).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Retry Download' })) expect(download).toHaveBeenCalledTimes(1) }) @@ -295,7 +369,7 @@ describe('UpdateCard Linux package-install recovery', () => { ) expect(screen.getByText('HTTP/2 Download Blocked')).toBeTruthy() - expect(screen.queryByText('Automatic Install Failed')).toBeNull() + expect(screen.queryByText('Manual Install Required')).toBeNull() expect(screen.getByRole('button', { name: 'Enable & Restart' })).toBeTruthy() }) @@ -360,7 +434,7 @@ describe('UpdateCard recovery keyboard and motion', () => { fireEvent.keyDown(screen.getByRole('complementary'), { key: 'Escape' }) expect(useAppStore.getState().updateCardCollapsed).toBe(true) - expect(screen.queryByText('Automatic Install Failed')).toBeNull() + expect(screen.queryByText('Manual Install Required')).toBeNull() }) it('plays the exit animation before minimizing when motion is allowed', () => { diff --git a/src/renderer/src/components/UpdateCard.test.ts b/src/renderer/src/components/UpdateCard.test.ts index a12146e9754..e3143835b6e 100644 --- a/src/renderer/src/components/UpdateCard.test.ts +++ b/src/renderer/src/components/UpdateCard.test.ts @@ -503,6 +503,37 @@ describe('UpdateCard visibility gates', () => { ).toBe('visible') }) + it('shows an initial package recovery before any version was cached', () => { + expect( + computeVisibility({ + status: { + state: 'error', + message: 'Quit Orca before running the system package install command.', + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'manual-install-required', + version: '1.2.0' + } + }, + dismissedVersion: null, + cachedVersion: null, + hasStartedDownload: false + }) + ).toBe('visible') + }) + + it('shows an initial versioned download error before any version was cached', () => { + expect( + computeVisibility({ + status: { state: 'error', message: 'invalid metadata', version: '1.2.0' }, + dismissedVersion: null, + cachedVersion: null, + hasStartedDownload: false + }) + ).toBe('visible') + }) + it('shows downloaded for card-initiated downloads', () => { expect( computeVisibility({ diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index 4bbf47fd492..bcee9e0f521 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -34,7 +34,6 @@ export function UpdateCard(): React.JSX.Element | null { const [installError, setInstallError] = useState(null) const [compatibilityRelaunching, setCompatibilityRelaunching] = useState(false) const [compatibilitySetupError, setCompatibilitySetupError] = useState(null) - const [errorDismissed, setErrorDismissed] = useState(false) const [autoDismissed, setAutoDismissed] = useState(false) const [exiting, setExiting] = useState(false) const isLocalBuild = status.source === 'local' @@ -65,9 +64,6 @@ export function UpdateCard(): React.JSX.Element | null { if (exiting) { setExiting(false) } - if (errorDismissed) { - setErrorDismissed(false) - } } const shouldAutoDismissLatest = @@ -115,7 +111,6 @@ export function UpdateCard(): React.JSX.Element | null { hasStartedDownload: hasStartedDownload.current, updateUserInitiatedCycle, autoDismissed, - errorDismissed, collapsed }) ) { @@ -130,13 +125,6 @@ export function UpdateCard(): React.JSX.Element | null { void window.api.updater.download() } const handleClose = (): void => { - if (status.state === 'error') { - setErrorDismissed(true) - if (cachedVersion) { - dismissUpdate(cachedVersion) - } - return - } dismissUpdate() } const handleInstallRetry = (): void => { @@ -177,7 +165,6 @@ export function UpdateCard(): React.JSX.Element | null { status.state === 'error' && status.recovery?.kind === 'linux-package-install' ? { recovery: status.recovery, diagnostic: status.message } : null - const handleDismissWithAnimation = (): void => { if (prefersReducedMotion) { handleClose() @@ -230,7 +217,6 @@ export function UpdateCard(): React.JSX.Element | null { errorCard={errorCard} linuxPackageRecovery={linuxPackageRecovery} isLocalBuild={isLocalBuild} - cachedVersion={cachedVersion} hasStartedDownload={hasStartedDownload.current} prefersReducedMotion={prefersReducedMotion} mediaFailed={mediaFailed} @@ -250,7 +236,9 @@ export function UpdateCard(): React.JSX.Element | null { ? 'animate-update-card-exit' : 'animate-update-card-enter' const showReassurance = - !reassuranceSeen && (status.state === 'available' || status.state === 'downloading') + !reassuranceSeen && + ((status.state === 'available' && !status.externallyManaged) || + status.state === 'downloading') return (

- {translate('auto.components.UpdateCard.3553a8672f', 'Last error')} + {translate('auto.components.UpdateCard.3553a8672f', 'Details')}

{detail} diff --git a/src/renderer/src/components/maintenance/update-card/UpdateAvailableCardContent.tsx b/src/renderer/src/components/maintenance/update-card/UpdateAvailableCardContent.tsx index b18c18537d1..ce45e1c69e5 100644 --- a/src/renderer/src/components/maintenance/update-card/UpdateAvailableCardContent.tsx +++ b/src/renderer/src/components/maintenance/update-card/UpdateAvailableCardContent.tsx @@ -7,6 +7,18 @@ function isAnimatedGif(url: string | undefined): boolean { return typeof url === 'string' && url.toLowerCase().endsWith('.gif') } +/** A package manager owns this install: the release is real but Orca can never apply it here. */ +function ExternallyManagedNote(): React.JSX.Element { + return ( +

+ {translate( + 'auto.components.UpdateCard.7f1a4c9e02', + 'Your system package manager installed Orca, so update it from there — Orca cannot install this release itself.' + )} +

+ ) +} + export function UpdateAvailableRichContent({ release, releasesBehind, @@ -16,7 +28,8 @@ export function UpdateAvailableRichContent({ onMediaError, onMediaLoad, onUpdate, - onClose + onClose, + externallyManaged = false }: { release: NonNullable releasesBehind: number | null @@ -27,6 +40,7 @@ export function UpdateAvailableRichContent({ onMediaLoad: () => void onUpdate: () => void onClose: () => void + externallyManaged?: boolean }): React.JSX.Element { const showMedia = release.mediaUrl && !mediaFailed && !(prefersReducedMotion && isAnimatedGif(release.mediaUrl)) @@ -87,9 +101,13 @@ export function UpdateAvailableRichContent({ > {translate('auto.components.UpdateCard.aad383aecc', 'Read the full release notes')} - + {externallyManaged ? ( + + ) : ( + + )}
) } @@ -98,12 +116,14 @@ export function UpdateAvailableSimpleContent({ version, releaseUrl, onUpdate, - onClose + onClose, + externallyManaged = false }: { version: string releaseUrl?: string onUpdate: () => void onClose: () => void + externallyManaged?: boolean }): React.JSX.Element { return (
@@ -126,9 +146,13 @@ export function UpdateAvailableSimpleContent({ value0: version })}

-

- {translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")} -

+ {externallyManaged ? ( + + ) : ( +

+ {translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")} +

+ )} {releaseUrl && ( + {!externallyManaged && ( + + )}
) } diff --git a/src/renderer/src/components/maintenance/update-card/UpdateCardStateContent.tsx b/src/renderer/src/components/maintenance/update-card/UpdateCardStateContent.tsx index 3a501f574af..8a67a762a50 100644 --- a/src/renderer/src/components/maintenance/update-card/UpdateCardStateContent.tsx +++ b/src/renderer/src/components/maintenance/update-card/UpdateCardStateContent.tsx @@ -20,7 +20,6 @@ export function UpdateCardStateContent({ errorCard, linuxPackageRecovery, isLocalBuild, - cachedVersion, hasStartedDownload, prefersReducedMotion, mediaFailed, @@ -40,7 +39,6 @@ export function UpdateCardStateContent({ diagnostic: string } | null isLocalBuild: boolean - cachedVersion: string | null hasStartedDownload: boolean prefersReducedMotion: boolean mediaFailed: boolean @@ -71,9 +69,14 @@ export function UpdateCardStateContent({ if (linuxPackageRecovery) { return ( ) @@ -129,6 +132,7 @@ export function UpdateCardStateContent({ onMediaLoad={onMediaLoad} onUpdate={onUpdate} onClose={onDismiss} + externallyManaged={status.externallyManaged} /> ) : ( ) } diff --git a/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts b/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts index 37f34f30b6a..1a6ab8672a0 100644 --- a/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts +++ b/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts @@ -117,17 +117,25 @@ export function buildUpdateCardErrorModel({ } return { title: cachedVersion ? 'Update Error' : 'Update Check Failed', - summary: cachedVersion ? 'Could not complete the update.' : 'Could not check for updates.', + summary: + cachedVersion && status.retryable === false + ? status.message + : cachedVersion + ? 'Could not complete the update.' + : 'Could not check for updates.', detail: status.message, releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), - primaryAction: cachedVersion - ? { - label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), - onClick: onRetryDownload - } - : { - label: translate('auto.components.UpdateCard.6b0085010d', 'Re-check'), - onClick: onRecheck - } + primaryAction: + cachedVersion && status.retryable !== false + ? { + label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), + onClick: onRetryDownload + } + : !cachedVersion + ? { + label: translate('auto.components.UpdateCard.6b0085010d', 'Re-check'), + onClick: onRecheck + } + : undefined } } diff --git a/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts b/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts index 038bf1542c6..f18629a4fbe 100644 --- a/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts +++ b/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts @@ -7,7 +7,6 @@ export function isUpdateCardVisible({ hasStartedDownload, updateUserInitiatedCycle, autoDismissed = false, - errorDismissed = false, collapsed = false }: { status: UpdateStatus @@ -16,12 +15,15 @@ export function isUpdateCardVisible({ hasStartedDownload: boolean updateUserInitiatedCycle: boolean autoDismissed?: boolean - errorDismissed?: boolean collapsed?: boolean }): boolean { const isUserInitiated = 'userInitiated' in status && Boolean(status.userInitiated) const shouldShowDetailedErrorCard = - status.state === 'error' && (hasStartedDownload || cachedVersion !== null) + status.state === 'error' && + (hasStartedDownload || + cachedVersion !== null || + status.version !== undefined || + status.recovery?.kind === 'linux-package-install') if (status.state === 'checking' && !isUserInitiated) { return false @@ -35,10 +37,6 @@ export function isUpdateCardVisible({ if (status.state === 'error' && !shouldShowDetailedErrorCard && !isUserInitiated) { return false } - if (status.state === 'error' && errorDismissed) { - return false - } - if (cachedVersion && dismissedVersion === cachedVersion && !updateUserInitiatedCycle) { if (status.state !== 'downloading' && status.state !== 'error') { return false diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx new file mode 100644 index 00000000000..f569c9f5114 --- /dev/null +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '../../store' +import { GeneralUpdateSettingsSection } from './GeneralUpdateSettingsSection' + +vi.mock('./GeneralRemoteServerUpdates', () => ({ GeneralRemoteServerUpdates: () => null })) +vi.mock('./ReleaseChannelSection', () => ({ ReleaseChannelSection: () => null })) + +beforeEach(() => { + useAppStore.setState({ + updateStatus: { state: 'available', version: '1.4.200', changelog: null } + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + updater: { + check: vi.fn(), + download: vi.fn(), + getVersion: vi.fn().mockResolvedValue('1.4.199') + } + } + }) +}) + +afterEach(() => { + cleanup() + useAppStore.setState({ updateStatus: { state: 'idle' } }) +}) + +it('describes the available action as a download', () => { + render() + + expect(screen.getByRole('button', { name: 'Download Update (1.4.200)' })).toBeTruthy() + expect(screen.getByText(/is available\. Click "Download Update" to download it\./)).toBeTruthy() + expect(screen.queryByText(/download and install it/)).toBeNull() +}) diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index 15677927ad3..1e59c200fca 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -14,19 +14,9 @@ import { getReleaseNotesUrlForVersion } from '../../../../shared/release-channel export function GeneralUpdateSettingsSection(): React.JSX.Element { const updateStatus = useAppStore((s) => s.updateStatus) - // Why: the 'error' variant of UpdateStatus does not carry a `version` field. - // The main process emits `{ state: 'error' }` for both check failures (no - // version known yet) and download/install failures (version was known from - // the preceding 'available'/'downloading'/'downloaded' state). Cache the - // last-known version so the error copy below can distinguish the two cases - // without adding IPC. Mirrors `versionRef` in UpdateCard.tsx. + // Why: older hosts omit `version` from errors, so retain the last target for correct copy. const updateVersionRef = useRef(null) - if ( - (updateStatus.state === 'available' || - updateStatus.state === 'downloading' || - updateStatus.state === 'downloaded') && - updateStatus.version - ) { + if ('version' in updateStatus && updateStatus.version) { updateVersionRef.current = updateStatus.version } else if ( updateStatus.state === 'checking' || @@ -122,7 +112,7 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { )} - {updateStatus.state === 'available' ? ( + {updateStatus.state === 'available' && !updateStatus.externallyManaged ? ( @@ -178,10 +168,15 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { 'Version' )}{' '} {updateStatus.version}{' '} - {translate( - 'auto.components.settings.GeneralUpdateSettingsSection.8311da27ba', - 'is available. Click "Install Update" to download and install it.' - )}{' '} + {updateStatus.externallyManaged + ? translate( + 'auto.components.settings.GeneralUpdateSettingsSection.e3b9d21c07', + 'is available. Update Orca through your system package manager — Orca cannot install this release itself.' + ) + : translate( + 'auto.components.settings.GeneralUpdateSettingsSection.8311da27ba', + 'is available. Click "Download Update" to download it.' + )}{' '} {updateStatus.source !== 'local' && (
)} {updateStatus.state === 'error' && - // Why: `{ state: 'error' }` is emitted for both check-time - // failures (no version cached) and download/install failures - // (version cached from a prior 'available'/'downloading'/ - // 'downloaded' state). Label accordingly so a download failure - // isn't mislabeled as a "check" failure. Mirrors UpdateCard.tsx. - (updateVersionRef.current - ? translate( - 'auto.components.settings.GeneralUpdateSettingsSection.b9ad70c30d', - 'Update error. {{value0}}', - { value0: updateStatus.message } - ) - : translate( - 'auto.components.settings.GeneralUpdateSettingsSection.bd79d412f0', - 'Update check failed. {{value0}}', - { value0: updateStatus.message } - ))} + (updateStatus.recovery?.kind === 'linux-package-install' + ? updateStatus.message + : updateVersionRef.current + ? translate( + 'auto.components.settings.GeneralUpdateSettingsSection.b9ad70c30d', + 'Update error. {{value0}}', + { value0: updateStatus.message } + ) + : translate( + 'auto.components.settings.GeneralUpdateSettingsSection.bd79d412f0', + 'Update check failed. {{value0}}', + { value0: updateStatus.message } + ))}

{channelSwitcherRevealed ? : null} diff --git a/src/renderer/src/components/settings/RemoteServerUpdateStatus.tsx b/src/renderer/src/components/settings/RemoteServerUpdateStatus.tsx index 40dc49dcc5f..5d02889fb7f 100644 --- a/src/renderer/src/components/settings/RemoteServerUpdateStatus.tsx +++ b/src/renderer/src/components/settings/RemoteServerUpdateStatus.tsx @@ -96,7 +96,7 @@ export function getRemoteServerManualUpdateHelp(entry: RemoteServerUpdateEntry): if (entry.support?.reason === 'manual-service-update-required') { return translate( 'auto.components.settings.RemoteServerUpdateStatus.serviceManagerHelp', - 'Update Orca through the service manager that starts this server.' + 'Update Orca on the server host — through its system package manager if it was installed from a .deb or .rpm, otherwise through the service manager that starts it.' ) } if (entry.support?.reason === 'unpackaged-build') { diff --git a/src/renderer/src/components/status-bar/UpdateStatusSegment.tsx b/src/renderer/src/components/status-bar/UpdateStatusSegment.tsx index af41d79ea48..0da77795798 100644 --- a/src/renderer/src/components/status-bar/UpdateStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/UpdateStatusSegment.tsx @@ -21,6 +21,10 @@ export function UpdateStatusSegment({ return null } + const linuxPackageRecovery = + status.state === 'error' && status.recovery?.kind === 'linux-package-install' + ? status.recovery + : null const segment = (() => { if (status.state === 'downloading') { const pct = Math.max(0, Math.min(100, Math.round(status.percent))) @@ -39,7 +43,9 @@ export function UpdateStatusSegment({ ) } } - if (status.state === 'downloaded') { + const readyVersion = + status.state === 'downloaded' ? status.version : linuxPackageRecovery?.version + if (readyVersion !== undefined) { return { icon: , label: translate( @@ -49,7 +55,7 @@ export function UpdateStatusSegment({ tooltip: translate( 'auto.components.status.bar.UpdateStatusSegment.9d13213a56', 'Orca v{{value0}} ready to install', - { value0: status.version } + { value0: readyVersion } ), ariaLabel: translate( 'auto.components.status.bar.UpdateStatusSegment.962404f68e', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a641c2cf7dd..35d8dc50d42 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2264,7 +2264,7 @@ "8acbdd3961": "Minimize to status bar", "17412483da": "Ready to Install", "47126bcf57": "Download Manually", - "3553a8672f": "Last error", + "3553a8672f": "Details", "90559b14e3": "This turns on a process-wide Electron networking switch after restart. Use it for corporate VPNs or proxies that reject HTTP/2 update downloads.", "6e45bfa2e0": "Downloading...", "558842597d": "Downloading Update", @@ -2303,7 +2303,8 @@ "a4650b0dc4": "Could Not Use Local Build", "b1e390250d": "Could not complete the local build switch.", "d29740d175": "The selected build could not be used.", - "37d45c9ec1": "Choose Another Build" + "37d45c9ec1": "Choose Another Build", + "7f1a4c9e02": "Your system package manager installed Orca, so update it from there — Orca cannot install this release itself." }, "WorktreeJumpPalette": { "ac037cfac2": "Move", @@ -7055,9 +7056,9 @@ "8a52ca1d02": "Release notes", "d89806cc89": "is ready to install.", "a6b37929dc": "Version", - "8311da27ba": "is available. Click \"Install Update\" to download and install it.", + "8311da27ba": "is available. Click \"Download Update\" to download it.", "f44299636f": "Restart to Update (", - "42717918f4": "Install Update (", + "42717918f4": "Download Update (", "02dc082e70": "Could not start the update download.", "e1a647adc5": "Check for Updates", "ceb579abaf": "Check for app updates and install a newer Orca version.", @@ -7075,7 +7076,8 @@ "31fd7150cf": "Checking for updates...", "3394d1f663": "checking", "d69a09b672": "Updates are checked automatically on launch.", - "7173352632": "idle" + "7173352632": "idle", + "e3b9d21c07": "is available. Update Orca through your system package manager — Orca cannot install this release itself." }, "GeneralWorkspaceSettingsSection": { "3d538a98f7": "Choose apps available from a workspace's Open in menu.", @@ -10963,7 +10965,7 @@ "restarting": "Restarting…", "updated": "Updated", "failed": "Update failed", - "serviceManagerHelp": "Update Orca through the service manager that starts this server.", + "serviceManagerHelp": "Update Orca on the server host — through its system package manager if it was installed from a .deb or .rpm, otherwise through the service manager that starts it.", "unpackedHelp": "Development builds must be updated from their source checkout.", "legacyHelp": "Update this server manually once to enable remote updates." }, @@ -16342,14 +16344,13 @@ }, "LinuxPackageInstallRecoveryCard": { "e3de29c86a": "Show Package", - "3da99454c6": "Try Automatic Install Again", "55c86654b7": "Copy Install Command", - "53e1559f99": "Automatic Install Failed", - "a7ac6ec78b": "Orca downloaded the update but could not install the system package automatically.", - "82c6dbea00": "Copy the command and run it in a system terminal on the computer where Orca is installed. After it finishes, quit and reopen Orca to run the new version.", + "53e1559f99": "Manual Install Required", + "a7ac6ec78b": "Orca downloaded the system package. Quit Orca before finishing the update from a terminal.", + "82c6dbea00": "Copy the command, quit Orca, and run it in a system terminal on the computer where Orca is installed. Reopen Orca after it finishes.", "53c4b8e148": "No usable authentication agent answered the privileged install request.", "c732bcbf8f": "Checking package...", - "aa57fa4f80": "Command copied. Run it in a system terminal to install {{value0}}, then quit and reopen Orca.", + "aa57fa4f80": "Command copied. Quit Orca, run it in a system terminal to install {{value0}}, then reopen Orca.", "b7e7c5bc95": "Orca checks the downloaded file against the release metadata at the moment it builds this command. The system package itself is not signature-checked, and Orca cannot vouch for the file after that point." }, "pr-check-counts": { diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index 213b9ebdd96..f5fae013b7e 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -150,10 +150,8 @@ src/main/ssh/system-ssh-dynamic-forward-process.ts src/main/ssh/system-ssh-file-transfer.ts src/main/ssh/system-ssh-forward-process.ts src/main/ssh/system-ssh-operation-lifecycle.ts -src/main/startup/appimage-cli-redirect.ts src/main/startup/ensure-virtual-display.ts src/main/startup/hydrate-shell-path.ts -src/main/startup/packaged-cli-entry-redirect.ts src/main/startup/windows-install-dir-acl-probe.ts src/main/startup/windows-user-data-acl.ts src/main/win32-utils.ts 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 752a762370a..a8878a182d0 100644 --- a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt +++ b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt @@ -43,9 +43,7 @@ main/pty/windows-environment-path.ts main/rate-limits/codex-fetcher.ts main/runtime/tls-certificate.ts main/ssh/ssh-connection.ts -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/win32-utils.ts main/window/clipboard-ipc-handlers.ts diff --git a/src/shared/child-process/child-process-import-boundary.test.ts b/src/shared/child-process/child-process-import-boundary.test.ts index 10ca4fd8522..32c6c9d890e 100644 --- a/src/shared/child-process/child-process-import-boundary.test.ts +++ b/src/shared/child-process/child-process-import-boundary.test.ts @@ -29,7 +29,7 @@ const CHILD_PROCESS_IMPORT_ALLOWLIST: readonly string[] = readFileSync( * May only ever be DECREASED, and only by migrating a file off * `node:child_process`. Raising it is never the fix. */ -const DIRECT_IMPORTER_PIN = 160 +const DIRECT_IMPORTER_PIN = 158 const IMPORT_PATTERN = /(?:from\s+['"]node:child_process['"]|from\s+['"]child_process['"]|require\(\s*['"]node:child_process['"]|require\(\s*['"]child_process['"])/ diff --git a/src/shared/child-process/windows-console-visibility.test.ts b/src/shared/child-process/windows-console-visibility.test.ts index 7985b0ee11a..596728fa245 100644 --- a/src/shared/child-process/windows-console-visibility.test.ts +++ b/src/shared/child-process/windows-console-visibility.test.ts @@ -34,7 +34,7 @@ const ALLOWLIST: readonly string[] = readAllowlist( * the allowlist does not bound this: a swap (one file fixed and delisted, one * new file added with its entry) satisfies both membership assertions. */ -const UNHIDDEN_SPAWNER_PIN = 68 +const UNHIDDEN_SPAWNER_PIN = 66 const CHILD_PROCESS_IMPORT = /from\s+['"](?:node:)?child_process['"]|require\(\s*['"](?:node:)?child_process['"]/ diff --git a/src/shared/cli-argument-boundary.test.ts b/src/shared/cli-argument-boundary.test.ts new file mode 100644 index 00000000000..ceb2e64432f --- /dev/null +++ b/src/shared/cli-argument-boundary.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { CLI_GLOBAL_VALUE_FLAGS, findCliCommandIndex } from './cli-argument-boundary' + +const COMMAND_PATHS = [['project'], ['serve'], ['status'], ['worktree']] as const + +describe('findCliCommandIndex', () => { + it.each([ + { argv: ['--json', 'status'], expected: 1, name: 'global boolean' }, + { argv: ['--environment', 'status'], expected: 1, name: 'missing global value' }, + { + argv: ['--environment', 'status', 'worktree', 'list'], + expected: 2, + name: 'command-named value' + }, + { + argv: ['--project', 'github:stablyai/orca', 'project', 'setups'], + expected: 2, + name: 'selector value' + }, + { argv: ['--project=github:stablyai/orca', 'project'], expected: 1, name: 'assignment' }, + { argv: ['--', 'status'], expected: 1, name: 'bare double dash' }, + { argv: ['workspace', 'status'], expected: -1, name: 'first non-command positional' }, + { argv: ['serve'], expected: 0, name: 'direct serve' } + ])('$name', ({ argv, expected }) => { + expect(findCliCommandIndex(argv, COMMAND_PATHS)).toBe(expected) + }) + + it('consumes known global values at the launch boundary', () => { + expect( + findCliCommandIndex(['--environment', 'status'], COMMAND_PATHS, CLI_GLOBAL_VALUE_FLAGS) + ).toBe(-1) + }) +}) diff --git a/src/shared/cli-argument-boundary.ts b/src/shared/cli-argument-boundary.ts new file mode 100644 index 00000000000..7b29db49c45 --- /dev/null +++ b/src/shared/cli-argument-boundary.ts @@ -0,0 +1,96 @@ +export const CLI_GLOBAL_VALUE_FLAGS: readonly string[] = ['pairing-code', 'environment'] +export const CLI_GLOBAL_FLAGS: readonly string[] = ['help', 'json', ...CLI_GLOBAL_VALUE_FLAGS] + +export const CLI_BOOLEAN_FLAGS = new Set([ + 'all', + 'attachments', + 'children', + 'comments', + 'connect', + 'current', + 'dry-run', + 'enter', + 'focus', + 'force', + 'full', + 'help', + 'inject', + 'include-archived', + 'include-visual-layouts', + 'interrupt', + 'json', + 'local', + 'messages', + 'me', + 'mobile', + 'mobile-pairing', + 'no-pairing', + 'screen', + 'parent-current', + 'provision', + 'ready', + 'recipe-json', + 'relations', + 'reinstall', + 'restore-window', + 'return-preamble', + 'run-hooks', + 'show-profile', + 'staged', + 'tab', + 'tasks', + 'text-stdin', + 'unread', + 'value-stdin', + 'wait' +]) + +function commandPathStartsAt( + argv: readonly string[], + tokenIndex: number, + path: readonly string[] +): boolean { + let cursor = tokenIndex + for (const part of path) { + while (argv[cursor]?.startsWith('--')) { + const assignment = argv[cursor].slice(2) + const flag = assignment.split('=', 1)[0] + cursor += assignment.includes('=') || CLI_BOOLEAN_FLAGS.has(flag) ? 1 : 2 + } + if (argv[cursor] !== part) { + return false + } + cursor += 1 + } + return true +} + +export function findCliCommandIndex( + argv: readonly string[], + commandPaths: readonly (readonly string[])[], + knownValueFlags: readonly string[] = [] +): number { + const startsCommandAt = (index: number): boolean => + commandPaths.some((path) => commandPathStartsAt(argv, index, path)) + + for (let index = 0; index < argv.length;) { + const token = argv[index] + if (!token.startsWith('--')) { + return startsCommandAt(index) ? index : -1 + } + + const assignment = token.slice(2) + const flag = assignment.split('=', 1)[0] + const next = argv[index + 1] + const takesNext = + !assignment.includes('=') && + !CLI_BOOLEAN_FLAGS.has(flag) && + next !== undefined && + !next.startsWith('--') && + (knownValueFlags.includes(flag) || + !(startsCommandAt(index + 1) && !startsCommandAt(index + 2))) + + index += takesNext ? 2 : 1 + } + return -1 +} diff --git a/src/shared/edit-distance.ts b/src/shared/edit-distance.ts new file mode 100644 index 00000000000..7a496712b89 --- /dev/null +++ b/src/shared/edit-distance.ts @@ -0,0 +1,23 @@ +export function levenshtein(a: string, b: string): number { + const m = a.length + const n = b.length + if (m === 0) { + return n + } + if (n === 0) { + return m + } + let previous = Array.from({ length: n + 1 }, (_, index) => index) + let current = Array.from({ length: n + 1 }, () => 0) + for (let i = 1; i <= m; i += 1) { + current[0] = i + for (let j = 1; j <= n; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost) + } + const swap = previous + previous = current + current = swap + } + return previous[n] +} diff --git a/src/shared/serve-option-validation.test.ts b/src/shared/serve-option-validation.test.ts new file mode 100644 index 00000000000..70473c57477 --- /dev/null +++ b/src/shared/serve-option-validation.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { getServeFlagTypoError, getServeOptionValidationError } from './serve-option-validation' + +const validOptions = { + noPairing: false, + mobilePairing: false, + recipeJson: false, + projectRoot: null +} + +describe('getServeOptionValidationError', () => { + it('accepts compatible options', () => { + expect(getServeOptionValidationError(validOptions)).toBeNull() + }) + + it.each([ + [{ noPairing: true, mobilePairing: true }, /either --mobile-pairing or --no-pairing/i], + [ + { recipeJson: true, noPairing: true, projectRoot: '/tmp/repo' }, + /requires runtime pairing.*--no-pairing/i + ], + [ + { recipeJson: true, mobilePairing: true, projectRoot: '/tmp/repo' }, + /requires runtime pairing.*--mobile-pairing/i + ], + [{ recipeJson: true }, /requires --project-root/i] + ])('rejects incompatible options', (override, expected) => { + expect( + getServeOptionValidationError({ ...validOptions, ...override } as typeof validOptions) + ).toMatch(expected) + }) +}) + +describe('getServeFlagTypoError', () => { + it('accepts exact serve flags and arbitrary Chromium switches', () => { + expect( + getServeFlagTypoError([ + '/opt/orca/orca-ide', + '--serve', + '--serve-no-pairing', + '--disable-gpu', + '--disable-features=Vulkan', + '--no-parent' + ]) + ).toBeNull() + }) + + it.each(['--no-pair', '--no-pairng', '--no-paring', '--mobile-pairng'])( + 'suggests the intended pairing flag for %s', + (flag) => { + expect(getServeFlagTypoError(['/opt/orca/orca-ide', '--serve', flag])).toMatch( + /Unknown flag .*Did you mean --(?:no-pairing|mobile-pairing)\?/i + ) + } + ) + + it('does not reinterpret tokens after --', () => { + expect(getServeFlagTypoError(['/opt/orca/orca-ide', '--serve', '--', '--no-pairng'])).toBeNull() + }) + + it('does not inspect an equals-form value as a flag', () => { + expect( + getServeFlagTypoError(['/opt/orca/orca-ide', '--serve-pairing-address=--no-pairng']) + ).toBeNull() + }) + + it('keeps flag-shaped space values subject to typo validation', () => { + expect( + getServeFlagTypoError(['/opt/orca/orca-ide', '--serve-pairing-address', '--no-pairng']) + ).toMatch(/Unknown flag --no-pairng.*--no-pairing/i) + }) +}) diff --git a/src/shared/serve-option-validation.ts b/src/shared/serve-option-validation.ts new file mode 100644 index 00000000000..36f844f322f --- /dev/null +++ b/src/shared/serve-option-validation.ts @@ -0,0 +1,89 @@ +import { levenshtein } from './edit-distance' + +export type ServeOptionValidationInput = { + noPairing: boolean + mobilePairing: boolean + recipeJson: boolean + projectRoot: string | null | undefined +} + +export function getServeOptionValidationError(options: ServeOptionValidationInput): string | null { + if (options.noPairing && options.mobilePairing) { + return 'Use either --mobile-pairing or --no-pairing, not both.' + } + if (options.recipeJson && options.noPairing) { + return 'Recipe JSON output requires runtime pairing; remove --no-pairing.' + } + if (options.recipeJson && options.mobilePairing) { + return 'Recipe JSON output requires runtime pairing; remove --mobile-pairing.' + } + if (options.recipeJson && !options.projectRoot) { + return 'Recipe JSON output requires --project-root.' + } + return null +} + +const SERVE_SECURITY_FLAG_NAMES = [ + '--no-pairing', + '--serve-no-pairing', + '--mobile-pairing', + '--serve-mobile-pairing', + '--recipe-json', + '--serve-recipe-json', + '--pairing-address', + '--serve-pairing-address' +] as const + +const SERVE_VALUE_FLAG_NAMES = new Set([ + '--port', + '--serve-port', + '--pairing-address', + '--serve-pairing-address', + '--project-root', + '--serve-project-root', + '--pairing-code', + '--environment' +]) + +function flagName(token: string): string { + const equalsIndex = token.indexOf('=') + return equalsIndex === -1 ? token : token.slice(0, equalsIndex) +} + +/** Reject only near-miss pairing flags; Electron/Chromium switches stay open-ended. */ +export function getServeFlagTypoError(argv: readonly string[]): string | null { + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]! + if (token === '--') { + break + } + if (!token.startsWith('--')) { + continue + } + const name = flagName(token) + let suggestion: string | null = null + let bestDistance = Number.POSITIVE_INFINITY + for (const candidate of SERVE_SECURITY_FLAG_NAMES) { + const distance = levenshtein(name, candidate) + const maxDistance = candidate.startsWith(name) ? 3 : 2 + if (distance > 0 && distance <= maxDistance && distance < bestDistance) { + suggestion = candidate + bestDistance = distance + } + } + if (suggestion) { + return `Unknown flag ${name}. Did you mean ${suggestion}?` + } + + // A value that is not flag-shaped belongs to the preceding known value flag. + // A `--`-prefixed space token remains a flag, matching parseArgs; use `=` when + // a value itself starts with `--`. + if (!token.includes('=') && SERVE_VALUE_FLAG_NAMES.has(name)) { + const value = argv[index + 1] + if (value !== undefined && !value.startsWith('--')) { + index += 1 + } + } + } + return null +} diff --git a/src/shared/update-status-types.ts b/src/shared/update-status-types.ts index 8d54837c9c5..74ee382ec58 100644 --- a/src/shared/update-status-types.ts +++ b/src/shared/update-status-types.ts @@ -37,11 +37,15 @@ export type LinuxPackageInstallFailureReason = | 'authentication-denied' | 'package-install-failed' -// Why: the renderer must not infer "no polkit agent" from copy alone — main classifies and the card branches on this discriminant. +export type LinuxPackageInstallRecoveryReason = + | 'manual-install-required' + | LinuxPackageInstallFailureReason + +// Older paired hosts can still publish classified install failures; the manual reason is additive. export type LinuxPackageInstallRecovery = { kind: 'linux-package-install' packageType: LinuxRootPackageType - reason: LinuxPackageInstallFailureReason + reason: LinuxPackageInstallRecoveryReason version: string } @@ -70,6 +74,9 @@ export type UpdateStatus = ( // three-state ambiguity (undefined vs null vs present) and makes exhaustive // checks straightforward. changelog: ChangelogData | null + /** Linux only: a package manager owns this install, so Orca cannot apply the update itself. + * Additive and optional — older clients simply keep offering their own download. */ + externallyManaged?: boolean } | { state: 'not-available'; userInitiated?: boolean } | { state: 'downloading'; percent: number; version: string; activeNudgeId?: string } @@ -77,6 +84,10 @@ export type UpdateStatus = ( | { state: 'error' message: string + /** Known download/install target; absent for check-time failures and older hosts. */ + version?: string + /** Omitted by older hosts and for failures whose retryability is unknown. */ + retryable?: boolean userInitiated?: boolean activeNudgeId?: string recovery?: LinuxPackageInstallRecovery From e5a1e79e8e4af790d38337cd5b8dd2fc10adadc5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:49:40 -0700 Subject: [PATCH 93/94] docs(linux): say which package to install and how updates arrive (#18123) * docs(linux): say which package to install and how updates arrive Closes #5188. Closes #10987. The install guide's entire Linux section was "AppImage and `.deb` builds are available. See the Releases page for details." It named two of the three published packages, gave no basis for choosing between them, and said nothing about updating -- which is the one thing that actually differs between them. Separately, nothing human-facing said the Linux CLI is `orca-ide`; only skills/orca-cli/SKILL.md carried it, which agents read and humans do not. Install page now picks the package by update behaviour: the AppImage self-updates, deb/rpm report the new version and hand over the install command, and a repackaged build is not offered a download it cannot apply. Records that Orca never escalates privileges for the package install, and points at #18086 for the signed repo as planned, not shipped. Adds .rpm to the download list. Release CI builds it (release-cut.yml: `--linux AppImage deb rpm`) and verify-release-required-assets.mjs requires the artifact, so omitting it was just wrong. The CLI command name is now stated where humans hit it -- the CLI reference and overview -- with the GNOME Orca collision as the reason, plus the two places bare `orca` does work: inside Orca-managed terminals (PTY PATH shim) and on a packaged `orca serve` host (the ~/.local/bin dispatcher). The headless guide gains the same note, which is what makes its `orca skills install` lines correct rather than a typo. * docs(linux): fix install ordering, CLI verification, and serve bootstrap Readiness review found ten defects. Two would have had a reader run the wrong program, and one would have had them install a .deb over a live app. Install ordering was reversed. The page said "run it, then quit and reopen Orca"; the ref this is gated to land with says the opposite in four places (linux-package-downloaded-status.ts LINUX_PACKAGE_MANUAL_INSTALL_MESSAGE, "Quit Orca before running the system package install command", plus the recovery card's title, summary and explainer). That wording came from main's older run-then-quit card, which the stack deliberately reversed when it retitled the card to "Manual Install Required". Now: quit first. CLI verification put the Linux caveat *below* `command -v orca`. That check succeeds on any GNOME desktop and resolves to the screen reader, so the reader got a confident hit from the page's own verification step and then invoked the wrong program. Caveat moved above, and the block now spells `orca-ide` literally instead of asking the reader to substitute. The serve bootstrap was circular: the bare-`orca` dispatcher is written *during* serve startup (main-process-runtime-launch.ts), so it can never be the command that starts serve. First launch is `orca-ide serve`. Fixed here and in the two pages this links to. Accuracy: the install command now matches what the code emits -- absolute paths resolved from the trusted directories and a POSIX-single-quoted package path, as pinned by linux-package-install-command.test.ts -- and names the manager fallbacks (dpkg; zypper/dnf/yum/rpm) rather than presenting apt as the only form. The pending path honours XDG_CACHE_HOME. rpm arch tokens are x86_64 and aarch64, not deb's amd64/arm64. arm64 AppImage is linked. Dropped the container example: isExternallyManagedLinuxInstall() needs a root marker AND no trusted package manager, and a Debian-based container has apt, so it is not flagged. --- docs/reference/headless-linux-server.md | 15 ++++++ docs/site/content/docs/cli/overview.mdx | 2 +- docs/site/content/docs/cli/reference.mdx | 18 +++++++- docs/site/content/docs/install.mdx | 56 +++++++++++++++++++++-- docs/site/content/docs/remote-servers.mdx | 8 ++++ docs/site/content/docs/ways-to-run.mdx | 4 +- 6 files changed, 96 insertions(+), 7 deletions(-) diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index 7368678c2e4..50a38cf446e 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -357,6 +357,21 @@ the command: This disables a security boundary. Prefer a dedicated unprivileged service user, especially when the listener is reachable beyond localhost. +The Linux CLI is named `orca-ide`, not `orca`, so it never shadows the GNOME +Orca screen reader at `/usr/bin/orca`. The `.deb` and `.rpm` packages put +`orca-ide` on `PATH` themselves at install time; with the AppImage it arrives +as `~/.local/bin/orca-ide` when the CLI is registered. + +A packaged `orca serve` start also writes a bare `orca` into `~/.local/bin` +that execs the same launcher, which is why the skills commands below can be +typed as `orca`. It writes it while starting, so it is never the command that +starts the server — the first launch is `orca-ide serve`, or the AppImage +invoked directly as above. The write is best-effort: it is gated on a packaged +build, it is skipped when no bundled launcher resolves, and it is skipped when +a file Orca does not own already holds that name (ownership is a marker on the +second line of the file). A host that really does run the screen reader keeps +its own `orca`. + ## Pairing troubleshooting - A pairing offer is a capability containing a device credential and E2EE diff --git a/docs/site/content/docs/cli/overview.mdx b/docs/site/content/docs/cli/overview.mdx index 3248e89e1eb..1e966c6217c 100644 --- a/docs/site/content/docs/cli/overview.mdx +++ b/docs/site/content/docs/cli/overview.mdx @@ -14,7 +14,7 @@ import { Callout } from '@/components/docs/prose' The Orca CLI is the `orca` command-line interface for scripting a running Orca editor from any shell. Use it to create and inspect worktrees, drive agent terminals, open files and diffs, automate the built-in browser, run scheduled automations, share HTML/Markdown artifacts, and control Orca-native tools from scripts or AI agents. -It ships with the desktop app; register it under [Settings → General → Orca CLI](/docs/settings). +It ships with the desktop app; register it under [Settings → General → Orca CLI](/docs/settings). On Linux the command is `orca-ide`, because GNOME Orca's screen reader already owns `/usr/bin/orca` — see [Install → Linux](/docs/install#linux). Agents can install the matching Orca CLI skill with: diff --git a/docs/site/content/docs/cli/reference.mdx b/docs/site/content/docs/cli/reference.mdx index 3df0773a4a7..a13ec0fdde4 100644 --- a/docs/site/content/docs/cli/reference.mdx +++ b/docs/site/content/docs/cli/reference.mdx @@ -9,13 +9,29 @@ The `orca` CLI talks to a running Orca runtime. Use it when a shell script or ag ## Verify the runtime -Register the CLI under [Settings → General → Orca CLI](/docs/settings), then check that it can reach Orca: +Register the CLI under [Settings → General → Orca CLI](/docs/settings), then check that it can reach Orca. + + + GNOME Orca — the screen reader that ships with most GNOME desktops — already owns `/usr/bin/orca`, + so Orca's Linux CLI installs as `orca-ide`. Do not check for it with `command -v orca`: that + succeeds on a GNOME desktop and resolves to the screen reader, not to Orca. This page writes + `orca` throughout — read it as `orca-ide` on Linux. See [Install → Linux](/docs/install#linux). + + +On macOS and Windows: ```bash command -v orca orca status --json ``` +On Linux: + +```bash +command -v orca-ide +orca-ide status --json +``` + If Orca is not already running: ```bash diff --git a/docs/site/content/docs/install.mdx b/docs/site/content/docs/install.mdx index f710644d19e..9341cb0fa0d 100644 --- a/docs/site/content/docs/install.mdx +++ b/docs/site/content/docs/install.mdx @@ -31,8 +31,11 @@ import { Callout } from '@/components/docs/prose'
  • **Linux:** - [AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · - [.deb](https://github.com/stablyai/orca/releases) + AppImage + [x64](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · + [arm64](https://github.com/stablyai/orca/releases/latest/download/orca-linux-arm64.AppImage) · + [.deb](https://github.com/stablyai/orca/releases) · + [.rpm](https://github.com/stablyai/orca/releases) — see [Linux](#linux) for which to pick
  • Older versions: [GitHub Releases](https://github.com/stablyai/orca/releases).
  • @@ -59,6 +62,8 @@ On first launch Orca will: Orca auto-updates by default, tracking the **stable** channel. Stable releases are vetted; **RC (release candidate)** builds ship new features first, often daily. +On Linux, whether Orca can apply an update itself depends on which package you installed. See [Linux](#linux) before you pick one. + There is no permanent in-app opt-in for the RC channel. Modifier clicks on **Check for Updates** ([Settings → General → Updates](/docs/settings), or the app / Help menu): | Modifier | Effect | @@ -87,4 +92,49 @@ The default shell can be set to PowerShell or CMD under [Settings → Terminal]( ### Linux -AppImage and `.deb` builds are available. See the Releases page for details. +Each published release ships three Linux packages — an **AppImage**, a **`.deb`**, and an **`.rpm`** — for both x64 and arm64. They contain the same app. What differs is how updates reach you, so pick on that. + +| Package | Pick it when | Updates | +| ------------ | --------------------------------------------------------- | ----------------------------------------------------------------- | +| **AppImage** | You want Orca to update itself, like on macOS and Windows | Orca downloads and applies the update in place | +| **`.deb`** | You manage software with `apt` on Debian or Ubuntu | Orca tells you a version is out and hands you the install command | +| **`.rpm`** | You manage software with `dnf`, `yum`, or `zypper` | Same as `.deb` | + +The AppImage has a stable download link per architecture — [`orca-linux.AppImage`](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) for x64 and [`orca-linux-arm64.AppImage`](https://github.com/stablyai/orca/releases/latest/download/orca-linux-arm64.AppImage) for arm64 — and needs `chmod +x` before its first run, because GitHub release assets carry no permission bits. The `.deb` and `.rpm` filenames carry the version and architecture, and the two formats spell architecture differently (`orca-ide__amd64.deb` or `_arm64.deb`; `orca-ide-.x86_64.rpm` or `.aarch64.rpm`), so take those from the [Releases page](https://github.com/stablyai/orca/releases) rather than a fixed URL. + +#### How updating works + +**The AppImage self-updates.** Choose it if you want automatic updates. Orca checks for a new release, you click **Update**, and it replaces the AppImage in place — the same flow as macOS and Windows. + +**The `.deb` and `.rpm` do not self-update.** Orca still notices the new version and downloads the package, then gives you a **Copy Install Command** button. Copy it rather than retyping it: Orca resolves every program to an absolute path in a trusted system directory and single-quotes the package path, so what you paste looks like this: + +``` +/usr/bin/sudo /usr/bin/apt install -- '/home/you/.cache/orca-updater/pending/orca-ide_1.4.194_amd64.deb' +``` + +Which package manager appears depends on what your system actually has: `apt`, else `dpkg -i`, for a `.deb`; `zypper`, `dnf`, `yum`, then `rpm -Uvh` for an `.rpm`. The download directory follows `XDG_CACHE_HOME` when that is set and falls back to `~/.cache` when it is not. + +**Quit Orca before you run the command**, then reopen it once the install finishes. You are replacing the files of a running application, and the package manager cannot swap them safely underneath a live process. Orca deliberately never escalates privileges to do this for you: installing a system package needs root, `orca serve` runs as an unprivileged user, and a headless machine has no authentication agent to prompt. VS Code and Signal make the same call on `.deb`. + +**A distro-managed build is left alone.** If you are running a repackaged Orca — an AUR build, a Nix derivation — Orca sees that no package manager it can drive owns this install and stops offering a download it could never apply. It still reports that a new version exists, so you can update the way you normally would. + + + [#18086](https://github.com/stablyai/orca/issues/18086) tracks publishing a signed repository so + your OS package manager owns Orca updates the way it owns everything else. It does not exist yet — + today, `.deb` and `.rpm` updates are the manual step described above. + + +#### The CLI command is `orca-ide` + +On Linux the [Orca CLI](/docs/cli/reference) installs as **`orca-ide`**, not `orca`. GNOME Orca — the screen reader that ships by default on Ubuntu and other GNOME desktops — already owns `/usr/bin/orca`, and Orca will not shadow it. The `.deb` and `.rpm` packages are named `orca-ide` for the same reason. + +- The `.deb` and `.rpm` put `orca-ide` on your `PATH` at install time, as `/usr/bin/orca-ide`. +- With the AppImage, register the CLI from [Settings → General → Orca CLI](/docs/settings). That installs `~/.local/bin/orca-ide`. +- Inside Orca's own terminals, bare `orca` works. Orca puts a shim on the `PATH` of the terminals it manages, so agents and scripts running there use the same command as on macOS and Windows. +- On a headless host, a packaged `orca serve` writes a bare `orca` into `~/.local/bin` as it starts, unless a file it does not own already holds that name. It writes that *during* startup, so it is never what starts the server — the first launch is always [`orca-ide serve`](/docs/remote-servers). + +Do not verify with `command -v orca`: on a GNOME desktop that succeeds and resolves to the screen reader. Use `orca-ide` in your own shell and `orca` inside Orca. If you want the short name everywhere and you do not use the screen reader, link it yourself: + +``` +ln -s "$(command -v orca-ide)" ~/.local/bin/orca +``` diff --git a/docs/site/content/docs/remote-servers.mdx b/docs/site/content/docs/remote-servers.mdx index 63f94e35af8..37f86665d6e 100644 --- a/docs/site/content/docs/remote-servers.mdx +++ b/docs/site/content/docs/remote-servers.mdx @@ -126,6 +126,14 @@ Use `orca serve` when the host should run without the desktop window—for examp Install Orca and its bundled CLI on the server, then run: + + The Linux CLI is named `orca-ide`, because GNOME Orca's screen reader already owns + `/usr/bin/orca`. A packaged `orca serve` does write a bare `orca` into `~/.local/bin`, but only + while it is starting, so that shim can never be the command that starts the server. Read + `orca serve` as `orca-ide serve` throughout this page when the host is Linux. See + [Install → Linux](/docs/install#linux). + + ```bash orca serve --pairing-address ``` diff --git a/docs/site/content/docs/ways-to-run.mdx b/docs/site/content/docs/ways-to-run.mdx index 1a9c44ba1d1..e35b63eae90 100644 --- a/docs/site/content/docs/ways-to-run.mdx +++ b/docs/site/content/docs/ways-to-run.mdx @@ -52,10 +52,10 @@ Keep Orca running on a machine you control—an old laptop, Mac mini, home serve **Easiest setup:** install Orca and Tailscale on both computers. On the server, open **Settings → Remote Orca Servers → Advertise this app as a server → New Link**, choose its Tailscale address, and generate an access link. On the client, choose **Add Server** and paste that link. -For a headless Linux server or service-managed VM, use `orca serve` as the alternative: +For a headless Linux server or service-managed VM, use `orca serve` as the alternative. On Linux the CLI is named `orca-ide`, so the first launch is: ```bash -orca serve --pairing-address +orca-ide serve --pairing-address ``` Full detail: [Remote Orca Servers](/docs/remote-servers). From f737f3499f3f9194fc4984b110dd202e5d089856 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:36:54 -0700 Subject: [PATCH 94/94] fix(relay): stream an oversized fs.listFiles reply instead of refusing it (#17954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening Orca's own checkout over SSH cannot list its files in one response frame. 22,617 tracked paths average 58 characters, so the 20,001-row page the client asks for serializes to 1,223,415 bytes — past `DISPATCHER_CONTROL_QUEUE_MAX_BYTES`, so `sendResponse` demotes it to the `legacy-response` lane, where an unrelated producer backlog can refuse it as an opaque `ResponseOverCapacity`. Break-even is around 49 characters of average path; any `packages//src/...` monorepo is over the line. Picking a ceiling to refuse at does not fix that, it just moves where it shows up and refuses listings that would have been delivered. `__streamResponse` already exists for exactly this on the git methods, and it is its own negotiation in both directions: an old client never sends it and gets the plain array on the legacy-response lane as before, and an old relay ignores it and answers plainly, which the client detects by the sentinel marker being absent. So fs.listFiles opts into it — no new method, no new opcode, nothing to advertise — and the size of a listing stops being a correctness question. The response-stream registry becomes one per relay, shared by FsHandler and GitHandler. A second registry is not an option and the header of git-response-stream.ts says why: a client keys reassembly on `streamId` alone, so two would hand out the same id and cross-feed chunks, and only the handler that registers `git.responseAck` can credit the window a pump parks on. Also declares `maxResults` on the runtime-RPC `files.listAll` and forwards it. The mechanism "the client names its cap, so a full page reads as truncation" was wired only on the Electron IPC hop; web and mobile were saved incidentally by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`. A new optional field is additive in both directions (wire rule 1). The new Docker-gated spec is claimed by run-ssh-docker-e2e.mjs. The sharded e2e lanes set no ORCA_E2E_SSH_DOCKER, so a Docker-gated spec that no runner names self-skips everywhere and still reports green — pr-e2e-gate-contract enforces that. Closes #12547 --- config/scripts/run-ssh-docker-e2e.mjs | 1 + .../providers/ssh-filesystem-provider.test.ts | 44 +++--- src/main/providers/ssh-filesystem-provider.ts | 7 +- .../methods/files-list-all-page-size.test.ts | 53 +++++++ src/main/runtime/rpc/methods/files.ts | 8 +- src/relay/fs-handler.ts | 25 +++- ...t-files-large-response.integration.test.ts | 130 +++++++++++++++++ src/relay/git-handler.ts | 22 ++- src/relay/git-response-stream.ts | 47 +++++- src/relay/relay-runtime-services.ts | 9 +- tests/e2e/helpers/docker-ssh-relay-target.ts | 10 ++ ...sh-docker-quick-open-large-listing.spec.ts | 134 ++++++++++++++++++ 12 files changed, 441 insertions(+), 49 deletions(-) create mode 100644 src/main/runtime/rpc/methods/files-list-all-page-size.test.ts create mode 100644 src/relay/fs-list-files-large-response.integration.test.ts create mode 100644 tests/e2e/ssh-docker-quick-open-large-listing.spec.ts diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs index 195811f7312..dddfa3e0148 100644 --- a/config/scripts/run-ssh-docker-e2e.mjs +++ b/config/scripts/run-ssh-docker-e2e.mjs @@ -71,6 +71,7 @@ const result = spawnSync( 'tests/e2e/ssh-ai-vault-session-history.spec.ts', 'tests/e2e/ssh-cold-activation-restore.spec.ts', 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts', + 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts', 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts', 'tests/e2e/ssh-external-image-preview.spec.ts', diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index 65913f0c7e9..b9cb21c3354 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -486,14 +486,16 @@ describe('SshFilesystemProvider', () => { expect(result).toEqual(searchResult) }) - it('listFiles sends fs.listFiles request', async () => { + // Why #12547: a monorepo listing does not fit one control-lane frame, so the request opts into + // response streaming. An old relay ignores `__streamResponse` and answers plainly, which is the + // plain-array case each of these asserts. + it('listFiles sends a streamable fs.listFiles request', async () => { mux.request.mockResolvedValue(['src/index.ts', 'package.json']) const result = await provider.listFiles('/home/user/project') - expect(mux.request).toHaveBeenCalledWith( - 'fs.listFiles', - { rootPath: '/home/user/project' }, - { signal: undefined } - ) + expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { + rootPath: '/home/user/project', + __streamResponse: true + }) expect(result).toEqual(['src/index.ts', 'package.json']) }) @@ -503,26 +505,22 @@ describe('SshFilesystemProvider', () => { maxResults: 20_000, searchQuery: 'target' }) - expect(mux.request).toHaveBeenCalledWith( - 'fs.listFiles', - { - rootPath: '/home/user/project', - excludePaths: ['/home/user/project/worktrees/b'], - maxResults: 20_000, - searchQuery: 'target' - }, - { signal: undefined } - ) + expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { + rootPath: '/home/user/project', + excludePaths: ['/home/user/project/worktrees/b'], + maxResults: 20_000, + searchQuery: 'target', + __streamResponse: true + }) }) it('listFiles omits excludePaths when empty', async () => { mux.request.mockResolvedValue([]) await provider.listFiles('/home/user/project', { excludePaths: [] }) - expect(mux.request).toHaveBeenCalledWith( - 'fs.listFiles', - { rootPath: '/home/user/project' }, - { signal: undefined } - ) + expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { + rootPath: '/home/user/project', + __streamResponse: true + }) }) it('listFiles forwards the cancellation signal to the mux request (#7721)', async () => { @@ -531,8 +529,8 @@ describe('SshFilesystemProvider', () => { await provider.listFiles('/home/user/project', { signal: controller.signal }) expect(mux.request).toHaveBeenCalledWith( 'fs.listFiles', - { rootPath: '/home/user/project' }, - { signal: controller.signal } + { rootPath: '/home/user/project', __streamResponse: true }, + { signal: controller.signal, timeoutMs: undefined } ) }) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 70bb06730f8..f6208ea00e9 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -1,6 +1,7 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader' import { uploadBuffer } from '../ssh/sftp-upload' +import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' import { lstatViaSftp } from './ssh-filesystem-provider-sftp' import { downloadFileViaSftp, @@ -314,7 +315,11 @@ export class SshFilesystemProvider implements IFilesystemProvider { // Why #7721: the signal lets a workspace switch send rpc.cancel so the // relay aborts the full-tree scan instead of stacking abandoned scans // that starve interactive fs.readDir/fs.stat on the shared SSH channel. - return (await this.mux.request('fs.listFiles', params, { + // Why streamable: a monorepo listing serializes past the relay's 1 MiB control lane, and the + // lane it demotes to is refused under unrelated producer load. Opting in moves it to the bulk + // lane in chunks; an old relay ignores the flag and answers plainly, which the reader detects + // by the sentinel marker being absent. + return (await requestGitStreamable(this.mux, 'fs.listFiles', params, { signal: options?.signal })) as string[] } diff --git a/src/main/runtime/rpc/methods/files-list-all-page-size.test.ts b/src/main/runtime/rpc/methods/files-list-all-page-size.test.ts new file mode 100644 index 00000000000..826d0c0f3f0 --- /dev/null +++ b/src/main/runtime/rpc/methods/files-list-all-page-size.test.ts @@ -0,0 +1,53 @@ +/** + * #12547: `files.listAll` did not declare `maxResults`, so "the client names its cap and a full page + * means there is more" was wired only on the Electron IPC hop. Web and mobile were saved incidentally, + * by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`. + */ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { FILE_METHODS } from './files' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('files.listAll page size', () => { + // Why #12547: `maxResults` was wired only on the Electron IPC hop, so "a full page means there is + // more" was true for a desktop client and incidental for web/mobile. Declaring it here is a new + // optional field (wire rule 1): an older host strips it and keeps its own default. + it('forwards a client-named page size for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts']) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.listAll', { worktree: 'id:wt-1', maxResults: 20_001 }) + ) + + expect(runtime.listRuntimeFiles).toHaveBeenCalledWith('id:wt-1', { + excludePaths: undefined, + maxResults: 20_001 + }) + expect(response).toMatchObject({ ok: true, result: ['src/index.ts'] }) + }) + + // Why refuse rather than fall back: no released client sends this field, so a malformed value is a + // bug in the caller, not skew — the same call `files.search` already makes for its own maxResults. + it('refuses a malformed page size instead of silently picking one', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts']) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.listAll', { worktree: 'id:wt-1', maxResults: -3 }) + ) + + expect(response).toMatchObject({ ok: false }) + }) +}) diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index d4aaae455bc..ef349a22f84 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -93,8 +93,13 @@ const FileSearch = WorktreeSelector.extend({ maxResults: z.number().int().positive().optional() }) +// Why: `maxResults` is a new optional field (wire rule 1) — an older host strips it and keeps its +// own default. It existed only on the Electron IPC hop, so "the client names its cap and a full page +// means there is more" was true for desktop and merely incidental for web and mobile, which were +// saved by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`. const FileListAll = WorktreeSelector.extend({ - excludePaths: z.array(z.string()).optional() + excludePaths: z.array(z.string()).optional(), + maxResults: z.number().int().positive().optional() }) const FileUnwatch = z.object({ @@ -236,6 +241,7 @@ export const FILE_METHODS: RpcAnyMethod[] = [ const maxContentBytes = remoteFileContentBudget(clientKind, requestId) return runtime.listRuntimeFiles(params.worktree, { excludePaths: params.excludePaths, + ...(params.maxResults === undefined ? {} : { maxResults: params.maxResults }), ...(signal === undefined ? {} : { signal }), ...(maxContentBytes === undefined ? {} : { maxContentBytes }) }) diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index f8514a47191..ec11a806bb8 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -25,6 +25,7 @@ import { writeRelayFile } from './fs-path-mutation-requests' import { buildExcludePathPrefixes } from '../shared/quick-open-filter' +import { maybeStreamRpcResponse, type GitResponseStreamRegistry } from './git-response-stream' import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-file-read' import { readRelayFileRange } from './fs-handler-file-range' import { FileRangeReadRequestError } from '../shared/file-range-read' @@ -47,12 +48,19 @@ export class FsHandler { private watchRegistry: RelayFilesystemWatchRegistry private streamRegistry = new RelayStreamRegistry() private listFilesScans = new ListFilesScanCoordinator() + private readonly responseStreams: GitResponseStreamRegistry | undefined constructor( dispatcher: RelayDispatcher, _context: RelayContext, - watcherPool?: RelayWatcherProcessPool + watcherPool?: RelayWatcherProcessPool, + // Why passed in rather than owned: GitHandler registers the `git.responseAck` route every pump + // is credited through, and a client keys reassembly on `streamId` alone — see the header of + // git-response-stream.ts. Without one this handler answers plainly, which is the pre-streaming + // behavior rather than a stream nothing can credit. + responseStreams?: GitResponseStreamRegistry ) { + this.responseStreams = responseStreams this.dispatcher = dispatcher this.watchRegistry = new RelayFilesystemWatchRegistry(dispatcher, watcherPool) this.registerHandlers() @@ -204,7 +212,10 @@ export class FsHandler { } } - private listFiles(params: Record, context?: RequestContext): Promise { + private async listFiles( + params: Record, + context?: RequestContext + ): Promise { const rootPath = expandTilde(params.rootPath as string) const maxResults = typeof params.maxResults === 'number' && @@ -224,13 +235,21 @@ export class FsHandler { // Why #7721: full-tree scans are the relay's most expensive request; the // coordinator caps them at one per client, coalescing duplicates and // aborting a stale scan when the workspace changes or the host cancels. - return this.listFilesScans.run({ + const files = await this.listFilesScans.run({ clientId: context?.clientId ?? 0, key: JSON.stringify([rootPath, excludePathPrefixes, maxResults, searchQuery]), signal: context?.signal, start: (signal) => runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults, searchQuery) }) + // Why: a full listing of a real monorepo serializes past the 1 MiB control lane — Orca's own + // checkout is 22.6k paths averaging 58 characters, so a 20,001-row page is ~1.2MB — and the + // legacy-response lane it demotes to is refused under unrelated producer load. Streaming makes + // size stop being a correctness question instead of picking a row or byte ceiling to refuse at. + // A client that did not opt in still gets the plain array, exactly as before. + return this.responseStreams + ? maybeStreamRpcResponse(files, params, context, this.responseStreams, this.dispatcher) + : files } private async workspaceSpaceScan(params: Record, context: RequestContext) { diff --git a/src/relay/fs-list-files-large-response.integration.test.ts b/src/relay/fs-list-files-large-response.integration.test.ts new file mode 100644 index 00000000000..27c7ee7e648 --- /dev/null +++ b/src/relay/fs-list-files-large-response.integration.test.ts @@ -0,0 +1,130 @@ +/** + * #12547: a full `fs.listFiles` reply for a real monorepo does not fit the relay's control lane. + * + * Orca's own checkout is ~22.6k tracked paths averaging 58 characters, so a 20,001-row page + * serializes to ~1.2MB — past `DISPATCHER_CONTROL_QUEUE_MAX_BYTES`, which demotes it to the + * `legacy-response` lane where an unrelated producer backlog can refuse it. Refusing at a fixed row + * or byte ceiling only moves where that shows up; streaming removes it, so these run the real + * dispatcher, the real FsHandler and the real client multiplexer over an in-memory pipe and assert + * an over-budget listing arrives intact — in both wire directions. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { runListFilesScanMock } = vi.hoisted(() => ({ runListFilesScanMock: vi.fn() })) + +vi.mock('./fs-list-files-fallback-chain', () => ({ runListFilesScan: runListFilesScanMock })) +vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) + +import { + SshChannelMultiplexer, + type MultiplexerTransport +} from '../main/ssh/ssh-channel-multiplexer' +import { requestGitStreamable } from '../main/ssh/ssh-git-response-stream-reader' +import { RelayContext } from './context' +import { RelayDispatcher } from './dispatcher' +import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from './dispatcher-writer-admission' +import { FsHandler } from './fs-handler' +import { GitHandler } from './git-handler' +import { GitResponseStreamRegistry } from './git-response-stream' +import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../shared/quick-open-listing-limits' + +/** Shaped like this repository: `packages//src/...`, ~58 characters. */ +function monorepoPaths(count: number): string[] { + return Array.from( + { length: count }, + (_, index) => + `packages/pkg-${String(index % 64).padStart(2, '0')}/src/renderer/components/entry-${String(index).padStart(6, '0')}.tsx` + ) +} + +describe('Integration: an over-budget fs.listFiles reply (#12547)', () => { + let mux: SshChannelMultiplexer + let dispatcher: RelayDispatcher + let fsHandler: FsHandler + let gitHandler: GitHandler + let writtenFrames: number[] + + beforeEach(() => { + runListFilesScanMock.mockReset() + writtenFrames = [] + + let relayFeed: (data: Buffer) => void + const clientDataCallbacks: ((data: Buffer) => void)[] = [] + const clientTransport: MultiplexerTransport = { + write: (data: Buffer) => { + setImmediate(() => relayFeed?.(data)) + }, + onData: (cb) => { + clientDataCallbacks.push(cb) + }, + onClose: () => {} + } + dispatcher = new RelayDispatcher((data: Buffer) => { + writtenFrames.push(data.length) + setImmediate(() => { + for (const cb of clientDataCallbacks) { + cb(data) + } + }) + return true + }) + relayFeed = (data: Buffer) => dispatcher.feed(data) + // Why: the same single registry production wires, so `git.responseAck` — registered by + // GitHandler — credits the pump an fs.listFiles stream parks on. + const responseStreams = new GitResponseStreamRegistry() + const context = new RelayContext() + fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams) + gitHandler = new GitHandler(dispatcher, context, undefined, responseStreams) + mux = new SshChannelMultiplexer(clientTransport) + }) + + afterEach(() => { + mux.dispose() + dispatcher.dispose() + fsHandler.dispose() + gitHandler.dispose() + }) + + it('delivers a page too large for the control lane, in chunks no frame has to carry', async () => { + const files = monorepoPaths(QUICK_OPEN_LISTING_MAX_RESULTS) + // Precondition, measured from the payload rather than asserted between two constants: this is + // the listing that does not fit, which is what makes the rest of the test mean anything. + expect(Buffer.byteLength(JSON.stringify(files), 'utf8')).toBeGreaterThan( + DISPATCHER_CONTROL_QUEUE_MAX_BYTES + ) + runListFilesScanMock.mockResolvedValue(files) + + const received = await requestGitStreamable(mux, 'fs.listFiles', { + rootPath: '/remote/root', + maxResults: QUICK_OPEN_LISTING_MAX_RESULTS + }) + + expect(received).toEqual(files) + expect(Math.max(...writtenFrames)).toBeLessThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES) + }) + + it('still answers a client that never opts into streaming, with the whole array', async () => { + const files = monorepoPaths(QUICK_OPEN_LISTING_MAX_RESULTS) + runListFilesScanMock.mockResolvedValue(files) + + // Why: an old client sends neither `__streamResponse` nor `maxResults`. It gets one plain frame + // on the legacy-response lane, as it did before this call ever learned to stream. + const received = await mux.request('fs.listFiles', { rootPath: '/remote/root' }) + + expect(received).toEqual(files) + expect(Math.max(...writtenFrames)).toBeGreaterThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES) + }) + + it('leaves a reply that fits on the plain response path', async () => { + const files = monorepoPaths(100) + runListFilesScanMock.mockResolvedValue(files) + + const received = await requestGitStreamable(mux, 'fs.listFiles', { + rootPath: '/remote/root', + maxResults: 100 + }) + + expect(received).toEqual(files) + expect(Math.max(...writtenFrames)).toBeLessThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES) + }) +}) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 58f47da9fce..66bbd6d3cd1 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -10,8 +10,7 @@ import { createSubmodulePathsCache, type SubmodulePathsCache } from './git-handler-submodule-ops' -import { GitResponseStreamRegistry } from './git-response-stream' -import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' +import { GitResponseStreamRegistry, maybeStreamRpcResponse } from './git-response-stream' import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' import { buildRelayGitEnv, buildRelayUnattendedGitEnv } from './relay-command-env' @@ -68,9 +67,6 @@ export class GitHandler { private dispatcher: RelayDispatcher private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() private readonly gitCapabilities = new GitCapabilityCache() - // Why: use the bulk lane so large responses do not block interactive PTY echo. - private readonly responseStreams = new GitResponseStreamRegistry() - // Why: cache .gitmodules per instance to avoid SSH reads and test leakage. private submodulePathsCache: SubmodulePathsCache = createSubmodulePathsCache() @@ -78,7 +74,12 @@ export class GitHandler { constructor( dispatcher: RelayDispatcher, _context: RelayContext, - private readonly watcherRegistry?: GitHandlerWatcherRegistry + private readonly watcherRegistry?: GitHandlerWatcherRegistry, + // Why: use the bulk lane so large responses do not block interactive PTY echo. This handler + // registers the `git.responseAck` route below, so in production it takes the relay's single + // registry and FsHandler is handed the same one — see the header of git-response-stream.ts for + // why a second registry both collides on stream ids and stalls on credit. + private readonly responseStreams: GitResponseStreamRegistry = new GitResponseStreamRegistry() ) { this.dispatcher = dispatcher const handlers = createGitHandlerOperationSet({ @@ -132,14 +133,7 @@ export class GitHandler { params: Record, context: RequestContext | undefined ): unknown { - if (params.__streamResponse !== true || !context) { - return result - } - const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8') - if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) { - return result - } - return this.responseStreams.startStream(payload, this.dispatcher, context) + return maybeStreamRpcResponse(result, params, context, this.responseStreams, this.dispatcher) } private clearGitMutationReadCaches(): void { diff --git a/src/relay/git-response-stream.ts b/src/relay/git-response-stream.ts index 3ccbd66ee8d..c9d8d2ce290 100644 --- a/src/relay/git-response-stream.ts +++ b/src/relay/git-response-stream.ts @@ -1,11 +1,22 @@ -// Streams large git RPC responses (diff family + exec) onto the bulk lane in -// chunks instead of one JSON-RPC frame, so a big diff cannot head-of-line-block -// interactive pty.data echo on the shared SSH channel. Mirrors the fs -// read-stream credit-window pattern (see fs-handler-file-read.ts) but the -// payload is an in-memory serialized string rather than a file handle. +// Streams large RPC responses onto the bulk lane in chunks instead of one +// JSON-RPC frame, so a big reply cannot head-of-line-block interactive pty.data +// echo on the shared SSH channel. Mirrors the fs read-stream credit-window +// pattern (see fs-handler-file-read.ts) but the payload is an in-memory +// serialized string rather than a file handle. +// +// ONE REGISTRY PER RELAY. The `git.*` method names below are the shipped wire +// spelling and are permanent, the way an opcode number is, so a second handler +// that needs streaming (`fs.listFiles` is the first) shares this instance rather +// than minting its own. A second registry is not an option: a client keys +// reassembly on `streamId` alone, so two would hand out the same id and +// cross-feed each other's chunks, and only the handler that registers +// `git.responseAck` can credit the ack window a pump parks on — the other's +// streams would stall at STREAM_ACK_WINDOW_CHUNKS forever. See +// `relay-runtime-services.ts` for the wiring. import type { RelayDispatcher, RequestContext } from './dispatcher' import { GIT_RESPONSE_CHUNK_SIZE, + GIT_RESPONSE_STREAM_THRESHOLD, STREAM_ACK_WINDOW_CHUNKS, STREAM_ACK_STALL_RECHECK_MS, type GitResponseStreamMarker @@ -220,3 +231,29 @@ export class GitResponseStreamRegistry { this.streams.clear() } } + +/** + * Opt-in response streaming, shared by every handler that can answer with a + * payload too large for one control-lane frame. + * + * `__streamResponse` is its own negotiation in both directions: an old client + * never sends it and gets the plain result, and an old relay ignores it and + * answers plainly, which the client detects by the sentinel marker being absent. + * So there is no new method and no capability to advertise. + */ +export function maybeStreamRpcResponse( + result: unknown, + params: Record, + context: RequestContext | undefined, + registry: GitResponseStreamRegistry, + dispatcher: RelayDispatcher +): unknown { + if (params.__streamResponse !== true || !context) { + return result + } + const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8') + if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) { + return result + } + return registry.startStream(payload, dispatcher, context) +} diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts index 36276ed9b70..485c9e787d1 100644 --- a/src/relay/relay-runtime-services.ts +++ b/src/relay/relay-runtime-services.ts @@ -6,6 +6,7 @@ import { RelayContext, expandTilde } from './context' import { PtyHandler } from './pty-handler' import { FsHandler } from './fs-handler' import { GitHandler } from './git-handler' +import { GitResponseStreamRegistry } from './git-response-stream' import { PreflightHandler } from './preflight-handler' import { ExternalAutomationsHandler } from './external-automations-handler' import { PortScanHandler } from './port-scan-handler' @@ -51,13 +52,17 @@ export class RelayRuntimeServices { ) this.ptyHandler.setSourcePublication(this.ptySourcePublication) - this.fsHandler = new FsHandler(dispatcher, context) + // Why one instance for both handlers: a client reassembles a streamed reply by `streamId` alone, + // so two registries would hand out the same id, and only GitHandler routes the `git.responseAck` + // credit every pump waits on. A second registry is not an option — see git-response-stream.ts. + const responseStreams = new GitResponseStreamRegistry() + this.fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams) const watchRegistry = this.fsHandler.getWatchRegistry() this.ptyHandler.setWorktreeRemovalCoordinator(watchRegistry) watchRegistry.setWorktreePtyTeardown((rootPath) => this.ptyHandler.shutdownForWorktreePath(rootPath) ) - this.gitHandler = new GitHandler(dispatcher, context, watchRegistry) + this.gitHandler = new GitHandler(dispatcher, context, watchRegistry, responseStreams) const preflightHandler = new PreflightHandler(dispatcher) this.skillInstallHandler = new SkillInstallHandler(dispatcher) const externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) diff --git a/tests/e2e/helpers/docker-ssh-relay-target.ts b/tests/e2e/helpers/docker-ssh-relay-target.ts index 78534499943..f535b22d073 100644 --- a/tests/e2e/helpers/docker-ssh-relay-target.ts +++ b/tests/e2e/helpers/docker-ssh-relay-target.ts @@ -211,6 +211,16 @@ export function writeDockerSshRelayTargetFile( ) } +/** Why not `writeDockerSshRelayTargetFile`: that one passes the contents as a shell argument, so a + * payload the size of a real repository's path list exceeds ARG_MAX before it reaches the shell. */ +export function copyFileIntoDockerSshRelayTarget( + target: DockerSshRelayTarget, + localPath: string, + remotePath: string +): void { + run('docker', ['cp', localPath, `${target.containerName}:${remotePath}`], { timeoutMs: 120_000 }) +} + export function startDockerSshRelayTarget(testInfo: TestInfo): DockerSshRelayTarget { const host = process.env.ORCA_E2E_SSH_TARGET_HOST?.trim() || '127.0.0.1' if (host === 'localhost' || host === '::1' || host.startsWith('127.')) { diff --git a/tests/e2e/ssh-docker-quick-open-large-listing.spec.ts b/tests/e2e/ssh-docker-quick-open-large-listing.spec.ts new file mode 100644 index 00000000000..ee814ab02db --- /dev/null +++ b/tests/e2e/ssh-docker-quick-open-large-listing.spec.ts @@ -0,0 +1,134 @@ +/** + * #12547 acceptance: open a repository the size of Orca's own checkout over SSH and list its files. + * + * The remote tree is seeded from this repository's real `git ls-files` output, so the payload has + * the shape that broke: ~22.6k paths averaging 58 characters, whose 20,001-row page serializes to + * ~1.2MB — past `DISPATCHER_CONTROL_QUEUE_MAX_BYTES`. Both wire directions are exercised over the + * real relay: a current client, and a client that names no `maxResults` at all. + */ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { expect, test } from './helpers/orca-app' +import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' +import { + cleanupDockerSshRelayTarget, + copyFileIntoDockerSshRelayTarget, + execDockerSshRelayTargetCommand, + shellQuote, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { ensureDockerSshRelayImage } from './helpers/docker-ssh-relay-image' +import { waitForSessionReady } from './helpers/store' +import { shouldIncludeQuickOpenPath } from '../../src/shared/quick-open-filter' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' +const REMOTE_REPO_PATH = '/tmp/orca-quick-open-large-listing-repo' +const REMOTE_PATH_LIST = '/tmp/orca-quick-open-large-listing-paths.txt' +/** What the desktop client asks for; a full page is what it reads as "there is more". */ +const CLIENT_PAGE_SIZE = 20_001 + +function thisRepositoryTrackedPaths(): string[] { + const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim() + // Why -z: `git ls-files` C-quotes any path with a special character, which would seed a tree that + // does not match the one being measured. + return execFileSync('git', ['ls-files', '-z'], { + cwd: root, + encoding: 'utf8', + maxBuffer: 1024 * 1024 * 256 + }) + .split('\0') + .filter(Boolean) +} + +function seedRemoteTree(target: DockerSshRelayTarget, paths: string[]): void { + const stagingDir = mkdtempSync(path.join(tmpdir(), 'orca-quick-open-large-listing-')) + try { + const localList = path.join(stagingDir, 'paths.txt') + writeFileSync(localList, `${paths.join('\n')}\n`) + copyFileIntoDockerSshRelayTarget(target, localList, REMOTE_PATH_LIST) + } finally { + rmSync(stagingDir, { recursive: true, force: true }) + } + const seedScript = [ + "const fs = require('fs'), path = require('path')", + `const list = fs.readFileSync(${JSON.stringify(REMOTE_PATH_LIST)}, 'utf8').split('\\n').filter(Boolean)`, + 'const seen = new Set()', + 'for (const entry of list) {', + ' const dir = path.dirname(entry)', + ' if (dir !== "." && !seen.has(dir)) { fs.mkdirSync(dir, { recursive: true }); seen.add(dir) }', + " fs.writeFileSync(entry, '')", + '}' + ].join(';') + const encoded = Buffer.from(seedScript, 'utf8').toString('base64') + execDockerSshRelayTargetCommand( + target, + [ + `rm -rf ${shellQuote(REMOTE_REPO_PATH)}`, + `mkdir -p ${shellQuote(REMOTE_REPO_PATH)}`, + `cd ${shellQuote(REMOTE_REPO_PATH)}`, + 'git init -q', + 'git config user.email e2e@test.local', + 'git config user.name "Orca Docker SSH E2E"', + `node -e ${shellQuote(`eval(Buffer.from('${encoded}', 'base64').toString('utf8'))`)}`, + 'git add -A', + 'git commit -q -m "seed monorepo-shaped tree"' + ].join(' && ') + ) +} + +test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run the Docker SSH relay lane') + +test('lists a monorepo-sized remote workspace, with and without a client page size (#12547)', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(420_000) + let target: DockerSshRelayTarget | null = null + try { + const trackedPaths = thisRepositoryTrackedPaths() + expect(trackedPaths.length).toBeGreaterThan(CLIENT_PAGE_SIZE) + // Why the real predicate rather than a copy of it: Quick Open prunes a few tracked paths on + // purpose (`.husky/` among them), and a hand-written expectation would go stale the first time + // that list changes and read as a transport bug. + const listablePaths = trackedPaths.filter(shouldIncludeQuickOpenPath) + // Precondition, measured rather than assumed: the page a current client asks for does not fit + // one control-lane frame, which is the listing that used to be refused outright. + expect( + Buffer.byteLength(JSON.stringify(trackedPaths.slice(0, CLIENT_PAGE_SIZE)), 'utf8') + ).toBeGreaterThan(1024 * 1024) + + ensureDockerSshRelayImage(process.cwd()) + target = startDockerSshRelayTarget(testInfo) + seedRemoteTree(target, trackedPaths) + + await waitForSessionReady(orcaPage) + const connected = await connectDockerSshRelayTarget(orcaPage, target, { + remotePath: REMOTE_REPO_PATH + }) + + const listFiles = async (maxResults?: number): Promise => + orcaPage.evaluate( + ({ connectionId, rootPath, maxResults }) => + window.api.fs.listFiles({ + rootPath, + connectionId, + ...(maxResults === undefined ? {} : { maxResults }) + }), + { connectionId: connected.targetId, rootPath: REMOTE_REPO_PATH, maxResults } + ) + + const currentClient = await listFiles(CLIENT_PAGE_SIZE) + expect(currentClient).toHaveLength(CLIENT_PAGE_SIZE) + + // Why: a client that predates `maxResults` on this call sends none at all, and it cannot + // reassemble a streamed reply either — it has to be answered on the plain response path. + const oldClient = await listFiles() + expect(oldClient).toHaveLength(listablePaths.length) + expect(new Set(oldClient)).toEqual(new Set(listablePaths)) + } finally { + cleanupDockerSshRelayTarget(target) + } +})