fix(orcad): ship computer use with the server and say when it is unavailable (#31958)

* fix(orcad): ship computer use with the server and say when it is unavailable

* test(computer): drop the type assertion in the sidecar app-environment mock

* fix(cli): carry the server reprovision hint into --json computer failures
This commit is contained in:
Neil
2026-10-22 03:27:39 -07:00
committed by GitHub
parent 0b4f2d9a86
commit 8e9690468a
19 changed files with 222 additions and 35 deletions
@@ -10,7 +10,7 @@ export const MAIN_BUILD_ENTRY_POINTS: Record<string, string> = {
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
'computer-sidecar': resolve('src/main/computer/computer-sidecar.ts'),
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
'foreign-sqlite-reader-entry': resolve(
+1 -1
View File
@@ -6,7 +6,7 @@
"src/preload/browser-window-close.ts",
"src/main/daemon/daemon-entry.ts",
"src/main/plugins/plugin-host-entry.ts",
"src/main/computer/sidecar-entry.ts",
"src/main/computer/computer-sidecar.ts",
"src/main/speech/stt-worker.ts",
"src/main/warp-themes/warp-theme-parser-worker.ts",
"src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts",
+5
View File
@@ -40,6 +40,7 @@ import {
ORCAD_EMOJI_SHORTCODE_DATASET,
ORCAD_CLI_ENTRY_FILENAME,
ORCAD_CLI_PACKAGE_FILENAME,
ORCAD_COMPUTER_USE_RUNTIME_ARTIFACTS,
ORCAD_LAUNCHER_FILENAME,
ORCAD_SERVER_ENTRY_FILENAME,
ORCAD_NODE_PTY_DIR,
@@ -210,6 +211,10 @@ cpSync(join(ROOT, 'resources', 'licenses', 'ripgrep'), join(OUT_DIR, 'ripgrep',
cpSync(join(ROOT, 'resources', 'native-chat-visuals'), join(OUT_DIR, 'native-chat-visuals'), {
recursive: true
})
for (const artifact of ORCAD_COMPUTER_USE_RUNTIME_ARTIFACTS) {
mkdirSync(dirname(join(OUT_DIR, artifact)), { recursive: true })
copyFileSync(join(ROOT, 'native', ...artifact.split('/')), join(OUT_DIR, ...artifact.split('/')))
}
stageOrcadMobileWebBundle(OUT_DIR)
/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source
+2 -1
View File
@@ -21,7 +21,8 @@ export const ORCAD_CHILD_ENTRY_POINTS = {
workerThreadChildProcess:
'src/main/worker-thread-child-process/worker-thread-child-process-entry.ts',
claudeProfileSetup: 'src/main/claude-accounts/claude-profile-setup-worker-entry.ts',
sttWorker: 'src/main/speech/stt-worker.ts'
sttWorker: 'src/main/speech/stt-worker.ts',
computerSidecar: 'src/main/computer/computer-sidecar.ts'
}
/** Each child ships flat beside orcad.js under its source basename; the runtime resolvers look there. */
+7 -2
View File
@@ -18,9 +18,14 @@ import time
import uuid
from dataclasses import dataclass
import gi
try:
import gi
gi.require_version("Atspi", "2.0")
gi.require_version("Atspi", "2.0")
except (ImportError, ValueError) as error:
# Why name it: PATH often puts a pyenv/Nix python3 without gi ahead of the system one.
sys.stderr.write(f"PyGObject unavailable in interpreter: {sys.executable}\n{error}\n")
sys.exit(1)
try:
gi.require_version("Gdk", "3.0")
gi.require_version("GdkPixbuf", "2.0")
+25 -1
View File
@@ -84,6 +84,10 @@ export function formatCliError(error: unknown, context: CliErrorContext = {}): s
if (conflict) {
return formatMessageWithNextSteps(stripAutomationOwnerConflictCode(message), conflict.nextSteps)
}
const sidecarExit = computerSidecarExitRecovery(error, context)
if (sidecarExit) {
return formatMessageWithNextSteps(message, sidecarExit.nextSteps)
}
if (error instanceof RuntimeClientError) {
const nextSteps = nextStepsFromData(error.data)
if (nextSteps.length > 0) {
@@ -120,7 +124,10 @@ export function reportCliError(error: unknown, json: boolean, context: CliErrorC
const selector = contextRecovery(errorCode(error), context)
if (json) {
if (error instanceof RuntimeRpcFailureError) {
const response = withAutomationOwnerConflictRecovery(error.response)
const sidecarExit = computerSidecarExitRecovery(error, context)
const response = sidecarExit
? { ...error.response, error: { ...error.response.error, data: sidecarExit } }
: withAutomationOwnerConflictRecovery(error.response)
console.log(
JSON.stringify(
selector
@@ -217,7 +224,24 @@ function nextStepsFromData(data: unknown): string[] {
return []
}
/** Read here, not from the error's data: the servers that send this predate the hint (#31777). */
function computerSidecarExitRecovery(error: unknown, context: CliErrorContext) {
if (
context.commandPath?.[0] !== 'computer' ||
errorCode(error) !== 'accessibility_error' ||
!(error instanceof Error) ||
!error.message.startsWith('computer sidecar exited with code')
) {
return undefined
}
return computerUseErrorRecoveryData('accessibility_error', error.message)
}
function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
const sidecarExit = computerSidecarExitRecovery(error, context)
if (sidecarExit) {
return sidecarExit
}
const selector = contextRecovery(errorCode(error), context)
// Why: error-specific recovery must win over the generic computer fallback.
if (error instanceof RuntimeClientError && error.data !== undefined) {
+35
View File
@@ -217,6 +217,41 @@ describe('CLI error recovery', () => {
expect(output).toContain('Fix the command flags or RPC params')
})
it('tells an older server to reprovision when its computer sidecar is missing', () => {
// An older orcad answers with the generic provider steps, which never name the server install.
const error = new RuntimeRpcFailureError({
id: 'req_sidecar_exit',
ok: false,
error: {
code: 'accessibility_error',
message: 'computer sidecar exited with code 1',
data: {
nextSteps: [
'Run `orca computer capabilities --json` to confirm the provider is available before retrying.'
]
}
},
_meta: { runtimeId: 'runtime_orcad' }
})
const output = formatCliError(error, { commandPath: ['computer', 'list-apps'] })
expect(output).toContain('update or reprovision the server')
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
reportCliError(error, true, { commandPath: ['computer', 'list-apps'] })
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
error: {
code: 'accessibility_error',
data: {
nextSteps: expect.arrayContaining([
expect.stringContaining('update or reprovision the server')
])
}
}
})
log.mockRestore()
})
it('does not replace mutation recovery with generic runtime startup advice', () => {
const error = new RuntimeClientError(
'runtime_unavailable',
@@ -11,6 +11,13 @@ describe('computerProviderUnavailableMessage', () => {
)
})
it('names the runtime file a Linux or Windows install is missing', () => {
expect(computerProviderUnavailableMessage('linux')).toContain('computer-use-linux/runtime.py')
expect(computerProviderUnavailableMessage('win32')).toContain(
'computer-use-windows/runtime.ps1'
)
})
it('keeps unsupported platforms explicit', () => {
expect(computerProviderUnavailableMessage('freebsd')).toBe(
'computer-use has no native provider for freebsd'
@@ -6,7 +6,9 @@ export function computerProviderUnavailableMessage(platform: NodeJS.Platform): s
].join(' ')
}
if (platform === 'linux' || platform === 'win32') {
return `computer-use has no native provider for ${platform}; the platform runtime file was not found`
const runtime =
platform === 'linux' ? 'computer-use-linux/runtime.py' : 'computer-use-windows/runtime.ps1'
return `Computer use isn't available on this host: its Orca install has no ${runtime}. On an Orca server installed before computer use shipped with it, update or reprovision the server.`
}
return `computer-use has no native provider for ${platform}`
}
@@ -18,4 +18,18 @@ describe('mapBridgeError', () => {
expect(mapBridgeError('unknown element_index').code).toBe('element_not_found')
expect(mapBridgeError('element metadata unavailable').code).toBe('accessibility_error')
})
it('names the python3 that could not load PyGObject', () => {
const error = mapBridgeError(
"PyGObject unavailable in interpreter: /home/me/.pyenv/shims/python3\nNo module named 'gi'"
)
expect(error.code).toBe('unsupported_capability')
expect(error.message).toContain('(/home/me/.pyenv/shims/python3)')
})
it('says computer use is unavailable when no python3 is on PATH', () => {
const error = mapBridgeError('spawn python3 ENOENT')
expect(error.code).toBe('unsupported_capability')
expect(error.message).toContain("Computer use isn't available on this host")
})
})
@@ -195,9 +195,18 @@ export function mapBridgeError(message: string): RuntimeClientError {
return new RuntimeClientError('invalid_argument', text)
}
if (/ModuleNotFoundError: No module named 'gi'|PyGObject|python3-gi/i.test(text)) {
const interpreter = /PyGObject unavailable in interpreter: (.+)/.exec(text)?.[1]?.trim()
return new RuntimeClientError(
'unsupported_capability',
'Linux Computer Use requires python3-gi and AT-SPI packages. Install python3-gi gir1.2-atspi-2.0 at-spi2-core, then retry.'
interpreter
? `Linux Computer Use requires python3-gi and AT-SPI packages, which the python3 it ran (${interpreter}) cannot load. Install python3-gi gir1.2-atspi-2.0 at-spi2-core for that interpreter, or put a python3 that has them (usually /usr/bin/python3) first on PATH, then retry.`
: 'Linux Computer Use requires python3-gi and AT-SPI packages. Install python3-gi gir1.2-atspi-2.0 at-spi2-core, then retry.'
)
}
if (/spawn python3 ENOENT/.test(text)) {
return new RuntimeClientError(
'unsupported_capability',
"Computer use isn't available on this host: Linux Computer Use runs python3, and none is on PATH. Install python3 with python3-gi gir1.2-atspi-2.0 at-spi2-core, then retry."
)
}
if (/not a valid secondary action|action.*not supported/i.test(text)) {
@@ -0,0 +1,26 @@
import type * as NodeFs from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { existsSyncMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn((_path: string) => false) }))
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<typeof NodeFs>()),
existsSync: existsSyncMock
}))
import { resolveDesktopScriptProviderPath } from './desktop-script-provider-paths'
describe('resolveDesktopScriptProviderPath', () => {
afterEach(() => {
existsSyncMock.mockReset()
})
it('finds the runtime orcad ships beside its sidecar when there is no resourcesPath', () => {
// orcad's sidecar bundle is flat at the install root, so its runtimes sit beside it.
const bundled = join(__dirname, 'computer-use-linux', 'runtime.py')
existsSyncMock.mockImplementation((path) => path === bundled)
expect(resolveDesktopScriptProviderPath('linux')).toBe(bundled)
})
})
@@ -29,11 +29,13 @@ export function resolveDesktopScriptProviderPath(
const sourceDirectory =
platform === 'windows' ? 'native/computer-use-windows' : 'native/computer-use-linux'
const packaged = [join(process.resourcesPath ?? '', directory, filename)]
// orcad has no resourcesPath; its bundled sidecar (this module's __dirname) holds the runtimes.
const bundled = join(__dirname, directory, filename)
const dev = [
join(process.cwd(), sourceDirectory, filename),
resolve(__dirname, '../../', sourceDirectory, filename)
]
const candidates = process.resourcesPath ? [...packaged, ...dev] : dev
const candidates = process.resourcesPath ? [...packaged, bundled, ...dev] : [bundled, ...dev]
return candidates.find((candidate) => candidate && existsSync(candidate)) ?? null
}
+50 -3
View File
@@ -1,4 +1,6 @@
import type * as NodeFs from 'node:fs'
import { EventEmitter } from 'node:events'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS,
@@ -11,14 +13,31 @@ import {
resetComputerSidecarForTest
} from './sidecar-client'
const { forkMock } = vi.hoisted(() => ({
forkMock: vi.fn()
}))
type TestAppEnvironment = { getAppPath(): string; isPackaged(): boolean }
const { forkMock, existsSyncMock, appEnvironment } = vi.hoisted(() => {
const environment: { current: TestAppEnvironment | null } = { current: null }
return {
forkMock: vi.fn(),
existsSyncMock: vi.fn((_path: string) => true),
appEnvironment: environment
}
})
vi.mock('child_process', () => ({
fork: forkMock
}))
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<typeof NodeFs>()),
existsSync: existsSyncMock
}))
vi.mock('../../shared/app-environment', () => ({
hasAppEnvironment: () => appEnvironment.current !== null,
getAppEnvironment: () => appEnvironment.current
}))
type SentRequest = {
id: number
method: string
@@ -72,9 +91,37 @@ describe('computer sidecar client', () => {
afterEach(() => {
resetComputerSidecarForTest()
forkMock.mockReset()
existsSyncMock.mockImplementation(() => true)
appEnvironment.current = null
vi.useRealTimers()
})
it('forks the sidecar orcad ships beside orcad.js, not a desktop out/main path', async () => {
appEnvironment.current = { getAppPath: () => '/srv/orcad', isPackaged: () => true }
existsSyncMock.mockImplementation((path) => path === join('/srv/orcad', 'computer-sidecar.js'))
const call = callComputerSidecarCapabilities()
children[0]!.emit('message', { id: children[0]!.sent[0]!.id, ok: true, result: {} })
await expect(call).resolves.toEqual({})
expect(forkMock).toHaveBeenCalledWith(
join('/srv/orcad', 'computer-sidecar.js'),
[],
expect.anything()
)
})
it('says computer use is unavailable when an older server install has no sidecar', async () => {
appEnvironment.current = { getAppPath: () => '/srv/orcad', isPackaged: () => true }
existsSyncMock.mockImplementation(() => false)
await expect(callComputerSidecarCapabilities()).rejects.toMatchObject({
code: 'unsupported_capability',
message: expect.stringContaining("Computer use isn't available on this host")
})
expect(forkMock).not.toHaveBeenCalled()
})
it('ignores stale child exit and error after a replacement sidecar starts', async () => {
const firstCall = callComputerSidecarCapabilities()
const firstRejection = expect(firstCall).rejects.toThrow(
+15 -22
View File
@@ -1,6 +1,5 @@
import { fork, type ChildProcess } from 'node:child_process'
import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment'
import { join } from 'node:path'
import { existsSync } from 'node:fs'
import type {
ComputerActionResult,
ComputerListAppsResult,
@@ -12,6 +11,10 @@ import { normalizeComputerActionResult } from './computer-action-verification-no
import { isComputerSidecarDiagnostic, logComputerDiagnostic } from './computer-sidecar-diagnostics'
import { validateComputerSidecarPasteText } from './computer-sidecar-paste-validation'
import { RuntimeClientError } from './runtime-client-error'
import { getForkedChildEntryPath } from '../forked-child-entry-path'
// orcad ships it beside orcad.js (ORCAD_CHILD_ENTRY_POINTS); the desktop under out/main.
export const COMPUTER_SIDECAR_ENTRY_FILENAME = 'computer-sidecar.js'
type ComputerSidecarMethod =
| 'capabilities'
@@ -94,30 +97,13 @@ export function resetComputerSidecarForTest(): void {
function getComputerSidecar(): ComputerSidecarProcess {
if (!sidecar) {
sidecar = new ComputerSidecarProcess(getComputerSidecarEntryPath())
sidecar = new ComputerSidecarProcess(getForkedChildEntryPath(COMPUTER_SIDECAR_ENTRY_FILENAME))
}
return sidecar
}
function getComputerSidecarEntryPath(): string {
const app = loadElectronApp()
const appPath = app?.getAppPath() ?? process.cwd()
const isPackaged = app?.isPackaged ?? false
// Why: packaged sidecars must be forked from app.asar.unpacked because
// ELECTRON_RUN_AS_NODE bypasses Electron's asar require integration.
const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
return join(basePath, 'out', 'main', 'computer-sidecar.js')
}
// Why the port and not require('electron'): the literal text fails the plain-Node
// entry guard even inside a try/catch, and hasAppEnvironment() gives the same
// "no app root here" answer without it.
function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null {
if (!hasAppEnvironment()) {
return null
}
const environment = getAppEnvironment()
return { getAppPath: () => environment.getAppPath(), isPackaged: environment.isPackaged() }
export function computerSidecarMissingMessage(entryPath: string): string {
return `Computer use isn't available on this host: its Orca install has no computer-use sidecar (${entryPath}). On an Orca server installed before computer use shipped with it, update or reprovision the server.`
}
class ComputerSidecarProcess {
@@ -211,6 +197,13 @@ class ComputerSidecarProcess {
}
this.cleanupActiveChildListeners()
this.child = null
// Why before fork: a missing entry only surfaces as "exited with code 1", which names nothing.
if (!existsSync(this.entryPath)) {
throw new RuntimeClientError(
'unsupported_capability',
computerSidecarMissingMessage(this.entryPath)
)
}
const child = fork(this.entryPath, [], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
@@ -14,7 +14,7 @@ describe.skipIf(process.platform === 'win32')('real sidecar exit reaping', () =>
directory = await mkdtemp(join(tmpdir(), 'orca-sidecar-reaping-'))
entry = join(directory, 'sidecar.cjs')
await build({
entryPoints: [join(__dirname, 'sidecar-entry.ts')],
entryPoints: [join(__dirname, 'computer-sidecar.ts')],
outfile: entry,
bundle: true,
platform: 'node',
@@ -92,6 +92,13 @@ export function computerUseErrorRecoveryData(
'If the message mentions the payload cap, target a smaller/current window or use `--no-screenshot`.'
)
case 'accessibility_error':
// Why: an Orca server installed before it bundled computer use reports only this exit (#31777).
if (message?.startsWith('computer sidecar exited with code')) {
return recoverWith(
'If this targets an Orca server (`orca serve`, SSH or WSL), its install may not include computer use: update or reprovision the server, then retry.',
'Run `orca computer capabilities --json` to confirm the provider is available before retrying.'
)
}
return recoverWith(
'Run `orca computer capabilities --json` to confirm the provider is available before retrying.',
'If the message mentions permissions, run `orca computer permissions --id accessibility --json` and grant access.',
+10
View File
@@ -120,6 +120,13 @@ export const ORCAD_FOREIGN_SQLITE_READER_ENTRY = 'foreign-sqlite-reader-entry.js
export const ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY = 'port-scan-command-worker-entry.js'
// Equals AI_VAULT_SERVICE_ENTRY_FILENAME: the forked child that lists agent sessions.
export const ORCAD_SESSION_SCANNER_SERVICE_ENTRY = 'session-scanner-service-entry.js'
// Equals COMPUTER_SIDECAR_ENTRY_FILENAME, and the desktop's out/main entry name.
export const ORCAD_COMPUTER_SIDECAR_ENTRY = 'computer-sidecar.js'
/** Plain scripts, one copy for every target: each platform's sidecar runs only its own. */
export const ORCAD_COMPUTER_USE_RUNTIME_ARTIFACTS = [
'computer-use-linux/runtime.py',
'computer-use-windows/runtime.ps1'
] as const
// Kept here because build-orcad.mjs imports this manifest directly under Node type stripping.
export const ORCAD_RIPGREP_ARTIFACTS = [
@@ -248,6 +255,9 @@ export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [
{ filename: 'claude-profile-setup-worker-entry.js' },
// Dictation's worker thread; it loads the recognizer `orca serve` names (#31174).
{ filename: 'stt-worker.js' },
// `orca computer` forks it; it finds the platform runtimes below beside itself (#31777).
{ filename: ORCAD_COMPUTER_SIDECAR_ENTRY },
...ORCAD_COMPUTER_USE_RUNTIME_ARTIFACTS.map((filename) => ({ filename })),
// Target-specific even when the JavaScript bundle is shared across packaged slots.
{ filename: ORCAD_SERVER_TARGET_FILENAME },
// orcad never depends on a host runtime or host-installed native module.