From 2fd2e8834339f55d7b7d80cdf18fba614b4fecb7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:40:10 -0700 Subject: [PATCH] feat(runtime): emit startup-cached updateInfo install/restart detection in runtime status Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 6 +- src/main/runtime/orca-runtime.ts | 7 + .../runtime/runtime-install-detection.test.ts | 187 ++++++++++++++ src/main/runtime/runtime-install-detection.ts | 230 ++++++++++++++++++ src/shared/runtime-types.ts | 31 +++ 5 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 src/main/runtime/runtime-install-detection.test.ts create mode 100644 src/main/runtime/runtime-install-detection.ts diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 4939d056a46..ed0bc5fa1b9 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -156,7 +156,11 @@ const electronMocks = vi.hoisted(() => { BrowserWindow: { fromId: vi.fn((_id: number): unknown => null) }, webContents: { fromId: vi.fn((_id: number): unknown => null) }, ipcMain, - app: { getPath: vi.fn(() => '/tmp') } + app: { + getPath: vi.fn(() => '/tmp'), + getVersion: vi.fn(() => '0.0.0-test'), + isPackaged: false + } } }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fe9eb66574d..cd553100185 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -377,6 +377,7 @@ import { type ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' import { joinWorktreeRelativePath } from './runtime-relative-paths' +import { getRuntimeUpdateInfo } from './runtime-install-detection' import { collectMemorySnapshot } from '../memory/collector' import { BrowserWindow, ipcMain } from 'electron' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' @@ -2684,6 +2685,9 @@ export class OrcaRuntimeService { } ) { this.store = store + // Why: prime the install-detection cache at startup so the one-time + // package-ownership probe never runs on the polled status.get path. + void getRuntimeUpdateInfo() if (stats) { this.stats = stats this.agentDetector = new AgentDetector(stats) @@ -3179,6 +3183,9 @@ export class OrcaRuntimeService { capabilities, hostPlatform: process.platform, terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null, + // Why: startup-cached; the advisor's install/restart hint must never run a + // subprocess or read /proc on this polled status path. + updateInfo: getRuntimeUpdateInfo(), protocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } diff --git a/src/main/runtime/runtime-install-detection.test.ts b/src/main/runtime/runtime-install-detection.test.ts new file mode 100644 index 00000000000..49707f5b2df --- /dev/null +++ b/src/main/runtime/runtime-install-detection.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import { + detectRuntimeInstall, + gatherWindowsInstallRoots, + type RuntimeInstallDetectionInput +} from './runtime-install-detection' + +function baseInput( + overrides: Partial = {} +): RuntimeInstallDetectionInput { + return { + platform: 'linux', + arch: 'x64', + isPackaged: true, + execPath: '/opt/Orca/orca-ide', + appVersion: '1.2.3', + appImagePath: null, + isServeMode: true, + cgroupContent: null, + windowsInstallRoots: [], + linuxPackageOwner: null, + ...overrides + } +} + +describe('detectRuntimeInstall install kind', () => { + it('reports source when the app is not packaged', () => { + const result = detectRuntimeInstall(baseInput({ isPackaged: false })) + expect(result.installKind).toBe('source') + }) + + it('reports linux-appimage and installPath when APPIMAGE is set', () => { + const result = detectRuntimeInstall( + baseInput({ + platform: 'linux', + appImagePath: '/opt/orca/orca-linux.AppImage' + }) + ) + expect(result.installKind).toBe('linux-appimage') + expect(result.installPath).toBe('/opt/orca/orca-linux.AppImage') + }) + + it('reports windows-installer when execPath sits under an NSIS root', () => { + const result = detectRuntimeInstall( + baseInput({ + platform: 'win32', + execPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\orca\\Orca.exe', + windowsInstallRoots: ['C:\\Users\\me\\AppData\\Local\\Programs'] + }) + ) + expect(result.installKind).toBe('windows-installer') + expect(result.installPath).toBeUndefined() + }) + + it('reports unknown on packaged win32 when execPath is outside every install root', () => { + const result = detectRuntimeInstall( + baseInput({ + platform: 'win32', + execPath: 'D:\\portable\\orca\\Orca.exe', + windowsInstallRoots: ['C:\\Program Files'] + }) + ) + expect(result.installKind).toBe('unknown') + }) + + it('reports mac-app for an execPath inside a .app bundle', () => { + const result = detectRuntimeInstall( + baseInput({ + platform: 'darwin', + execPath: '/Applications/Orca.app/Contents/MacOS/Orca' + }) + ) + expect(result.installKind).toBe('mac-app') + }) + + it('never reports mac-homebrew and falls back to unknown outside a bundle', () => { + const result = detectRuntimeInstall( + baseInput({ + platform: 'darwin', + execPath: '/usr/local/bin/orca' + }) + ) + expect(result.installKind).toBe('unknown') + }) + + it('reports linux-deb only when the startup package probe resolved it', () => { + const deb = detectRuntimeInstall( + baseInput({ platform: 'linux', linuxPackageOwner: 'linux-deb' }) + ) + expect(deb.installKind).toBe('linux-deb') + + const unknown = detectRuntimeInstall(baseInput({ platform: 'linux', linuxPackageOwner: null })) + expect(unknown.installKind).toBe('unknown') + }) +}) + +describe('detectRuntimeInstall restart kind', () => { + it('maps a system-slice service cgroup to systemd with its unit name', () => { + const result = detectRuntimeInstall( + baseInput({ + platform: 'linux', + cgroupContent: '0::/system.slice/orca-serve.service\n' + }) + ) + expect(result.restartKind).toBe('systemd') + expect(result.serviceName).toBe('orca-serve.service') + }) + + it('trusts a user-slice service only when the unit is an orca unit', () => { + const result = detectRuntimeInstall( + baseInput({ + platform: 'linux', + cgroupContent: + '0::/user.slice/user-1000.slice/user@1000.service/app.slice/orca-serve.service\n' + }) + ) + expect(result.restartKind).toBe('systemd') + expect(result.serviceName).toBe('orca-serve.service') + }) + + it('does NOT map a user-slice terminal service to systemd', () => { + const result = detectRuntimeInstall( + baseInput({ + platform: 'linux', + isServeMode: true, + cgroupContent: + '0::/user.slice/user-1000.slice/user@1000.service/app.slice/gnome-terminal-server.service\n' + }) + ) + expect(result.restartKind).toBe('foreground-serve') + expect(result.serviceName).toBeUndefined() + }) + + it('maps a foreground orca serve with no systemd markers to foreground-serve', () => { + const result = detectRuntimeInstall( + baseInput({ platform: 'linux', isServeMode: true, cgroupContent: null }) + ) + expect(result.restartKind).toBe('foreground-serve') + }) + + it('maps the full desktop app to desktop', () => { + const result = detectRuntimeInstall( + baseInput({ platform: 'darwin', isServeMode: false, cgroupContent: null }) + ) + expect(result.restartKind).toBe('desktop') + }) + + it('does not read cgroup off linux', () => { + const result = detectRuntimeInstall( + baseInput({ + platform: 'win32', + isServeMode: false, + cgroupContent: '0::/system.slice/orca-serve.service\n' + }) + ) + expect(result.restartKind).toBe('desktop') + expect(result.serviceName).toBeUndefined() + }) +}) + +describe('detectRuntimeInstall common fields', () => { + it('stamps currentVersion and hostArch and leaves latest fields unset', () => { + const result = detectRuntimeInstall(baseInput({ appVersion: '4.5.6', arch: 'arm64' })) + expect(result.currentVersion).toBe('4.5.6') + expect(result.hostArch).toBe('arm64') + expect(result.latestVersion).toBeUndefined() + expect(result.updateAvailable).toBeUndefined() + expect(result.docsUrl).toBeUndefined() + }) +}) + +describe('gatherWindowsInstallRoots', () => { + it('derives per-user Programs and machine-wide Program Files roots', () => { + const roots = gatherWindowsInstallRoots({ + LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local', + ProgramFiles: 'C:\\Program Files', + 'ProgramFiles(x86)': 'C:\\Program Files (x86)' + } as NodeJS.ProcessEnv) + expect(roots).toContain('C:\\Users\\me\\AppData\\Local\\Programs') + expect(roots).toContain('C:\\Program Files') + expect(roots).toContain('C:\\Program Files (x86)') + }) + + it('returns an empty list when no install-root env vars are present', () => { + expect(gatherWindowsInstallRoots({} as NodeJS.ProcessEnv)).toEqual([]) + }) +}) diff --git a/src/main/runtime/runtime-install-detection.ts b/src/main/runtime/runtime-install-detection.ts new file mode 100644 index 00000000000..48852c1ff61 --- /dev/null +++ b/src/main/runtime/runtime-install-detection.ts @@ -0,0 +1,230 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { app } from 'electron' +import type { + RuntimeInstallKind, + RuntimeRestartKind, + RuntimeUpdateInfo +} from '../../shared/runtime-types' + +// Why: everything the mapping needs is captured in one plain snapshot so the +// core is a pure function testable with fixtures — the caching wrapper is the +// only place that touches electron/process/fs. +export type RuntimeInstallDetectionInput = { + platform: NodeJS.Platform + arch: string + isPackaged: boolean + execPath: string + appVersion: string + // process.env.APPIMAGE — set by the AppImage runtime; its value is the install path. + appImagePath: string | null + // True for `orca serve`; false when serving from the full desktop app. + isServeMode: boolean + // /proc/self/cgroup contents (linux only); null when unreadable or off-linux. + cgroupContent: string | null + // Candidate NSIS install roots gathered from Windows env vars. + windowsInstallRoots: string[] + // Result of a single startup package-ownership probe on packaged non-AppImage + // Linux; null when skipped, failed, or inconclusive. + linuxPackageOwner: 'linux-deb' | 'linux-rpm' | null +} + +// Why: a manual `orca serve` inherits its parent's cgroup, so a user-slice +// service unit is only trusted as systemd when it is actually an orca unit. +const ORCA_SERVICE_PREFIX = 'orca' + +export function detectRuntimeInstall(input: RuntimeInstallDetectionInput): RuntimeUpdateInfo { + const systemd = detectSystemdService(input) + const installKind = detectInstallKind(input) + const restartKind = detectRestartKind(input, systemd) + + const updateInfo: RuntimeUpdateInfo = { + currentVersion: input.appVersion, + installKind, + restartKind, + hostArch: input.arch + } + if (installKind === 'linux-appimage' && input.appImagePath) { + updateInfo.installPath = input.appImagePath + } + if (systemd) { + updateInfo.serviceName = systemd.serviceName + } + return updateInfo +} + +function detectInstallKind(input: RuntimeInstallDetectionInput): RuntimeInstallKind { + if (!input.isPackaged) { + return 'source' + } + if (input.platform === 'linux' && input.appImagePath) { + return 'linux-appimage' + } + if (input.platform === 'win32') { + return isUnderWindowsInstallRoot(input) ? 'windows-installer' : 'unknown' + } + if (input.platform === 'darwin') { + // Why: Homebrew casks also copy into /Applications, so mac-homebrew is not + // distinguishable at runtime; report the broader mac-app (its guide covers Homebrew). + return isInsideMacAppBundle(input.execPath) ? 'mac-app' : 'unknown' + } + if (input.platform === 'linux') { + // Non-AppImage packaged Linux: only trust deb/rpm from the startup probe. + return input.linuxPackageOwner ?? 'unknown' + } + return 'unknown' +} + +function detectRestartKind( + input: RuntimeInstallDetectionInput, + systemd: SystemdService | null +): RuntimeRestartKind { + if (systemd) { + return 'systemd' + } + // Serving from the full desktop app restarts by relaunching the app; a + // foreground `orca serve` with no systemd markers is stopped and re-run by hand. + return input.isServeMode ? 'foreground-serve' : 'desktop' +} + +type SystemdService = { serviceName: string } + +function detectSystemdService(input: RuntimeInstallDetectionInput): SystemdService | null { + if (input.platform !== 'linux' || !input.cgroupContent) { + return null + } + for (const line of input.cgroupContent.split('\n')) { + const service = matchSystemdServiceLine(line) + if (service) { + return service + } + } + return null +} + +function matchSystemdServiceLine(line: string): SystemdService | null { + const trimmed = line.trim() + if (trimmed === '') { + return null + } + // cgroup lines are `hierarchy:controllers:/path`; the path is the final field. + const path = trimmed.slice(trimmed.lastIndexOf(':') + 1) + const segments = path.split('/').filter((segment) => segment !== '') + // The leaf `.service` is this process's unit; ancestors like user@1000.service + // are session managers, not the served unit. + let leafService: string | null = null + for (const segment of segments) { + if (segment.endsWith('.service')) { + leafService = segment + } + } + if (!leafService) { + return null + } + if (segments.includes('system.slice')) { + return { serviceName: leafService } + } + // A user-slice unit is only trusted when it is itself an orca unit; a manual + // `orca serve` inside gnome-terminal-server.service must not map to systemd. + if (segments.includes('user.slice') && leafService.startsWith(ORCA_SERVICE_PREFIX)) { + return { serviceName: leafService } + } + return null +} + +function isUnderWindowsInstallRoot(input: RuntimeInstallDetectionInput): boolean { + const execPath = normalizeWindowsPath(input.execPath) + return input.windowsInstallRoots.some((root) => { + const normalizedRoot = normalizeWindowsPath(root) + return normalizedRoot !== '' && execPath.startsWith(`${normalizedRoot}\\`) + }) +} + +function normalizeWindowsPath(value: string): string { + // Windows paths are case-insensitive and mix separators; normalize both. + return value.replace(/\//g, '\\').toLowerCase() +} + +function isInsideMacAppBundle(execPath: string): boolean { + return /\.app\/Contents\/MacOS\//.test(execPath) +} + +// Why: NSIS installs per-user under %LOCALAPPDATA%\Programs and machine-wide +// under Program Files; execPath sitting below one of these is the installer signal. +export function gatherWindowsInstallRoots(env: NodeJS.ProcessEnv): string[] { + const roots: string[] = [] + if (env.LOCALAPPDATA) { + roots.push(`${env.LOCALAPPDATA}\\Programs`) + } + for (const key of ['ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432']) { + const value = env[key] + if (value) { + roots.push(value) + } + } + return roots +} + +function readCgroupContent(platform: NodeJS.Platform): string | null { + if (platform !== 'linux') { + return null + } + try { + return readFileSync('/proc/self/cgroup', 'utf-8') + } catch { + return null + } +} + +// Why: single best-effort startup probe for the deb-vs-rpm split; any failure +// (tool absent, not owned, timeout) yields null so detection reports `unknown`. +function probeLinuxPackageOwner( + input: Pick +): 'linux-deb' | 'linux-rpm' | null { + if (input.platform !== 'linux' || !input.isPackaged || input.appImagePath) { + return null + } + if (tryPackageProbe('dpkg', ['-S', input.execPath])) { + return 'linux-deb' + } + if (tryPackageProbe('rpm', ['-qf', input.execPath])) { + return 'linux-rpm' + } + return null +} + +function tryPackageProbe(command: string, args: string[]): boolean { + try { + execFileSync(command, args, { stdio: 'ignore', timeout: 2000 }) + return true + } catch { + return false + } +} + +let cachedUpdateInfo: RuntimeUpdateInfo | undefined + +// Gathers real values once and caches the result. Never runs a subprocess or +// reads /proc on the status hot path — status.get reuses the cached snapshot. +export function getRuntimeUpdateInfo(): RuntimeUpdateInfo { + if (cachedUpdateInfo) { + return cachedUpdateInfo + } + const platform = process.platform + const isPackaged = app.isPackaged + const execPath = process.execPath + const appImagePath = process.env.APPIMAGE ?? null + cachedUpdateInfo = detectRuntimeInstall({ + platform, + arch: process.arch, + isPackaged, + execPath, + appVersion: app.getVersion(), + appImagePath, + isServeMode: process.argv.includes('--serve'), + cgroupContent: readCgroupContent(platform), + windowsInstallRoots: platform === 'win32' ? gatherWindowsInstallRoots(process.env) : [], + linuxPackageOwner: probeLinuxPackageOwner({ platform, isPackaged, execPath, appImagePath }) + }) + return cachedUpdateInfo +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 953dae57909..ee7bfbc8e5e 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -58,6 +58,34 @@ export type RuntimeTerminalDriverState = export type RuntimeBrowserDriverState = RuntimeTerminalDriverState +// Why: advisory, untrusted server-supplied hint for the remote update advisor. +// Every field is optional and validated client-side before it selects a guide +// template or fills a placeholder — a compromised server must never widen what +// the client renders into a copyable command. +export type RuntimeUpdateInfo = { + currentVersion?: string | null + latestVersion?: string | null + updateAvailable?: boolean | null + installKind?: RuntimeInstallKind + restartKind?: RuntimeRestartKind + serviceName?: string | null + installPath?: string | null + hostArch?: string | null // process.arch, e.g. 'x64' | 'arm64' + docsUrl?: string +} + +export type RuntimeInstallKind = + | 'mac-app' + | 'mac-homebrew' + | 'windows-installer' + | 'linux-appimage' + | 'linux-deb' + | 'linux-rpm' + | 'source' + | 'unknown' + +export type RuntimeRestartKind = 'desktop' | 'foreground-serve' | 'systemd' | 'unknown' + export type RuntimeStatus = { runtimeId: string rendererGraphEpoch: number @@ -74,6 +102,9 @@ export type RuntimeStatus = { remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null hostPlatform?: NodeJS.Platform terminalWindowsShell?: string | null + // Why: startup-cached, advisory install/restart shape for the update advisor. + // Omitted by servers older than this contract; all client handling is defensive. + updateInfo?: RuntimeUpdateInfo // Why: legacy or saved WebSocket pairings may not carry scope metadata, so // the server stamps the authenticated token scope here for status.get only. deviceScope?: DeviceScope