mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
feat(runtime): emit startup-cached updateInfo install/restart detection in runtime status
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user