feat(runtime): emit startup-cached updateInfo install/restart detection in runtime status

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-20 11:42:25 -07:00
co-authored by Orca
parent 4f84969e93
commit 2fd2e88343
5 changed files with 460 additions and 1 deletions
+5 -1
View File
@@ -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
}
}
})
+7
View File
@@ -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
}
@@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest'
import {
detectRuntimeInstall,
gatherWindowsInstallRoots,
type RuntimeInstallDetectionInput
} from './runtime-install-detection'
function baseInput(
overrides: Partial<RuntimeInstallDetectionInput> = {}
): 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([])
})
})
@@ -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<RuntimeInstallDetectionInput, 'platform' | 'isPackaged' | 'execPath' | 'appImagePath'>
): '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
}
+31
View File
@@ -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