Add native computer-use automation (#1683)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-11 14:20:08 -07:00
committed by GitHub
co-authored by Orca
parent 8f3c783767
commit 0f54103dda
68 changed files with 11518 additions and 16 deletions
+104
View File
@@ -0,0 +1,104 @@
name: Computer-use e2e
on:
pull_request:
paths:
- '.github/workflows/computer-e2e.yml'
- 'config/electron-builder.config.cjs'
- 'config/scripts/build-computer-macos.mjs'
- 'config/scripts/verify-computer-native.mjs'
- 'native/computer-use-macos/**'
- 'native/computer-use-linux/**'
- 'native/computer-use-windows/**'
- 'src/cli/**'
- 'src/main/computer/**'
- 'src/main/runtime/rpc/methods/computer.ts'
- 'src/shared/runtime-types.ts'
- 'tests/e2e/computer-linux.e2e.ts'
- 'tests/e2e/computer-mac.e2e.ts'
- 'tests/e2e/computer-windows.e2e.ts'
- 'tests/e2e/helpers/computer-driver.ts'
workflow_dispatch:
schedule:
- cron: '0 6 * * *'
jobs:
native-smoke:
if: github.event_name == 'pull_request'
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: package.json
- uses: pnpm/action-setup@v4
with:
run_install: false
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool
- run: pnpm install --frozen-lockfile
- run: pnpm verify:computer-native
- run: pnpm build:cli
mac:
# macOS Accessibility and Screen Recording require user-granted TCC entries.
# Keep this on manual/scheduled permission-bearing runners instead of PR CI.
if: github.event_name != 'pull_request'
runs-on: macos-14
env:
ORCA_COMPUTER_E2E: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: package.json
- uses: pnpm/action-setup@v4
with:
run_install: false
- run: pnpm install --frozen-lockfile
- run: pnpm build:computer-macos
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm test:e2e:computer -- --reporter=verbose tests/e2e/computer-mac.e2e.ts
linux:
if: github.event_name != 'pull_request'
runs-on: ubuntu-22.04
env:
ORCA_COMPUTER_E2E: '1'
ACCESSIBILITY_ENABLED: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: package.json
- uses: pnpm/action-setup@v4
with:
run_install: false
- run: sudo apt-get update && sudo apt-get install -y build-essential python3 python3-gi gir1.2-atspi-2.0 gedit at-spi2-core xvfb xclip xdotool
- run: pnpm install --frozen-lockfile
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer -- --reporter=verbose tests/e2e/computer-linux.e2e.ts
windows:
if: github.event_name != 'pull_request'
runs-on: windows-latest
env:
ORCA_COMPUTER_E2E: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: package.json
- uses: pnpm/action-setup@v4
with:
run_install: false
- run: pnpm install --frozen-lockfile
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm test:e2e:computer -- --reporter=verbose tests/e2e/computer-windows.e2e.ts
+1
View File
@@ -24,6 +24,7 @@ dist-electron/
out/
/build/
release/
native/**/.build/
# pnpm
.pnpm-store/
+91 -2
View File
@@ -1,5 +1,6 @@
const { chmodSync, existsSync, readdirSync } = require('node:fs')
const { join } = require('node:path')
const { execFileSync } = require('node:child_process')
const { join, resolve } = require('node:path')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
@@ -35,6 +36,7 @@ module.exports = {
'out/cli/**',
'out/shared/**',
'out/main/daemon-entry.js',
'out/main/computer-sidecar.js',
'out/main/chunks/**',
'resources/**',
'node_modules/zod/**'
@@ -42,7 +44,12 @@ module.exports = {
afterPack: async (context) => {
const resourcesDir =
context.electronPlatformName === 'darwin'
? join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources')
? join(
context.appOutDir,
`${context.packager.appInfo.productFilename}.app`,
'Contents',
'Resources'
)
: join(context.appOutDir, 'resources')
if (!existsSync(resourcesDir)) {
return
@@ -56,6 +63,9 @@ module.exports = {
// the copied binary to be executable in packaged apps.
chmodSync(join(resourcesDir, filename), 0o755)
}
if (context.electronPlatformName === 'darwin') {
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
}
},
win: {
executableName: 'Orca',
@@ -67,6 +77,10 @@ module.exports = {
{
from: 'node_modules/agent-browser/bin/agent-browser-win32-x64.exe',
to: 'agent-browser-win32-x64.exe'
},
{
from: 'native/computer-use-windows/runtime.ps1',
to: 'computer-use-windows/runtime.ps1'
}
]
},
@@ -115,6 +129,10 @@ module.exports = {
{
from: 'node_modules/agent-browser/bin/agent-browser-darwin-${arch}',
to: 'agent-browser-darwin-${arch}'
},
{
from: 'native/computer-use-macos/.build/release/Orca Computer Use.app',
to: 'Orca Computer Use.app'
}
],
target: [
@@ -134,6 +152,9 @@ module.exports = {
dmg: {
artifactName: 'orca-macos-${arch}.${ext}'
},
deb: {
depends: ['python3', 'python3-gi', 'gir1.2-atspi-2.0', 'at-spi2-core', 'xdotool', 'xclip']
},
linux: {
extraResources: [
{
@@ -143,6 +164,10 @@ module.exports = {
{
from: 'node_modules/agent-browser/bin/agent-browser-linux-${arch}',
to: 'agent-browser-linux-${arch}'
},
{
from: 'native/computer-use-linux/runtime.py',
to: 'computer-use-linux/runtime.py'
}
],
target: ['AppImage', 'deb'],
@@ -165,3 +190,67 @@ module.exports = {
releaseType: 'release'
}
}
async function signMacComputerUseHelper(helperAppPath, packager) {
if (!existsSync(helperAppPath)) {
if (isMacRelease) {
throw new Error(`Missing Orca Computer Use helper app at ${helperAppPath}`)
}
return
}
const codeSigningInfo =
isMacRelease && process.env.CSC_LINK && packager?.codeSigningInfo?.value
? await packager.codeSigningInfo.value
: null
const identity =
process.env.ORCA_COMPUTER_MACOS_SIGN_IDENTITY ??
process.env.CSC_NAME ??
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
(isMacRelease ? null : '-')
if (!identity) {
throw new Error('Missing signing identity for Orca Computer Use helper app')
}
// Why: TCC grants attach to this nested app's code identity. Sign it before
// the outer Orca.app is sealed so production builds preserve that identity.
execFileSync('codesign', codesignArgs(identity, helperAppPath), { stdio: 'inherit' })
execFileSync('codesign', ['--verify', '--deep', '--strict', helperAppPath], {
stdio: 'inherit'
})
}
function codesignArgs(identity, targetPath) {
const args = ['--force', '--deep', '--sign', identity]
if (isMacRelease) {
args.push(
'--options',
'runtime',
'--timestamp',
'--entitlements',
resolve(__dirname, '../resources/build/entitlements.computer-use.mac.plist')
)
}
args.push(targetPath)
return args
}
function findInstalledMacSigningIdentity(keychainFile) {
try {
const output = execFileSync(
'security',
['find-identity', '-v', '-p', 'codesigning', ...(keychainFile ? [keychainFile] : [])],
{
encoding: 'utf8'
}
)
const releaseMatch =
output.match(/"([^"]*Developer ID Application:[^"]+)"/) ??
output.match(/"([^"]*Apple Distribution:[^"]+)"/)
if (releaseMatch?.[1]) {
return releaseMatch[1]
}
if (!isMacRelease) {
return output.match(/"([^"]*Apple Development:[^"]+)"/)?.[1] ?? null
}
} catch {}
return null
}
+142
View File
@@ -0,0 +1,142 @@
import { spawnSync } from 'node:child_process'
import { chmodSync, copyFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const packagePath = path.join(repoRoot, 'native', 'computer-use-macos')
const binaryPath = path.join(packagePath, '.build', 'release', 'orca-computer-use-macos')
const appPath = path.join(packagePath, '.build', 'release', 'Orca Computer Use.app')
const appExecutablePath = path.join(appPath, 'Contents', 'MacOS', 'orca-computer-use-macos')
const appIconPath = path.join(appPath, 'Contents', 'Resources', 'AppIcon.icns')
const entitlementsPath = path.join(
repoRoot,
'resources',
'build',
'entitlements.computer-use.mac.plist'
)
const bundleId = process.env.ORCA_COMPUTER_MACOS_BUNDLE_ID ?? 'com.stablyai.orca.computer-use'
const displayName = 'Orca Computer Use'
const signingIdentity = resolveSigningIdentity()
const universalTriples = ['arm64-apple-macosx', 'x86_64-apple-macosx']
if (process.platform !== 'darwin') {
process.exit(0)
}
buildUniversalBinary()
chmodSync(binaryPath, 0o755)
createHelperApp()
function buildUniversalBinary() {
const builtBinaries = universalTriples.map((triple) => {
run('swift', ['build', '-c', 'release', '--package-path', packagePath, '--triple', triple])
return path.join(packagePath, '.build', triple, 'release', 'orca-computer-use-macos')
})
mkdirSync(path.dirname(binaryPath), { recursive: true })
run('lipo', ['-create', ...builtBinaries, '-output', binaryPath])
}
function createHelperApp() {
rmSync(appPath, { recursive: true, force: true })
mkdirSync(path.dirname(appExecutablePath), { recursive: true })
mkdirSync(path.join(appPath, 'Contents', 'Resources'), { recursive: true })
copyFileSync(binaryPath, appExecutablePath)
copyFileSync(path.join(repoRoot, 'resources', 'build', 'icon.icns'), appIconPath)
chmodSync(appExecutablePath, 0o755)
writeFileSync(path.join(appPath, 'Contents', 'Info.plist'), infoPlist(), 'utf8')
const signer = spawnSync('codesign', codesignArgs(signingIdentity, appPath), { stdio: 'inherit' })
if (signer.signal) {
process.kill(process.pid, signer.signal)
}
if (signer.status !== 0) {
process.exit(signer.status ?? 1)
}
}
function codesignArgs(identity, targetPath) {
const args = ['--force', '--deep', '--sign', identity]
if (process.env.ORCA_MAC_RELEASE === '1' && identity !== '-') {
args.push('--options', 'runtime', '--timestamp', '--entitlements', entitlementsPath)
}
args.push(targetPath)
return args
}
function resolveSigningIdentity() {
const explicitIdentity = process.env.ORCA_COMPUTER_MACOS_SIGN_IDENTITY ?? process.env.CSC_NAME
if (explicitIdentity) {
return explicitIdentity
}
const identities = spawnSync('security', ['find-identity', '-v', '-p', 'codesigning'], {
encoding: 'utf8'
})
if (identities.status !== 0 || !identities.stdout) {
return '-'
}
const developmentMatch = identities.stdout.match(/"([^"]*Apple Development:[^"]+)"/)
if (process.env.ORCA_MAC_RELEASE !== '1' && developmentMatch) {
return developmentMatch[1]
}
const releaseMatch =
identities.stdout.match(/"([^"]*Developer ID Application:[^"]+)"/) ??
identities.stdout.match(/"([^"]*Apple Distribution:[^"]+)"/)
return releaseMatch?.[1] ?? developmentMatch?.[1] ?? '-'
}
function run(command, args) {
const result = spawnSync(command, args, { stdio: 'inherit' })
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
function infoPlist() {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>orca-computer-use-macos</string>
<key>CFBundleIdentifier</key>
<string>${escapePlist(bundleId)}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleName</key>
<string>${escapePlist(displayName)}</string>
<key>CFBundleDisplayName</key>
<string>${escapePlist(displayName)}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>NSAccessibilityUsageDescription</key>
<string>Orca Computer Use needs Accessibility permission to read and interact with app interfaces when you ask Orca to use apps.</string>
<key>NSScreenCaptureUsageDescription</key>
<string>Orca Computer Use needs Screen Recording permission to capture app windows when you ask Orca to inspect your screen.</string>
</dict>
</plist>
`
}
function escapePlist(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;')
}
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env node
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
const repoRoot = resolve(import.meta.dirname, '..', '..')
const cliPath = resolve(repoRoot, 'out', 'cli', 'index.js')
const args = new Set(process.argv.slice(2))
const requestedApps = valueFlag('--apps')
const includeScreenshot = args.has('--screenshot')
const session = valueFlag('--session') ?? `computer-smoke-${process.pid}`
const preferredApps = (requestedApps ?? process.env.ORCA_COMPUTER_SMOKE_APPS ?? 'Finder,TextEdit,Spotify,Slack,Microsoft Edge')
.split(',')
.map((app) => app.trim())
.filter(Boolean)
if (!existsSync(cliPath)) {
fail(`Missing built CLI at ${cliPath}. Run pnpm build:cli first.`)
}
const list = unwrapResult(runCli(['computer', 'list-apps', '--json']))
const apps = Array.isArray(list.apps) ? list.apps : []
const availableNames = new Set(apps.map((app) => String(app.name ?? '').toLowerCase()))
const availableBundles = new Set(apps.map((app) => String(app.bundleId ?? '').toLowerCase()).filter(Boolean))
const targets = preferredApps.filter((app) => availableNames.has(app.toLowerCase()) || availableBundles.has(app.toLowerCase()))
console.log(`computer-use smoke: ${apps.length} apps listed`)
if (targets.length === 0) {
console.log(`computer-use smoke: no preferred apps are running (${preferredApps.join(', ')})`)
process.exit(0)
}
let failures = 0
for (const app of targets) {
const result = runCli([
'computer',
'get-app-state',
'--session',
session,
'--app',
app,
...(includeScreenshot ? [] : ['--no-screenshot']),
'--json'
], { allowFailure: true })
if (!result.ok) {
failures += 1
console.log(`computer-use smoke: ${app}: failed: ${result.error}`)
continue
}
const state = unwrapResult(result.value)
const snapshot = state.snapshot
const treeText = String(snapshot.treeText ?? '')
const lineCount = treeText.split('\n').filter(Boolean).length
const secondaryActions = (treeText.match(/Secondary Actions:/g) ?? []).length
const settable = (treeText.match(/\bsettable\b/g) ?? []).length
const screenshotState = state.screenshot ? `${state.screenshot.width}x${state.screenshot.height}` : 'missing'
console.log([
`computer-use smoke: ${snapshot.app.name}`,
`${snapshot.elementCount} elements`,
`${lineCount} lines`,
`${secondaryActions} secondary-action lines`,
`${settable} settable elements`,
`screenshot=${screenshotState}`
].join(' | '))
}
if (failures > 0) {
fail(`${failures} app snapshot smoke check(s) failed`)
}
function valueFlag(name) {
const index = process.argv.indexOf(name)
if (index === -1) {
return null
}
return process.argv[index + 1] ?? null
}
function runCli(cliArgs, options = {}) {
const child = spawnSync(process.execPath, [cliPath, ...cliArgs], {
cwd: repoRoot,
encoding: 'utf8',
env: {
...process.env,
ORCA_USER_DATA_PATH: process.env.ORCA_COMPUTER_SMOKE_USER_DATA_PATH ?? defaultDevUserDataPath()
}
})
if (child.status !== 0) {
const error = (child.stderr || child.stdout || `exit ${child.status}`).trim()
if (options.allowFailure) {
return { ok: false, error }
}
fail(error)
}
try {
return options.allowFailure
? { ok: true, value: JSON.parse(child.stdout) }
: JSON.parse(child.stdout)
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
fail(`Could not parse CLI JSON for ${cliArgs.join(' ')}: ${detail}\n${child.stdout}`)
}
}
function defaultDevUserDataPath() {
if (process.platform === 'darwin') {
return resolve(homedir(), 'Library', 'Application Support', 'orca-dev')
}
if (process.platform === 'win32') {
return resolve(process.env.APPDATA ?? resolve(homedir(), 'AppData', 'Roaming'), 'orca-dev')
}
return resolve(process.env.XDG_CONFIG_HOME ?? resolve(homedir(), '.config'), 'orca-dev')
}
function unwrapResult(value) {
if (value && typeof value === 'object' && 'result' in value) {
return value.result
}
return value
}
function fail(message) {
console.error(`computer-use smoke: ${message}`)
process.exit(1)
}
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
const repoRoot = resolve(import.meta.dirname, '..', '..')
const checks = [
{
name: 'macOS Swift renderer/provider tests',
command: 'swift',
args: ['test', '--package-path', 'native/computer-use-macos'],
enabled: process.platform === 'darwin'
},
{
name: 'Linux provider Python syntax',
command: 'python3',
args: ['-m', 'py_compile', 'native/computer-use-linux/runtime.py'],
enabled: true
},
{
name: 'Linux provider imports',
command: 'python3',
args: [
'-c',
[
'import importlib.util',
'spec=importlib.util.spec_from_file_location("orca_linux","native/computer-use-linux/runtime.py")',
'module=importlib.util.module_from_spec(spec)',
'spec.loader.exec_module(module)',
'print("import-ok")'
].join(';')
],
enabled: process.platform === 'linux'
},
{
name: 'Windows provider PowerShell parse',
command: process.platform === 'win32' ? 'powershell.exe' : 'pwsh',
args: [
'-NoLogo',
'-NoProfile',
'-Command',
[
'$errors=$null',
'$tokens=$null',
'[System.Management.Automation.Language.Parser]::ParseFile("native/computer-use-windows/runtime.ps1",[ref]$tokens,[ref]$errors) > $null',
'if ($errors.Count) { $errors | Format-List *; exit 1 }',
'"parse-ok"'
].join('; ')
],
enabled: true
},
{
name: 'Windows provider handshake',
run: verifyWindowsProviderHandshake,
enabled: process.platform === 'win32'
},
{
name: 'macOS helper app bundle and signature',
run: verifyMacOSHelperApp,
enabled: process.platform === 'darwin'
}
]
let failed = false
for (const check of checks) {
if (!check.enabled) {
console.log(`[computer-native] skip ${check.name}`)
continue
}
if (check.run) {
console.log(`[computer-native] ${check.name}`)
if (!check.run()) {
failed = true
}
continue
}
if (!hasCommand(check.command)) {
console.log(`[computer-native] skip ${check.name}: ${check.command} not found`)
continue
}
console.log(`[computer-native] ${check.name}`)
const result = spawnSync(check.command, check.args, {
cwd: repoRoot,
stdio: 'inherit'
})
if (result.status !== 0 || result.error) {
failed = true
}
}
if (failed) {
process.exit(1)
}
function hasCommand(command) {
if (process.platform === 'win32') {
const result = spawnSync('where.exe', [command], { stdio: 'ignore' })
return result.status === 0
}
if (existsSync(command)) {
return true
}
const result = spawnSync('/bin/sh', ['-lc', `command -v ${quoteShell(command)}`], {
stdio: 'ignore'
})
return result.status === 0
}
function verifyMacOSHelperApp() {
const appPath = join(
repoRoot,
'native',
'computer-use-macos',
'.build',
'release',
'Orca Computer Use.app'
)
if (!existsSync(appPath)) {
console.error(
`[computer-native] missing helper app at ${appPath}; run pnpm build:computer-macos`
)
return false
}
return run('codesign', ['--verify', '--deep', '--strict', appPath])
}
function verifyWindowsProviderHandshake() {
const dir = mkdtempSync(join(tmpdir(), 'orca-computer-use-verify-'))
const operationPath = join(dir, 'operation.json')
try {
writeFileSync(operationPath, JSON.stringify({ tool: 'handshake' }), { mode: 0o600 })
const result = spawnSync(
'powershell.exe',
[
'-NoLogo',
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
'native/computer-use-windows/runtime.ps1',
operationPath
],
{ cwd: repoRoot, encoding: 'utf8' }
)
if (result.status !== 0 || result.error) {
process.stderr.write(result.stderr ?? '')
return false
}
const response = JSON.parse(result.stdout.trim())
if (response.ok === true && response.capabilities?.protocolVersion === 1) {
console.log('[computer-native] windows-handshake-ok')
return true
}
console.error(`[computer-native] invalid Windows handshake response: ${result.stdout}`)
return false
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
return false
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
function run(command, args) {
if (!hasCommand(command)) {
console.log(`[computer-native] skip ${command}: command not found`)
return true
}
const result = spawnSync(command, args, {
cwd: repoRoot,
stdio: 'inherit'
})
return result.status === 0 && !result.error
}
function quoteShell(value) {
return `'${value.replaceAll("'", "'\\''")}'`
}
+2 -1
View File
@@ -41,7 +41,8 @@ export default defineConfig({
rollupOptions: {
input: {
index: resolve('src/main/index.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts')
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts')
}
}
},
+819
View File
@@ -0,0 +1,819 @@
#!/usr/bin/env python3
"""Orca Linux computer-use bridge.
The Node sidecar owns Orca's public API. This process is intentionally a small
AT-SPI adapter: read one JSON operation file, execute it in the user's desktop
session, and print one JSON response.
"""
import base64
import json
import math
import os
import re
import shutil
import subprocess
import sys
import time
import uuid
from dataclasses import dataclass
import gi
gi.require_version("Atspi", "2.0")
try:
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk
except (ImportError, ValueError):
Gdk = None
from gi.repository import Atspi
MAX_NODES = 1200
MAX_DEPTH = 64
TEXT_LIMIT = 500
BLOCKED_APP_FRAGMENTS = (
"1password",
"bitwarden",
"dashlane",
"lastpass",
"nordpass",
"proton pass",
)
@dataclass
class Rect:
x: float
y: float
width: float
height: float
def to_json(self):
return {"x": self.x, "y": self.y, "width": self.width, "height": self.height}
def attempt(fn, fallback=None):
try:
value = fn()
return fallback if value is None else value
except Exception:
return fallback
def ensure_desktop_bus():
missing = [name for name in ("XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS") if not os.environ.get(name)]
if missing:
raise RuntimeError("Linux computer use requires an active desktop session; missing " + ", ".join(missing))
def desktop_root():
return Atspi.get_desktop(0)
def children(node):
count = int(attempt(node.get_child_count, 0) or 0)
for index in range(count):
child = attempt(lambda i=index: node.get_child_at_index(i))
if child is not None:
yield index, child
def text_attr(node, getter):
return str(attempt(getter, "") or "")
def name_of(node):
return text_attr(node, node.get_name)
def role_of(node):
return text_attr(node, node.get_role_name)
def pid_of(node):
return int(attempt(node.get_process_id, 0) or 0)
def has_state(node, state):
state_set = attempt(node.get_state_set)
return bool(state_set is not None and attempt(lambda: state_set.contains(state), False))
def screen_rect(node):
component = attempt(node.get_component_iface)
if component is None:
return None
rect = attempt(lambda: Atspi.Component.get_extents(component, Atspi.CoordType.SCREEN))
if rect is None or rect.width <= 0 or rect.height <= 0:
return None
return Rect(float(rect.x), float(rect.y), float(rect.width), float(rect.height))
def relative_rect(node, window_rect):
rect = screen_rect(node)
if rect is None or window_rect is None:
return rect
return Rect(rect.x - window_rect.x, rect.y - window_rect.y, rect.width, rect.height)
def desktop_apps():
for _, app in children(desktop_root()):
if name_of(app):
yield app
def windows_for(app):
result = []
for index, child in children(app):
role = role_of(child).lower()
rect = screen_rect(child)
if rect is not None or role in {"frame", "window", "dialog", "alert"}:
result.append((index, child))
return result
def choose_window(app, window_id=None, window_index=None):
windows = windows_for(app)
if not windows:
raise RuntimeError("No top-level AT-SPI window is available for " + name_of(app))
target = window_id if window_id is not None else window_index
if target is not None:
for item in windows:
if item[0] == int(target):
return item
raise RuntimeError(f'windowNotFound("{target}")')
for item in windows:
if has_state(item[1], Atspi.StateType.ACTIVE):
return item
for item in windows:
if has_state(item[1], Atspi.StateType.SHOWING):
return item
return windows[0]
def restore_window(app):
pid = pid_of(app)
if not pid or not shutil.which("xdotool"):
return
subprocess.run(
["xdotool", "search", "--pid", str(pid), "windowactivate", "--sync"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def require_keyboard_focus(window, operation):
if operation.get("restoreWindow") or has_state(window, Atspi.StateType.ACTIVE):
return
raise RuntimeError("window_not_focused: keyboard input requires the target window to be focused; retry with --restore-window")
def app_matches(app, query):
needle = str(query or "").strip().lower()
if not needle:
return False
if needle.startswith("pid:"):
return str(pid_of(app)) == needle[4:]
if needle.isdigit() and pid_of(app) == int(needle):
return True
haystacks = [name_of(app).lower()] + [name_of(window).lower() for _, window in windows_for(app)]
return any(value == needle or needle in value for value in haystacks)
def find_app(query):
for app in desktop_apps():
if app_matches(app, query):
reject_blocked_app(app)
return app
raise RuntimeError(f'appNotFound("{query}")')
def reject_blocked_app(app):
haystacks = [name_of(app).lower()] + [name_of(window).lower() for _, window in windows_for(app)]
if any(fragment in value for fragment in BLOCKED_APP_FRAGMENTS for value in haystacks):
raise RuntimeError(f'appBlocked("{name_of(app)}")')
def action_labels(node):
labels = []
count = int(attempt(node.get_n_actions, 0) or 0)
for index in range(count):
label = str(attempt(lambda i=index: node.get_action_name(i), "") or "")
description = str(attempt(lambda i=index: node.get_action_description(i), "") or "")
value = label or description
if value and value not in labels:
labels.append(value)
return labels
def meaningful_actions(actions):
noisy = {
"click",
"press",
"show default ui",
"show alternate ui",
"show menu",
"scroll to visible",
"raise",
}
return [action for action in actions if action.strip().lower() not in noisy]
def display_action(action):
value = str(action or "").strip()
if not value:
return value
return " ".join(value.replace("_", " ").split())
def sanitize_text(value):
return " ".join(str(value or "").replace("\r", " ").replace("\n", " ").split())
def formatted_value(role_key, title, value):
clean = sanitize_text(value)
if not clean or clean == title:
return ""
if role_key in {"label", "static", "static text", "text", "entry", "text entry"}:
return " " + clean
return ", Value: " + clean
def suppress_children(role_key, title, value, summary):
has_compact_label = bool(title or sanitize_text(value) or sanitize_text(summary))
return has_compact_label and role_key in {
"button",
"check box",
"checkbox",
"combo box",
"heading",
"link",
"menu item",
"page tab",
"push button",
"radio button",
}
def string_value(node):
if is_secure_node(node):
return "[redacted]"
if bool(attempt(node.is_text, False)):
iface = attempt(node.get_text_iface)
count = int(attempt(lambda: Atspi.Text.get_character_count(iface), 0) or 0)
if iface is not None and count > 0:
value = str(attempt(lambda: Atspi.Text.get_text(iface, 0, min(count, TEXT_LIMIT)), "") or "")
return value + ("..." if count > TEXT_LIMIT else "")
value_iface = attempt(node.get_value_iface)
if value_iface is not None:
current = attempt(lambda: Atspi.Value.get_current_value(value_iface))
if current is not None:
return str(current)
return ""
def is_secure_node(node):
role = role_of(node).lower()
label = " ".join([role, name_of(node).lower(), accessible_id(node).lower()])
if any(term in label for term in ("password", "passcode", "pin", "secret", "one-time code")):
return True
state_set = attempt(node.get_state_set)
protected_state = getattr(Atspi.StateType, "PROTECTED", None)
if state_set is not None and protected_state is not None and attempt(lambda: state_set.contains(protected_state), False):
return True
return False
def accessible_id(node):
return str(attempt(node.get_accessible_id, "") or "")
def record(node, index, path, window_rect):
role = role_of(node)
rect = relative_rect(node, window_rect)
return {
"index": index,
"runtimeId": path,
"automationId": accessible_id(node),
"name": name_of(node),
"controlType": role,
"localizedControlType": role,
"className": str(attempt(node.get_toolkit_name, "") or ""),
"value": string_value(node),
"nativeWindowHandle": 0,
"frame": rect.to_json() if rect else None,
"actions": action_labels(node),
}
def render_accessibility_tree(root, window_rect, root_path):
records = []
lines = []
truncation = {"truncated": False, "maxNodes": MAX_NODES, "maxDepth": MAX_DEPTH, "maxDepthReached": False}
def text_snippets(node, limit=6, max_depth=3):
values = []
seen = set()
def collect(candidate, depth):
if len(values) >= limit or depth > max_depth:
return
role = role_of(candidate).lower()
if role in {"label", "static", "static text", "text", "link"}:
for raw in (name_of(candidate), string_value(candidate)):
value = " ".join(str(raw or "").split())
if value and value not in seen:
seen.add(value)
values.append(value[:80])
if len(values) >= limit:
return
for _, child in children(candidate):
collect(child, depth + 1)
if len(values) >= limit:
return
collect(node, 0)
return values
def is_plain_text_subtree(node, max_depth=4):
saw_text = False
allowed = {"panel", "filler", "unknown", "section", "label", "static", "static text", "text", "link", "image"}
def visit(candidate, depth):
nonlocal saw_text
if depth > max_depth:
return False
role = role_of(candidate).lower()
if role not in allowed:
return False
if role in {"label", "static", "static text", "text", "link"}:
saw_text = True
if meaningful_actions(action_labels(candidate)):
return False
return all(visit(child, depth + 1) for _, child in children(candidate))
return visit(node, 0) and saw_text
def should_elide(item, child_count, summary):
role = (item["controlType"] or "").lower()
has_text = bool(item["name"] or item["automationId"] or item["value"])
return role in {"panel", "filler", "unknown", "section"} and not has_text and not meaningful_actions(item["actions"]) and summary is None and child_count <= 1
def walk(node, depth, path):
if len(records) >= MAX_NODES or depth > MAX_DEPTH:
truncation["truncated"] = True
if depth > MAX_DEPTH:
truncation["maxDepthReached"] = True
return
item = record(node, len(records), path, window_rect)
child_items = list(children(node))
role_key = (item["controlType"] or "").lower()
summary_values = text_snippets(node, limit=8, max_depth=4)
generic_summary = " ".join(summary_values) if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"] and len(summary_values) >= 2 and is_plain_text_subtree(node) else None
if should_elide(item, len(child_items), generic_summary):
for child_index, child in child_items:
walk(child, depth, path + [child_index])
return
records.append(item)
title = item["name"] or item["automationId"] or ""
role_label = item["localizedControlType"] or item["controlType"]
line = f'{item["index"]} {role_label} {sanitize_text(title)}'.rstrip()
line += formatted_value(role_key, title, item["value"])
if generic_summary and generic_summary != title:
line += ", Text: " + sanitize_text(generic_summary)
elif role_key in {"row", "table row", "list item"}:
row_summary = " ".join(text_snippets(node))
if row_summary and row_summary != title:
line += ", Text: " + sanitize_text(row_summary)
filtered_actions = meaningful_actions(item["actions"])
if filtered_actions:
line += ", Secondary Actions: " + ", ".join(display_action(action) for action in filtered_actions)
lines.append(("\t" * depth) + line)
if generic_summary or suppress_children(role_key, title, item["value"], generic_summary):
return
for child_index, child in child_items:
walk(child, depth + 1, path + [child_index])
walk(root, 0, root_path)
return records, lines, truncation
def capture_png(rect):
if Gdk is None or rect is None or os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland":
return None
screen = Gdk.Screen.get_default()
root = screen.get_root_window() if screen else None
if root is None:
return None
pixbuf = Gdk.pixbuf_get_from_window(root, round(rect.x), round(rect.y), max(1, round(rect.width)), max(1, round(rect.height)))
if pixbuf is None:
return None
ok, data = pixbuf.save_to_bufferv("png", [], [])
return base64.b64encode(bytes(data)).decode("ascii") if ok else None
def first_descendant(root, predicate):
if predicate(root):
return root
for _, child in children(root):
found = first_descendant(child, predicate)
if found is not None:
return found
return None
def focused_summary(window):
node = first_descendant(window, lambda candidate: has_state(candidate, Atspi.StateType.FOCUSED))
if node is None:
return None
return (role_of(node) + " " + name_of(node)).strip()
def selected_text(window):
node = first_descendant(window, lambda candidate: has_state(candidate, Atspi.StateType.FOCUSED) and bool(attempt(candidate.is_text, False)))
iface = attempt(node.get_text_iface) if node is not None else None
selections = attempt(lambda: Atspi.Text.get_text_selections(iface), [])
if not selections:
return None
selection = selections[0]
return Atspi.Text.get_text(iface, selection.start_offset, selection.end_offset)
def app_json(app):
return {"name": name_of(app), "bundleIdentifier": name_of(app), "pid": pid_of(app)}
def window_json(app, index, window):
bounds = screen_rect(window)
showing = has_state(window, Atspi.StateType.SHOWING)
return {
"index": index,
"app": app_json(app),
"id": index,
"title": name_of(window),
"x": round(bounds.x) if bounds else None,
"y": round(bounds.y) if bounds else None,
"width": max(0, round(bounds.width if bounds else 0)),
"height": max(0, round(bounds.height if bounds else 0)),
"isMinimized": not showing,
"isOffscreen": not showing,
"screenIndex": None,
"platform": {"backend": "at-spi", "runtimeId": [index], "role": role_of(window)},
}
def make_snapshot(query, include_screenshot, window_id=None, window_index=None, restore=False):
app = find_app(query)
if restore:
restore_window(app)
window_index, window = choose_window(app, window_id, window_index)
bounds = screen_rect(window)
records, lines, truncation = render_accessibility_tree(window, bounds, [window_index])
return {
"snapshotId": str(uuid.uuid4()),
"app": app_json(app),
"windowTitle": name_of(window),
"windowId": window_index,
"windowBounds": bounds.to_json() if bounds else None,
"screenshotPngBase64": capture_png(bounds) if include_screenshot else None,
"coordinateSpace": "window",
"truncation": truncation,
"treeLines": lines,
"focusedSummary": focused_summary(window),
"selectedText": selected_text(window),
"elements": records,
}
def list_apps_response():
apps = []
for app in sorted(desktop_apps(), key=lambda value: (name_of(value).lower(), pid_of(value))):
if windows_for(app):
apps.append({"name": name_of(app), "bundleIdentifier": name_of(app), "pid": pid_of(app)})
return apps
def list_windows_response(query):
app = find_app(query)
return {"app": app_json(app), "windows": [window_json(app, index, window) for index, window in windows_for(app)]}
def handshake_response():
is_wayland = os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland"
has_hotkey = shutil.which("xdotool") is not None and not is_wayland
has_clipboard = any(shutil.which(command) for command in ("wl-copy", "xclip", "xsel"))
has_screenshot = Gdk is not None and not is_wayland
return {
"platform": "linux",
"provider": "orca-computer-use-linux",
"providerVersion": "1.0.0",
"protocolVersion": 1,
"supports": {
"apps": {"list": True, "bundleIds": False, "pids": True},
"windows": {"list": True, "targetById": True, "targetByIndex": True, "focus": False, "moveResize": False},
"observation": {"screenshot": has_screenshot, "annotatedScreenshot": False, "elementFrames": True, "ocr": False},
"actions": {
"click": True,
"typeText": True,
"pressKey": True,
"hotkey": has_hotkey,
"pasteText": has_clipboard,
"scroll": True,
"drag": True,
"setValue": True,
"performAction": True,
},
"surfaces": {"menus": False, "dialogs": False, "dock": False, "menubar": False},
},
}
def find_by_path(app, path):
node = app
for index in path or []:
node = dict(children(node)).get(int(index))
if node is None:
return None
return node
def all_nodes(root):
result = []
def walk(node):
if len(result) >= MAX_NODES:
return
result.append(node)
for _, child in children(node):
walk(child)
walk(root)
return result
def find_element(app, saved):
if not saved:
return None
by_path = find_by_path(app, saved.get("runtimeId"))
if by_path is not None and same_element_signature(by_path, saved):
return by_path
return None
def same_element_signature(node, saved):
if role_of(node) != str(saved.get("controlType") or ""):
return False
if name_of(node) != str(saved.get("name") or ""):
return False
if accessible_id(node) != str(saved.get("automationId") or ""):
return False
saved_actions = [str(action) for action in saved.get("actions") or []]
return action_labels(node) == saved_actions
def preferred_action(node):
if node is None:
return None
priority = {"click", "press", "activate", "invoke", "select", "toggle", "open"}
fallback = None
for index in range(int(attempt(node.get_n_actions, 0) or 0)):
label = str(attempt(lambda i=index: node.get_action_name(i), "") or "").lower()
if label in priority:
return index
if fallback is None and any(term in label for term in ("click", "press", "activate")):
fallback = index
return fallback
def perform_action(node, index):
return bool(index is not None and attempt(lambda: node.do_action(int(index)), False))
def screen_point(window_rect, saved_element=None, x=None, y=None, node=None):
rect = screen_rect(node) if node is not None else None
if rect is not None:
return rect.x + rect.width / 2, rect.y + rect.height / 2
if saved_element is not None:
raise RuntimeError("stale element frame; run get-app-state again and use a fresh element index")
if window_rect is None or x is None or y is None:
raise RuntimeError("coordinate action requires a visible window and coordinates")
return window_rect.x + float(x), window_rect.y + float(y)
def click_at(x, y, button, count):
button = (button or "left").lower()
down, up = {"right": ("b3p", "b3r"), "middle": ("b2p", "b2r")}.get(button, ("b1p", "b1r"))
for _ in range(max(1, int(count or 1))):
Atspi.generate_mouse_event(round(x), round(y), "abs")
Atspi.generate_mouse_event(round(x), round(y), down)
time.sleep(0.03)
Atspi.generate_mouse_event(round(x), round(y), up)
def scroll_at(x, y, direction, pages):
down, up = {
"up": ("b4p", "b4r"),
"down": ("b5p", "b5r"),
"left": ("b6p", "b6r"),
"right": ("b7p", "b7r"),
}.get(str(direction or "down").lower(), ("b5p", "b5r"))
for _ in range(max(1, math.ceil(float(pages or 1)))):
Atspi.generate_mouse_event(round(x), round(y), "abs")
Atspi.generate_mouse_event(round(x), round(y), down)
time.sleep(0.03)
Atspi.generate_mouse_event(round(x), round(y), up)
def drag_between(start, end):
Atspi.generate_mouse_event(round(start[0]), round(start[1]), "abs")
Atspi.generate_mouse_event(round(start[0]), round(start[1]), "b1p")
for step in range(1, 13):
x = start[0] + (end[0] - start[0]) * step / 12
y = start[1] + (end[1] - start[1]) * step / 12
Atspi.generate_mouse_event(round(x), round(y), "abs")
time.sleep(0.02)
Atspi.generate_mouse_event(round(end[0]), round(end[1]), "b1r")
def key_name(raw):
aliases = {
"return": "Return", "enter": "Return", "tab": "Tab", "escape": "Escape", "esc": "Escape",
"backspace": "BackSpace", "delete": "Delete", "space": "space", "left": "Left", "right": "Right",
"up": "Up", "down": "Down", "page_up": "Page_Up", "page_down": "Page_Down",
}
return aliases.get(str(raw).lower(), str(raw))
def press_key(raw):
name = key_name(raw)
if len(name) == 1:
Atspi.generate_keyboard_event(0, name, Atspi.KeySynthType.STRING)
return
if Gdk is None:
raise RuntimeError("GDK is required for non-character key synthesis")
Atspi.generate_keyboard_event(Gdk.keyval_from_name(name), None, Atspi.KeySynthType.PRESSRELEASE)
def hotkey(raw):
key_spec = re.sub(r"(?i)commandorcontrol|cmdorctrl", "ctrl", str(raw))
xdotool = shutil.which("xdotool")
if xdotool:
subprocess.run([xdotool, "key", key_spec], check=True)
return
if "+" in key_spec:
raise RuntimeError("hotkey combinations require xdotool")
press_key(key_spec)
def type_text(value):
Atspi.generate_keyboard_event(0, str(value), Atspi.KeySynthType.STRING)
def paste_text(value):
text = str(value)
previous = read_clipboard()
try:
write_clipboard(text)
hotkey("ctrl+v")
finally:
if previous is not None:
write_clipboard(previous)
def read_clipboard():
for command in (["wl-paste"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "--clipboard", "--output"]):
if shutil.which(command[0]):
result = subprocess.run(command, check=False, capture_output=True, text=True)
if result.returncode == 0:
return result.stdout
return None
def write_clipboard(value):
for command in (["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]):
if shutil.which(command[0]):
subprocess.run(command, input=value, check=True, text=True)
return
raise RuntimeError("paste_text requires wl-copy, xclip, or xsel")
def set_value(node, value):
if node is not None and bool(attempt(node.is_editable_text, False)):
editable = attempt(node.get_editable_text_iface)
if editable is not None and attempt(lambda: Atspi.EditableText.set_text_contents(editable, str(value)), False):
return True
value_iface = attempt(node.get_value_iface) if node is not None else None
if value_iface is not None:
return bool(attempt(lambda: Atspi.Value.set_current_value(value_iface, float(value)), False))
return False
def run_operation(operation):
tool = operation.get("tool")
include_screenshot = not bool(operation.get("noScreenshot"))
if tool == "handshake":
return {"ok": True, "capabilities": handshake_response()}
if tool == "list_apps":
return {"ok": True, "apps": list_apps_response()}
if tool == "list_windows":
return {"ok": True, **list_windows_response(operation.get("app", ""))}
if tool == "get_app_state":
return {
"ok": True,
"snapshot": make_snapshot(
operation.get("app", ""),
include_screenshot,
operation.get("windowId"),
operation.get("windowIndex"),
bool(operation.get("restoreWindow")),
),
}
app = find_app(operation.get("app", ""))
if operation.get("restoreWindow"):
restore_window(app)
_, window = choose_window(app, operation.get("windowId"), operation.get("windowIndex"))
if tool in {"type_text", "press_key", "hotkey", "paste_text"}:
require_keyboard_focus(window, operation)
bounds = screen_rect(window)
saved = operation.get("element")
node = find_element(app, saved)
from_node = find_element(app, operation.get("fromElement"))
to_node = find_element(app, operation.get("toElement"))
action = None
if tool == "click":
preferred = preferred_action(node)
click_count = int(operation.get("click_count", 1) or 1)
handled = operation.get("mouse_button", "left") == "left" and click_count <= 1 and perform_action(node, preferred)
if not handled:
click_at(*screen_point(bounds, saved, operation.get("x"), operation.get("y"), node), operation.get("mouse_button", "left"), operation.get("click_count", 1))
action = {"path": "synthetic", "actionName": None, "fallbackReason": "actionUnsupported"}
else:
labels = action_labels(node)
action = {"path": "accessibility", "actionName": labels[preferred] if preferred is not None and preferred < len(labels) else "action", "fallbackReason": None}
elif tool == "perform_secondary_action":
wanted = str(operation.get("action", "")).lower()
for index, label in enumerate(action_labels(node)):
if label.lower() == wanted and perform_action(node, index):
action = {"path": "accessibility", "actionName": label, "fallbackReason": None}
break
else:
raise RuntimeError(f'{operation.get("action", "")} is not a valid secondary action')
elif tool == "scroll":
scroll_at(*screen_point(bounds, saved, operation.get("x"), operation.get("y"), node), operation.get("direction"), operation.get("pages"))
action = {"path": "synthetic", "actionName": "scroll", "fallbackReason": None}
elif tool == "drag":
drag_between(
screen_point(bounds, operation.get("fromElement"), operation.get("from_x"), operation.get("from_y"), from_node),
screen_point(bounds, operation.get("toElement"), operation.get("to_x"), operation.get("to_y"), to_node),
)
action = {"path": "synthetic", "actionName": "drag", "fallbackReason": None}
elif tool == "type_text":
type_text(operation.get("text", ""))
action = {"path": "synthetic", "actionName": "typeText", "fallbackReason": None}
elif tool == "press_key":
press_key(operation.get("key", ""))
action = {"path": "synthetic", "actionName": "pressKey", "fallbackReason": None}
elif tool == "hotkey":
hotkey(operation.get("key", ""))
action = {"path": "synthetic", "actionName": "hotkey", "fallbackReason": None, "verification": {"state": "unverified", "reason": "synthetic_input"}}
elif tool == "paste_text":
paste_text(operation.get("text", ""))
action = {"path": "clipboard", "actionName": "paste", "fallbackReason": None, "verification": {"state": "unverified", "reason": "clipboard_paste"}}
elif tool == "set_value":
if not set_value(node, operation.get("value", "")):
raise RuntimeError("element value is not settable")
action = {"path": "accessibility", "actionName": "setValue", "fallbackReason": None}
else:
raise RuntimeError("unknown tool: " + str(tool))
try:
snapshot = make_snapshot(
operation.get("app", ""),
include_screenshot,
operation.get("windowId"),
operation.get("windowIndex"),
)
except Exception:
if operation.get("windowId") is None and operation.get("windowIndex") is None:
raise
action.setdefault("verification", {"state": "unverified", "reason": "window_changed"})
snapshot = make_snapshot(operation.get("app", ""), include_screenshot, None, None)
return {"ok": True, "action": action, "snapshot": snapshot}
def main():
try:
ensure_desktop_bus()
with open(sys.argv[1], "r", encoding="utf-8") as handle:
operation = json.load(handle)
print(json.dumps(run_operation(operation), separators=(",", ":")))
except Exception as exc:
print(json.dumps({"ok": False, "error": str(exc)}, separators=(",", ":")))
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "OrcaComputerUseMacOS",
platforms: [
.macOS(.v14)
],
products: [
.library(
name: "OrcaComputerUseMacOSCore",
targets: ["OrcaComputerUseMacOSCore"]
),
.executable(
name: "orca-computer-use-macos",
targets: ["OrcaComputerUseMacOS"]
)
],
targets: [
.target(
name: "OrcaComputerUseMacOSCore",
path: "Sources/OrcaComputerUseMacOSCore"
),
.executableTarget(
name: "OrcaComputerUseMacOS",
dependencies: ["OrcaComputerUseMacOSCore"],
path: "Sources/OrcaComputerUseMacOS"
),
.testTarget(
name: "OrcaComputerUseMacOSTests",
dependencies: ["OrcaComputerUseMacOSCore"],
path: "Tests/OrcaComputerUseMacOSTests"
)
]
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,236 @@
import Foundation
public struct SnapshotRenderNode: Equatable {
public var role: String
public var roleDescription: String?
public var title: String?
public var label: String?
public var linkText: String?
public var value: String?
public var placeholder: String?
public var url: String?
public var traits: [String]
public var rawActions: [String]
public var childCount: Int
public var summary: String?
public var rowSummary: String?
public var webAreaDepth: Int?
public init(
role: String,
roleDescription: String? = nil,
title: String? = nil,
label: String? = nil,
linkText: String? = nil,
value: String? = nil,
placeholder: String? = nil,
url: String? = nil,
traits: [String] = [],
rawActions: [String] = [],
childCount: Int = 0,
summary: String? = nil,
rowSummary: String? = nil,
webAreaDepth: Int? = nil
) {
self.role = role
self.roleDescription = roleDescription
self.title = title
self.label = label
self.linkText = linkText
self.value = value
self.placeholder = placeholder
self.url = url
self.traits = traits
self.rawActions = rawActions
self.childCount = childCount
self.summary = summary
self.rowSummary = rowSummary
self.webAreaDepth = webAreaDepth
}
}
public enum SnapshotRenderHeuristics {
public static func displayName(_ node: SnapshotRenderNode) -> String? {
if let title = clean(node.title) {
return title
}
if node.role == "AXLink", let url = clean(node.url), let text = clean(node.linkText ?? node.label ?? node.value) {
return "[\(markdownEscaped(text))](\(url))"
}
if node.role == "AXWebArea" {
return clean(node.label) ?? clean(node.value)
}
if ["AXButton", "AXPopUpButton", "AXImage"].contains(node.role) {
return clean(node.label)
}
if ["AXRow", "AXCell", "AXOutlineRow"].contains(node.role) {
return clean(node.rowSummary)
}
return clean(node.label)
}
public static func meaningfulActions(_ rawActions: [String], role: String) -> [String] {
let noisy: Set<String> = [
"AXPress",
"AXShowDefaultUI",
"AXShowAlternateUI",
"AXShowMenu",
"AXScrollToVisible",
"AXConfirm",
"AXRaise",
]
return rawActions.filter { action in
if noisy.contains(action) { return false }
if role == "AXMenu" || role == "AXMenuItem" {
return action != "AXCancel" && action != "AXPick"
}
if role == "AXScrollArea",
(rawActions.contains("AXScrollUpByPage") || rawActions.contains("AXScrollDownByPage")),
action == "AXScrollLeftByPage" || action == "AXScrollRightByPage" {
return false
}
return true
}
}
public static func shouldElide(_ node: SnapshotRenderNode) -> Bool {
guard node.role == "AXGroup" || node.role == "AXUnknown" else { return false }
guard displayName(node) == nil,
node.traits.isEmpty,
meaningfulActions(node.rawActions, role: node.role).isEmpty,
clean(node.summary) == nil
else {
return false
}
if node.webAreaDepth != nil, node.childCount > 1 {
return false
}
return true
}
public static func shouldSuppressChildren(_ node: SnapshotRenderNode) -> Bool {
if node.role == "AXMenuBarItem" {
return true
}
let name = displayName(node)
if node.role == "AXLink" && name?.hasPrefix("[") == true {
return true
}
let hasCompactLabel = name != nil || clean(node.value) != nil || clean(node.summary) != nil
return hasCompactLabel && compactControlRoles.contains(node.role)
}
public static func shouldSuppressChildren(role: String, name: String? = nil) -> Bool {
shouldSuppressChildren(SnapshotRenderNode(role: role, title: name))
}
public static func line(index: Int, node: SnapshotRenderNode) -> String {
let name = displayName(node)
let roleText = roleText(node)
let meaningful = meaningfulActions(node.rawActions, role: node.role)
var line = roleText.isEmpty ? "\(index)" : "\(index) \(roleText)"
if !node.traits.isEmpty { line += " (\(node.traits.joined(separator: ", ")))" }
if let name { line += " \(sanitize(name))" }
if node.role != "AXLink", let description = clean(node.label), description != name {
line += ", Description: \(sanitize(description))"
}
if let valueSegment = formattedValueSegment(roleText: roleText, name: name, value: node.value) {
line += valueSegment
}
if let placeholder = clean(node.placeholder), placeholder != name, placeholder != node.value {
line += name == nil && clean(node.value) == nil ? " Placeholder: \(sanitize(placeholder))" : ", Placeholder: \(sanitize(placeholder))"
}
if let summary = clean(node.summary), summary != name {
line += ", Text: \(sanitize(summary))"
} else if let rowSummary = clean(node.rowSummary), rowSummary != name {
line += ", Text: \(sanitize(rowSummary))"
}
if !meaningful.isEmpty {
line += ", Secondary Actions: \(meaningful.map(prettyAction).joined(separator: ", "))"
}
return line
}
public static func roleText(_ node: SnapshotRenderNode) -> String {
if node.role == "AXGroup" || node.role == "AXUnknown" {
return "container"
}
if node.role == "AXLink" {
return "link"
}
if node.role == "AXWebArea" {
return clean(node.roleDescription) ?? "html content"
}
if node.role == "AXMenuBarItem" {
return ""
}
if let value = clean(node.roleDescription) {
return value.lowercased()
}
if node.role.hasPrefix("AX") {
return splitCamelCase(String(node.role.dropFirst(2))).lowercased()
}
return node.role
}
public static func prettyAction(_ action: String) -> String {
if action == "AXZoomWindow" {
return "zoom the window"
}
let stripped = action.hasPrefix("AX") ? String(action.dropFirst(2)) : action
return splitCamelCase(stripped.replacingOccurrences(of: "ByPage", with: "")).lowercased()
}
public static func sanitize(_ value: String) -> String {
value.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: " ")
}
private static func clean(_ value: String?) -> String? {
guard let value else { return nil }
let sanitized = sanitize(value)
return sanitized.isEmpty ? nil : sanitized
}
private static func formattedValueSegment(roleText: String, name: String?, value: String?) -> String? {
guard let value = clean(value), value != name else { return nil }
if roleText == "heading", Int(value) != nil {
return nil
}
let clean = sanitize(value)
if roleText == "text" || roleText == "text entry area" || roleText == "scroll bar" || roleText == "value indicator" {
return " \(clean)"
}
return ", Value: \(clean)"
}
private static func markdownEscaped(_ value: String) -> String {
sanitize(value)
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "[", with: "\\[")
.replacingOccurrences(of: "]", with: "\\]")
}
private static func splitCamelCase(_ value: String) -> String {
var result = ""
for character in value {
if character.isUppercase, !result.isEmpty {
result.append(" ")
}
result.append(character)
}
return result
}
private static let compactControlRoles: Set<String> = [
"AXButton",
"AXCheckBox",
"AXComboBox",
"AXDisclosureTriangle",
"AXHeading",
"AXMenuItem",
"AXPopUpButton",
"AXRadioButton",
"AXStaticText",
"AXTab",
]
}
@@ -0,0 +1,85 @@
import XCTest
@testable import OrcaComputerUseMacOSCore
final class SnapshotRenderingTests: XCTestCase {
func testElidesAnonymousWrappers() {
let node = SnapshotRenderNode(role: "AXGroup", childCount: 1)
XCTAssertTrue(SnapshotRenderHeuristics.shouldElide(node))
}
func testPreservesWebAreaContainersWithMultipleChildren() {
let node = SnapshotRenderNode(role: "AXGroup", childCount: 3, webAreaDepth: 1)
XCTAssertFalse(SnapshotRenderHeuristics.shouldElide(node))
}
func testRendersMarkdownLinksAndSuppressesTheirChildren() {
let node = SnapshotRenderNode(role: "AXLink", linkText: "Skip [main]", url: "https://example.com/path")
XCTAssertEqual(SnapshotRenderHeuristics.line(index: 7, node: node), "7 link [Skip \\[main\\]](https://example.com/path)")
XCTAssertTrue(SnapshotRenderHeuristics.shouldSuppressChildren(node))
}
func testSuppressesChildrenForNamedCompactControls() {
let button = SnapshotRenderNode(role: "AXButton", roleDescription: "button", label: "Install GitHub", childCount: 1)
let heading = SnapshotRenderNode(role: "AXHeading", roleDescription: "heading", label: "Repository navigation", value: "2", childCount: 1)
XCTAssertTrue(SnapshotRenderHeuristics.shouldSuppressChildren(button))
XCTAssertTrue(SnapshotRenderHeuristics.shouldSuppressChildren(heading))
XCTAssertEqual(SnapshotRenderHeuristics.line(index: 5, node: heading), "5 heading Repository navigation")
}
func testKeepsChildrenForRowsWithNestedControls() {
let row = SnapshotRenderNode(role: "AXRow", roleDescription: "row", childCount: 3, rowSummary: "Liked Songs")
XCTAssertFalse(SnapshotRenderHeuristics.shouldSuppressChildren(row))
}
func testFiltersNoisyActionsAndFormatsSecondaryActions() {
let node = SnapshotRenderNode(
role: "AXWindow",
title: "Document",
rawActions: ["AXPress", "AXShowMenu", "AXScrollToVisible", "AXZoomWindow"]
)
XCTAssertEqual(SnapshotRenderHeuristics.meaningfulActions(node.rawActions, role: node.role), ["AXZoomWindow"])
XCTAssertEqual(SnapshotRenderHeuristics.line(index: 1, node: node), "1 window Document, Secondary Actions: zoom the window")
}
func testSuppressesHorizontalScrollWhenVerticalScrollExists() {
let node = SnapshotRenderNode(
role: "AXScrollArea",
rawActions: ["AXScrollUpByPage", "AXScrollDownByPage", "AXScrollLeftByPage", "AXScrollRightByPage"]
)
XCTAssertEqual(SnapshotRenderHeuristics.meaningfulActions(node.rawActions, role: node.role), ["AXScrollUpByPage", "AXScrollDownByPage"])
}
func testTextFieldsKeepDistinctValueAndPlaceholder() {
let node = SnapshotRenderNode(
role: "AXTextField",
roleDescription: "text field",
label: "Address",
value: "https://example.com",
placeholder: "Search"
)
XCTAssertEqual(
SnapshotRenderHeuristics.line(index: 3, node: node),
"3 text field Address, Value: https://example.com, Placeholder: Search"
)
}
func testStaticTextUsesCompactValueFormatting() {
let node = SnapshotRenderNode(role: "AXStaticText", roleDescription: "text", value: "Home")
XCTAssertEqual(SnapshotRenderHeuristics.line(index: 4, node: node), "4 text Home")
}
func testRowSummaryBecomesName() {
let node = SnapshotRenderNode(role: "AXRow", roleDescription: "row", rowSummary: "General Settings Enabled")
XCTAssertEqual(SnapshotRenderHeuristics.line(index: 9, node: node), "9 row General Settings Enabled")
}
}
+879
View File
@@ -0,0 +1,879 @@
param(
[Parameter(Mandatory = $true)]
[string]$OperationPath
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class OrcaDesktopWin32 {
[StructLayout(LayoutKind.Sequential)]
public struct RECT {
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[StructLayout(LayoutKind.Sequential)]
public struct POINT {
public int X;
public int Y;
}
[DllImport("user32.dll")]
public static extern bool GetWindowRect(IntPtr hwnd, out RECT rect);
[DllImport("user32.dll")]
public static extern bool ScreenToClient(IntPtr hwnd, ref POINT point);
[DllImport("user32.dll")]
public static extern bool PostMessage(IntPtr hwnd, uint message, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
public static extern bool ShowWindow(IntPtr hwnd, int nCmdShow);
[DllImport("user32.dll")]
public static extern bool SetForegroundWindow(IntPtr hwnd);
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
}
"@
$MaxNodes = 1200
$MaxDepth = 64
$TextLimit = 500
$BlockedAppFragments = @(
"1password",
"bitwarden",
"dashlane",
"lastpass",
"nordpass",
"proton pass"
)
$WindowsMessages = @{
Char = 0x0102
KeyDown = 0x0100
KeyUp = 0x0101
MouseMove = 0x0200
LeftDown = 0x0201
LeftUp = 0x0202
RightDown = 0x0204
RightUp = 0x0205
MiddleDown = 0x0207
MiddleUp = 0x0208
Wheel = 0x020A
}
function Write-OrcaJson($Payload) {
$Payload | ConvertTo-Json -Depth 100 -Compress
}
function New-OrcaFrame([double]$X, [double]$Y, [double]$Width, [double]$Height) {
if ($Width -le 0 -or $Height -le 0) { return $null }
[pscustomobject]@{ x = $X; y = $Y; width = $Width; height = $Height }
}
function Read-OrcaOperation([string]$Path) {
Get-Content -Raw -Path $Path | ConvertFrom-Json
}
function ConvertTo-OrcaLParam([int]$X, [int]$Y) {
[IntPtr]((($Y -band 0xffff) -shl 16) -bor ($X -band 0xffff))
}
function ConvertTo-OrcaWheelParam([int]$Delta) {
[IntPtr](($Delta -band 0xffff) -shl 16)
}
function Get-OrcaWindowProcesses {
@(Get-Process | Where-Object { $_.MainWindowHandle -ne 0 } | Sort-Object ProcessName, Id)
}
function Find-OrcaProcess([string]$Query) {
$needle = ""
if ($null -ne $Query) { $needle = $Query.Trim() }
if ([string]::IsNullOrWhiteSpace($needle)) { throw 'appNotFound("")' }
if ($needle.StartsWith("pid:", [System.StringComparison]::OrdinalIgnoreCase)) {
$needle = $needle.Substring(4)
}
$pid = 0
$processes = Get-OrcaWindowProcesses
if ([int]::TryParse($needle, [ref]$pid)) {
$match = $processes | Where-Object { $_.Id -eq $pid } | Select-Object -First 1
if ($null -ne $match) {
Assert-OrcaProcessAllowed $match
return $match
}
}
$processNeedle = $needle
if ($processNeedle.EndsWith(".exe", [System.StringComparison]::OrdinalIgnoreCase)) {
$processNeedle = $processNeedle.Substring(0, $processNeedle.Length - 4)
}
$match = $processes | Where-Object {
$_.ProcessName -ieq $processNeedle -or
"$($_.ProcessName).exe" -ieq $needle -or
$_.MainWindowTitle -ieq $needle -or
$_.MainWindowTitle -ilike "*$needle*"
} | Select-Object -First 1
if ($null -ne $match) {
Assert-OrcaProcessAllowed $match
return $match
}
throw "appNotFound(`"$Query`")"
}
function Assert-OrcaProcessAllowed($Process) {
$values = @($Process.ProcessName, $Process.MainWindowTitle) | ForEach-Object { ([string]$_).ToLowerInvariant() }
foreach ($fragment in $BlockedAppFragments) {
foreach ($value in $values) {
if ($value.Contains($fragment)) {
throw "appBlocked(`"$($Process.ProcessName)`")"
}
}
}
}
function Get-OrcaRootElement($Process) {
if ($Process.MainWindowHandle -eq 0) {
throw "No top-level UI Automation window is available for $($Process.ProcessName)."
}
[Windows.Automation.AutomationElement]::FromHandle([IntPtr]$Process.MainWindowHandle)
}
function Get-OrcaWindowFrame($Process, $RootElement) {
$rect = New-Object OrcaDesktopWin32+RECT
if ([OrcaDesktopWin32]::GetWindowRect([IntPtr]$Process.MainWindowHandle, [ref]$rect)) {
return New-OrcaFrame $rect.Left $rect.Top ($rect.Right - $rect.Left) ($rect.Bottom - $rect.Top)
}
try {
$bounds = $RootElement.Current.BoundingRectangle
if (-not $bounds.IsEmpty) {
return New-OrcaFrame $bounds.X $bounds.Y $bounds.Width $bounds.Height
}
} catch {}
$null
}
function Get-OrcaWindowId($Process) {
[int64]$Process.MainWindowHandle
}
function Assert-OrcaWindowTarget($Process, $WindowId, $WindowIndex) {
if ($null -ne $WindowIndex -and [int]$WindowIndex -ne 0) {
throw "windowNotFound(`"$WindowIndex`")"
}
if ($null -ne $WindowId -and [int64]$WindowId -ne (Get-OrcaWindowId $Process)) {
throw "windowNotFound(`"$WindowId`")"
}
}
function Restore-OrcaWindow($Process) {
if ($Process.MainWindowHandle -eq 0) { return }
[void][OrcaDesktopWin32]::ShowWindow([IntPtr]$Process.MainWindowHandle, 9)
[void][OrcaDesktopWin32]::SetForegroundWindow([IntPtr]$Process.MainWindowHandle)
}
function Assert-OrcaKeyboardFocus([IntPtr]$WindowHandle, $Operation) {
if ([bool]$Operation.restoreWindow) { return }
if ([OrcaDesktopWin32]::GetForegroundWindow() -eq $WindowHandle) { return }
throw "window_not_focused: keyboard input requires the target window to be focused; retry with --restore-window"
}
function Get-OrcaElementFrame($Element, $WindowFrame) {
try {
$bounds = $Element.Current.BoundingRectangle
if ($bounds.IsEmpty) { return $null }
if ($null -eq $WindowFrame) {
return New-OrcaFrame $bounds.X $bounds.Y $bounds.Width $bounds.Height
}
New-OrcaFrame ($bounds.X - $WindowFrame.x) ($bounds.Y - $WindowFrame.y) $bounds.Width $bounds.Height
} catch {
$null
}
}
function Get-OrcaProperty($Element, [string]$Name) {
try { [string]$Element.Current.$Name } catch { "" }
}
function Get-OrcaRuntimeId($Element) {
try { @($Element.GetRuntimeId()) } catch { @() }
}
function Get-OrcaValueText($Element) {
try {
if ($Element.Current.IsPassword) { return "[redacted]" }
$pattern = $Element.GetCurrentPattern([Windows.Automation.ValuePattern]::Pattern)
$rawValue = $pattern.Current.Value
$text = if ($null -eq $rawValue) { "" } else { [string]$rawValue }
if ($text.Length -gt $TextLimit) { return $text.Substring(0, $TextLimit) + "..." }
$text
} catch {
""
}
}
function Get-OrcaActions($Element) {
$actions = New-Object System.Collections.Generic.List[string]
foreach ($pattern in $Element.GetSupportedPatterns()) {
$name = [string]$pattern.ProgrammaticName
if ($name -like "InvokePatternIdentifiers.Pattern") { $actions.Add("Invoke") }
elseif ($name -like "TogglePatternIdentifiers.Pattern") { $actions.Add("Toggle") }
elseif ($name -like "SelectionItemPatternIdentifiers.Pattern") { $actions.Add("Select") }
elseif ($name -like "ScrollPatternIdentifiers.Pattern") { $actions.Add("Scroll") }
elseif ($name -like "ValuePatternIdentifiers.Pattern") { $actions.Add("SetValue") }
}
@($actions | Select-Object -Unique)
}
function Get-OrcaMeaningfulActions($Actions) {
$noisy = @("Invoke", "ScrollToVisible", "ShowMenu")
@($Actions | Where-Object { $noisy -notcontains $_ })
}
function Format-OrcaSnapshotText([string]$Text) {
if ([string]::IsNullOrWhiteSpace($Text)) { return "" }
(($Text -replace "\s+", " ").Trim())
}
function Format-OrcaValueSegment([string]$RoleKey, [string]$Title, [string]$Value) {
$clean = Format-OrcaSnapshotText $Value
if ([string]::IsNullOrWhiteSpace($clean) -or $clean -eq $Title) { return "" }
if ($RoleKey -eq "heading" -and $clean -match "^\d+$") { return "" }
if ($RoleKey -in @("text", "edit", "document", "scroll bar", "progress bar")) {
return " $clean"
}
", Value: $clean"
}
function Test-OrcaSuppressChildren([string]$RoleKey, [string]$Title, [string]$Value, [string]$Summary) {
$hasCompactLabel = -not [string]::IsNullOrWhiteSpace($Title) -or -not [string]::IsNullOrWhiteSpace((Format-OrcaSnapshotText $Value)) -or -not [string]::IsNullOrWhiteSpace((Format-OrcaSnapshotText $Summary))
$hasCompactLabel -and $RoleKey -in @(
"button",
"check box",
"combo box",
"heading",
"hyperlink",
"link",
"menu item",
"radio button",
"tab item"
)
}
function Get-OrcaTextSnippets($Element, [int]$Limit = 6, [int]$MaxDepth = 3) {
$values = New-Object System.Collections.Generic.List[string]
$seen = New-Object System.Collections.Generic.HashSet[string]
function Visit-OrcaText($Node, [int]$Depth) {
if ($values.Count -ge $Limit -or $Depth -gt $MaxDepth) { return }
$role = try { [string]$Node.Current.LocalizedControlType } catch { "" }
if ($role -match "text|link|label") {
foreach ($raw in @((Get-OrcaProperty $Node "Name"), (Get-OrcaValueText $Node))) {
$value = (($raw -replace "\s+", " ").Trim())
if (-not [string]::IsNullOrWhiteSpace($value) -and $seen.Add($value)) {
if ($value.Length -gt 80) { $value = $value.Substring(0, 80) + "..." }
$values.Add($value)
if ($values.Count -ge $Limit) { return }
}
}
}
try {
$children = $Node.FindAll([Windows.Automation.TreeScope]::Children, [Windows.Automation.Condition]::TrueCondition)
for ($i = 0; $i -lt $children.Count; $i++) {
Visit-OrcaText $children.Item($i) ($Depth + 1)
if ($values.Count -ge $Limit) { return }
}
} catch {}
}
Visit-OrcaText $Element 0
@($values.ToArray())
}
function Test-OrcaPlainTextSubtree($Element, [int]$MaxDepth = 4) {
$script:sawOrcaText = $false
$allowed = @("pane", "group", "custom", "unknown", "text", "link", "image")
function Visit-OrcaPlainText($Node, [int]$Depth) {
if ($Depth -gt $MaxDepth) { return $false }
$role = try { [string]$Node.Current.LocalizedControlType } catch { "" }
$roleKey = $role.ToLowerInvariant()
if ($allowed -notcontains $roleKey) { return $false }
if ($roleKey -match "text|link") { $script:sawOrcaText = $true }
if (@(Get-OrcaMeaningfulActions @(Get-OrcaActions $Node)).Count -gt 0) { return $false }
try {
$children = $Node.FindAll([Windows.Automation.TreeScope]::Children, [Windows.Automation.Condition]::TrueCondition)
for ($i = 0; $i -lt $children.Count; $i++) {
if (-not (Visit-OrcaPlainText $children.Item($i) ($Depth + 1))) { return $false }
}
} catch {}
return $true
}
(Visit-OrcaPlainText $Element 0) -and $script:sawOrcaText
}
function New-OrcaElementRecord($Element, [int]$Index, $WindowFrame) {
$controlType = try { [string]$Element.Current.ControlType.ProgrammaticName } catch { "" }
$nativeWindowHandle = try { [int64]$Element.Current.NativeWindowHandle } catch { 0 }
[pscustomobject]@{
index = $Index
runtimeId = @(Get-OrcaRuntimeId $Element)
automationId = Get-OrcaProperty $Element "AutomationId"
name = Get-OrcaProperty $Element "Name"
controlType = $controlType
localizedControlType = Get-OrcaProperty $Element "LocalizedControlType"
className = Get-OrcaProperty $Element "ClassName"
value = Get-OrcaValueText $Element
nativeWindowHandle = $nativeWindowHandle
frame = Get-OrcaElementFrame $Element $WindowFrame
actions = @(Get-OrcaActions $Element)
}
}
function Render-OrcaTree($RootElement, $WindowFrame) {
$records = New-Object System.Collections.Generic.List[object]
$lines = New-Object System.Collections.Generic.List[string]
$seen = New-Object System.Collections.Generic.HashSet[string]
$truncation = [pscustomobject]@{
truncated = $false
maxNodes = $MaxNodes
maxDepth = $MaxDepth
maxDepthReached = $false
}
function Visit-OrcaNode($Node, [int]$Depth) {
if ($records.Count -ge $MaxNodes -or $Depth -gt $MaxDepth) {
$truncation.truncated = $true
if ($Depth -gt $MaxDepth) { $truncation.maxDepthReached = $true }
return
}
$identity = try { (@($Node.GetRuntimeId()) -join ".") } catch { [Guid]::NewGuid().ToString() }
if (-not $seen.Add($identity)) { return }
$record = New-OrcaElementRecord $Node $records.Count $WindowFrame
$children = @()
try {
$children = @($Node.FindAll([Windows.Automation.TreeScope]::Children, [Windows.Automation.Condition]::TrueCondition))
} catch {}
$meaningfulActions = @(Get-OrcaMeaningfulActions $record.actions)
$title = if ([string]::IsNullOrWhiteSpace($record.name)) { $record.automationId } else { $record.name }
$role = if ([string]::IsNullOrWhiteSpace($record.localizedControlType)) { $record.controlType } else { $record.localizedControlType }
$roleKey = $role.ToLowerInvariant()
$snippets = @(Get-OrcaTextSnippets $Node 8 4)
$genericSummary = $null
if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) {
$genericSummary = ($snippets -join " ")
}
if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $meaningfulActions.Count -eq 0 -and $null -eq $genericSummary -and $children.Count -le 1) {
for ($i = 0; $i -lt $children.Count; $i++) {
Visit-OrcaNode $children.Item($i) $Depth
}
return
}
$records.Add($record)
$line = "$($record.index) $role $(Format-OrcaSnapshotText $title)".TrimEnd()
$line += Format-OrcaValueSegment $roleKey $title $record.value
if (-not [string]::IsNullOrWhiteSpace($genericSummary) -and $genericSummary -ne $title) {
$line += ", Text: " + (Format-OrcaSnapshotText $genericSummary)
} elseif ($roleKey -in @("row", "data item", "list item")) {
$rowSummary = @((Get-OrcaTextSnippets $Node 6 3)) -join " "
if (-not [string]::IsNullOrWhiteSpace($rowSummary) -and $rowSummary -ne $title) {
$line += ", Text: " + (Format-OrcaSnapshotText $rowSummary)
}
}
if ($meaningfulActions.Count -gt 0) {
$line += ", Secondary Actions: " + ($meaningfulActions -join ", ")
}
$lines.Add(("`t" * $Depth) + $line)
if (-not [string]::IsNullOrWhiteSpace($genericSummary) -or (Test-OrcaSuppressChildren $roleKey $title $record.value $genericSummary)) { return }
for ($i = 0; $i -lt $children.Count; $i++) {
Visit-OrcaNode $children.Item($i) ($Depth + 1)
}
}
Visit-OrcaNode $RootElement 0
[pscustomobject]@{ elements = @($records.ToArray()); lines = @($lines.ToArray()); truncation = $truncation }
}
function Get-OrcaScreenshot([bool]$IncludeScreenshot, $WindowFrame) {
if (-not $IncludeScreenshot -or $null -eq $WindowFrame) { return $null }
try {
$width = [int][Math]::Max(1, [Math]::Round($WindowFrame.width))
$height = [int][Math]::Max(1, [Math]::Round($WindowFrame.height))
$bitmap = New-Object System.Drawing.Bitmap $width, $height
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen([int][Math]::Round($WindowFrame.x), [int][Math]::Round($WindowFrame.y), 0, 0, $bitmap.Size)
$stream = New-Object System.IO.MemoryStream
$bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
$bytes = $stream.ToArray()
$graphics.Dispose()
$bitmap.Dispose()
$stream.Dispose()
[Convert]::ToBase64String($bytes)
} catch {
$null
}
}
function New-OrcaSnapshot([string]$Query, [bool]$IncludeScreenshot, $WindowId = $null, $WindowIndex = $null, [bool]$RestoreWindow = $false) {
$process = Find-OrcaProcess $Query
if ($RestoreWindow) { Restore-OrcaWindow $process }
Assert-OrcaWindowTarget $process $WindowId $WindowIndex
$root = Get-OrcaRootElement $process
$windowFrame = Get-OrcaWindowFrame $process $root
$tree = Render-OrcaTree $root $windowFrame
[pscustomobject]@{
snapshotId = [guid]::NewGuid().ToString()
app = [pscustomobject]@{
name = $process.ProcessName
bundleIdentifier = $process.ProcessName
bundleId = $process.ProcessName
pid = [int]$process.Id
}
windowTitle = $process.MainWindowTitle
windowId = Get-OrcaWindowId $process
windowBounds = $windowFrame
screenshotPngBase64 = Get-OrcaScreenshot $IncludeScreenshot $windowFrame
coordinateSpace = "window"
truncation = $tree.truncation
treeLines = @($tree.lines)
focusedSummary = $null
focusedElementId = $null
selectedText = $null
elements = @($tree.elements)
}
}
function Get-OrcaAppList {
@(Get-OrcaWindowProcesses | ForEach-Object {
[pscustomobject]@{
name = $_.ProcessName
bundleIdentifier = $_.ProcessName
bundleId = $_.ProcessName
pid = [int]$_.Id
}
})
}
function Get-OrcaWindowList([string]$Query) {
$process = Find-OrcaProcess $Query
$root = Get-OrcaRootElement $process
$windowFrame = Get-OrcaWindowFrame $process $root
$x = $null
$y = $null
$width = 0
$height = 0
if ($null -ne $windowFrame) {
$x = [int][Math]::Round($windowFrame.x)
$y = [int][Math]::Round($windowFrame.y)
$width = [int][Math]::Max(0, [Math]::Round($windowFrame.width))
$height = [int][Math]::Max(0, [Math]::Round($windowFrame.height))
}
$app = [pscustomobject]@{
name = $process.ProcessName
bundleIdentifier = $process.ProcessName
bundleId = $process.ProcessName
pid = [int]$process.Id
}
[pscustomobject]@{
app = $app
windows = @([pscustomobject]@{
index = 0
app = $app
id = Get-OrcaWindowId $process
title = $process.MainWindowTitle
x = $x
y = $y
width = $width
height = $height
isMinimized = $false
isOffscreen = $false
screenIndex = $null
platform = [pscustomobject]@{ backend = "uia"; nativeWindowHandle = Get-OrcaWindowId $process }
})
}
}
function Get-OrcaHandshake {
[pscustomobject]@{
platform = "win32"
provider = "orca-computer-use-windows"
providerVersion = "1.0.0"
protocolVersion = 1
supports = [pscustomobject]@{
apps = [pscustomobject]@{ list = $true; bundleIds = $false; pids = $true }
windows = [pscustomobject]@{ list = $true; targetById = $true; targetByIndex = $true; focus = $false; moveResize = $false }
observation = [pscustomobject]@{ screenshot = $true; annotatedScreenshot = $false; elementFrames = $true; ocr = $false }
actions = [pscustomobject]@{
click = $true
typeText = $true
pressKey = $true
hotkey = $true
pasteText = $true
scroll = $true
drag = $true
setValue = $true
performAction = $true
}
surfaces = [pscustomobject]@{ menus = $false; dialogs = $false; dock = $false; menubar = $false }
}
}
}
function Test-OrcaSameRuntimeId($Left, $Right) {
if ($null -eq $Left -or $null -eq $Right -or $Left.Count -ne $Right.Count) { return $false }
for ($i = 0; $i -lt $Left.Count; $i++) {
if ([int]$Left[$i] -ne [int]$Right[$i]) { return $false }
}
$true
}
function Find-OrcaElement($RootElement, $Record) {
if ($null -eq $Record) { return $null }
if ($Record.index -eq 0) { return $RootElement }
try {
$descendants = $RootElement.FindAll([Windows.Automation.TreeScope]::Descendants, [Windows.Automation.Condition]::TrueCondition)
for ($i = 0; $i -lt $descendants.Count; $i++) {
$candidate = $descendants.Item($i)
if (Test-OrcaSameRuntimeId @($candidate.GetRuntimeId()) @($Record.runtimeId)) {
return $candidate
}
}
} catch {}
$null
}
function Invoke-OrcaPrimaryAction($Element) {
foreach ($pattern in @(
[Windows.Automation.InvokePattern]::Pattern,
[Windows.Automation.SelectionItemPattern]::Pattern,
[Windows.Automation.TogglePattern]::Pattern
)) {
try {
$instance = $Element.GetCurrentPattern($pattern)
if ($pattern -eq [Windows.Automation.InvokePattern]::Pattern) { $instance.Invoke(); return $true }
if ($pattern -eq [Windows.Automation.SelectionItemPattern]::Pattern) { $instance.Select(); return $true }
if ($pattern -eq [Windows.Automation.TogglePattern]::Pattern) { $instance.Toggle(); return $true }
} catch {}
}
$false
}
function Invoke-OrcaNamedAction($Element, [string]$Action) {
$wanted = ""
if ($null -ne $Action) { $wanted = $Action.Trim().ToLowerInvariant() }
switch ($wanted) {
"invoke" {
$pattern = $Element.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern)
$pattern.Invoke()
return $true
}
"select" {
$pattern = $Element.GetCurrentPattern([Windows.Automation.SelectionItemPattern]::Pattern)
$pattern.Select()
return $true
}
"toggle" {
$pattern = $Element.GetCurrentPattern([Windows.Automation.TogglePattern]::Pattern)
$pattern.Toggle()
return $true
}
default {
return $false
}
}
}
function Set-OrcaElementValue($Element, [string]$Value) {
try {
$pattern = $Element.GetCurrentPattern([Windows.Automation.ValuePattern]::Pattern)
if (-not $pattern.Current.IsReadOnly) {
$pattern.SetValue($Value)
return $true
}
} catch {}
$false
}
function Get-OrcaScreenPoint($Operation, $WindowFrame) {
if ($null -ne $Operation.element) {
throw "stale element frame; run get-app-state again and use a fresh element index"
}
@{
x = [int][Math]::Round($WindowFrame.x + [double]$Operation.x)
y = [int][Math]::Round($WindowFrame.y + [double]$Operation.y)
}
}
function Get-OrcaElementScreenPoint($Element) {
if ($null -eq $Element) { return $null }
try {
$rect = $Element.Current.BoundingRectangle
if ($rect.Width -gt 0 -and $rect.Height -gt 0) {
return @{
x = [int][Math]::Round($rect.X + ($rect.Width / 2))
y = [int][Math]::Round($rect.Y + ($rect.Height / 2))
}
}
} catch {}
$null
}
function Send-OrcaMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY, [string]$Button, [int]$Count) {
$point = New-Object OrcaDesktopWin32+POINT
$point.X = $ScreenX
$point.Y = $ScreenY
[void][OrcaDesktopWin32]::ScreenToClient($WindowHandle, [ref]$point)
$down = $WindowsMessages.LeftDown
$up = $WindowsMessages.LeftUp
$flag = 1
if ($Button -eq "right") {
$down = $WindowsMessages.RightDown
$up = $WindowsMessages.RightUp
$flag = 2
} elseif ($Button -eq "middle") {
$down = $WindowsMessages.MiddleDown
$up = $WindowsMessages.MiddleUp
$flag = 16
}
$position = ConvertTo-OrcaLParam $point.X $point.Y
for ($i = 0; $i -lt [Math]::Max(1, $Count); $i++) {
[void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.MouseMove, [IntPtr]::Zero, $position)
[void][OrcaDesktopWin32]::PostMessage($WindowHandle, $down, [IntPtr]$flag, $position)
Start-Sleep -Milliseconds 35
[void][OrcaDesktopWin32]::PostMessage($WindowHandle, $up, [IntPtr]::Zero, $position)
}
}
function Send-OrcaDrag([IntPtr]$WindowHandle, $From, $To) {
$start = New-Object OrcaDesktopWin32+POINT
$start.X = [int]$From.x
$start.Y = [int]$From.y
[void][OrcaDesktopWin32]::ScreenToClient($WindowHandle, [ref]$start)
$end = New-Object OrcaDesktopWin32+POINT
$end.X = [int]$To.x
$end.Y = [int]$To.y
[void][OrcaDesktopWin32]::ScreenToClient($WindowHandle, [ref]$end)
[void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.MouseMove, [IntPtr]::Zero, (ConvertTo-OrcaLParam $start.X $start.Y))
[void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.LeftDown, [IntPtr]1, (ConvertTo-OrcaLParam $start.X $start.Y))
for ($step = 1; $step -le 12; $step++) {
$x = [int][Math]::Round($start.X + (($end.X - $start.X) * $step / 12))
$y = [int][Math]::Round($start.Y + (($end.Y - $start.Y) * $step / 12))
[void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.MouseMove, [IntPtr]1, (ConvertTo-OrcaLParam $x $y))
Start-Sleep -Milliseconds 20
}
[void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.LeftUp, [IntPtr]::Zero, (ConvertTo-OrcaLParam $end.X $end.Y))
}
function Send-OrcaText([IntPtr]$WindowHandle, [string]$Text) {
foreach ($character in $Text.ToCharArray()) {
[void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.Char, [IntPtr][int][char]$character, [IntPtr]::Zero)
Start-Sleep -Milliseconds 8
}
}
function Get-OrcaVirtualKey([string]$Key) {
$normalized = $Key.ToLowerInvariant()
$map = @{
"return" = 0x0D; "enter" = 0x0D; "tab" = 0x09; "escape" = 0x1B; "esc" = 0x1B
"backspace" = 0x08; "delete" = 0x2E; "space" = 0x20; "left" = 0x25
"up" = 0x26; "right" = 0x27; "down" = 0x28; "home" = 0x24; "end" = 0x23
}
if ($map.ContainsKey($normalized)) { return $map[$normalized] }
if ($normalized.Length -eq 1) { return [int][char]$normalized.ToUpperInvariant()[0] }
throw "Unsupported key: $Key"
}
function Send-OrcaKey([IntPtr]$WindowHandle, [string]$Key) {
$virtualKey = Get-OrcaVirtualKey $Key
[void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.KeyDown, [IntPtr]$virtualKey, [IntPtr]::Zero)
Start-Sleep -Milliseconds 25
[void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.KeyUp, [IntPtr]$virtualKey, [IntPtr]::Zero)
}
function Get-OrcaModifierVirtualKey([string]$Modifier) {
switch ($Modifier.ToLowerInvariant()) {
{ $_ -in @("ctrl", "control", "cmdorctrl", "commandorcontrol") } { return 0x11 }
{ $_ -in @("shift") } { return 0x10 }
{ $_ -in @("alt", "option") } { return 0x12 }
{ $_ -in @("meta", "super", "win", "cmd", "command") } { return 0x5B }
default { throw "Unsupported modifier: $Modifier" }
}
}
function Send-OrcaHotkey([IntPtr]$WindowHandle, [string]$KeySpec) {
$parts = @($KeySpec.Split("+") | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($parts.Count -eq 0) { throw "Unsupported key: $KeySpec" }
$key = $parts[$parts.Count - 1]
$modifiers = @()
if ($parts.Count -gt 1) {
$modifiers = @($parts[0..($parts.Count - 2)] | ForEach-Object { Get-OrcaModifierVirtualKey $_ })
}
foreach ($modifier in $modifiers) {
[void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.KeyDown, [IntPtr]$modifier, [IntPtr]::Zero)
}
Send-OrcaKey $WindowHandle $key
for ($i = $modifiers.Count - 1; $i -ge 0; $i--) {
[void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.KeyUp, [IntPtr]$modifiers[$i], [IntPtr]::Zero)
}
}
function Send-OrcaPasteText([IntPtr]$WindowHandle, [string]$Text) {
$previous = $null
$hadPrevious = $false
try { $previous = [System.Windows.Forms.Clipboard]::GetDataObject() } catch {}
$hadPrevious = $null -ne $previous
try {
Set-Clipboard -Value $Text
Send-OrcaHotkey $WindowHandle "Ctrl+v"
} finally {
if ($hadPrevious) {
[System.Windows.Forms.Clipboard]::SetDataObject($previous, $true)
} else {
[System.Windows.Forms.Clipboard]::Clear()
}
}
}
function Invoke-OrcaOperation($Operation) {
$includeScreenshot = -not [bool]$Operation.noScreenshot
if ($Operation.tool -eq "handshake") {
return [pscustomobject]@{ ok = $true; capabilities = Get-OrcaHandshake }
}
if ($Operation.tool -eq "list_apps") {
return [pscustomobject]@{ ok = $true; apps = @(Get-OrcaAppList) }
}
if ($Operation.tool -eq "list_windows") {
$list = Get-OrcaWindowList $Operation.app
return [pscustomobject]@{ ok = $true; app = $list.app; windows = @($list.windows) }
}
if ($Operation.tool -eq "get_app_state") {
return [pscustomobject]@{ ok = $true; snapshot = New-OrcaSnapshot $Operation.app $includeScreenshot $Operation.windowId $Operation.windowIndex ([bool]$Operation.restoreWindow) }
}
$process = Find-OrcaProcess $Operation.app
if ([bool]$Operation.restoreWindow) { Restore-OrcaWindow $process }
Assert-OrcaWindowTarget $process $Operation.windowId $Operation.windowIndex
$root = Get-OrcaRootElement $process
$windowFrame = if ($null -ne $Operation.windowBounds) { $Operation.windowBounds } else { Get-OrcaWindowFrame $process $root }
$element = Find-OrcaElement $root $Operation.element
$fromElement = Find-OrcaElement $root $Operation.fromElement
$toElement = Find-OrcaElement $root $Operation.toElement
$handle = [IntPtr]$process.MainWindowHandle
if ($Operation.tool -in @("type_text", "press_key", "hotkey", "paste_text")) {
Assert-OrcaKeyboardFocus $handle $Operation
}
$action = $null
switch ($Operation.tool) {
"click" {
$handledByPattern = $false
if ($null -ne $element -and $Operation.mouse_button -ne "right" -and $Operation.mouse_button -ne "middle" -and [int]$Operation.click_count -le 1) {
$handledByPattern = Invoke-OrcaPrimaryAction $element
}
if (-not $handledByPattern) {
$point = Get-OrcaElementScreenPoint $element
if ($null -eq $point) { $point = Get-OrcaScreenPoint $Operation $windowFrame }
Send-OrcaMouseClick $handle $point.x $point.y $Operation.mouse_button ([int]$Operation.click_count)
$action = [pscustomobject]@{ path = "synthetic"; actionName = $null; fallbackReason = "actionUnsupported" }
} else {
$action = [pscustomobject]@{ path = "accessibility"; actionName = "primaryAction"; fallbackReason = $null }
}
}
"perform_secondary_action" {
if ($null -eq $element) { throw "unknown element_index" }
if (-not (Invoke-OrcaNamedAction $element $Operation.action)) {
throw "$($Operation.action) is not a valid secondary action"
}
$action = [pscustomobject]@{ path = "accessibility"; actionName = $Operation.action; fallbackReason = $null }
}
"scroll" {
$delta = 120 * [int][Math]::Max(1, [Math]::Ceiling([double]$Operation.pages))
if ($Operation.direction -eq "down" -or $Operation.direction -eq "right") { $delta = -1 * $delta }
$point = Get-OrcaElementScreenPoint $element
if ($null -eq $point) { $point = Get-OrcaScreenPoint $Operation $windowFrame }
[void][OrcaDesktopWin32]::PostMessage($handle, $WindowsMessages.Wheel, (ConvertTo-OrcaWheelParam $delta), (ConvertTo-OrcaLParam $point.x $point.y))
$action = [pscustomobject]@{ path = "synthetic"; actionName = "scroll"; fallbackReason = $null }
}
"drag" {
$from = Get-OrcaElementScreenPoint $fromElement
if ($null -eq $from -and $null -ne $Operation.fromElement) { throw "stale element frame; run get-app-state again and use a fresh element index" }
if ($null -eq $from) { $from = @{ x = $windowFrame.x + [double]$Operation.from_x; y = $windowFrame.y + [double]$Operation.from_y } }
$to = Get-OrcaElementScreenPoint $toElement
if ($null -eq $to -and $null -ne $Operation.toElement) { throw "stale element frame; run get-app-state again and use a fresh element index" }
if ($null -eq $to) { $to = @{ x = $windowFrame.x + [double]$Operation.to_x; y = $windowFrame.y + [double]$Operation.to_y } }
Send-OrcaDrag $handle $from $to
$action = [pscustomobject]@{ path = "synthetic"; actionName = "drag"; fallbackReason = $null }
}
"type_text" {
Send-OrcaText $handle ([string]$Operation.text)
$action = [pscustomobject]@{ path = "synthetic"; actionName = "typeText"; fallbackReason = $null }
}
"press_key" {
Send-OrcaKey $handle ([string]$Operation.key)
$action = [pscustomobject]@{ path = "synthetic"; actionName = "pressKey"; fallbackReason = $null }
}
"hotkey" {
Send-OrcaHotkey $handle ([string]$Operation.key)
$action = [pscustomobject]@{ path = "synthetic"; actionName = "hotkey"; fallbackReason = $null; verification = [pscustomobject]@{ state = "unverified"; reason = "synthetic_input" } }
}
"paste_text" {
Send-OrcaPasteText $handle ([string]$Operation.text)
$action = [pscustomobject]@{ path = "clipboard"; actionName = "paste"; fallbackReason = $null; verification = [pscustomobject]@{ state = "unverified"; reason = "clipboard_paste" } }
}
"set_value" {
if ($null -eq $element -or -not (Set-OrcaElementValue $element ([string]$Operation.value))) {
throw "element value is not settable"
}
$action = [pscustomobject]@{ path = "accessibility"; actionName = "setValue"; fallbackReason = $null }
}
default {
throw "unsupported tool: $($Operation.tool)"
}
}
try {
$snapshot = New-OrcaSnapshot $Operation.app $includeScreenshot $Operation.windowId $Operation.windowIndex
} catch {
if ($null -eq $Operation.windowId -and $null -eq $Operation.windowIndex) { throw }
if ($null -eq $action.verification) {
$action | Add-Member -NotePropertyName verification -NotePropertyValue ([pscustomobject]@{ state = "unverified"; reason = "window_changed" })
}
$snapshot = New-OrcaSnapshot $Operation.app $includeScreenshot $null $null
}
[pscustomobject]@{ ok = $true; action = $action; snapshot = $snapshot }
}
try {
$operation = Read-OrcaOperation $OperationPath
Write-OrcaJson (Invoke-OrcaOperation $operation)
} catch {
Write-OrcaJson ([pscustomobject]@{ ok = $false; error = [string]$_.Exception.Message })
}
+7 -3
View File
@@ -25,10 +25,13 @@
"start": "electron-vite preview",
"dev": "node config/scripts/run-electron-vite-dev.mjs",
"build:relay": "node config/scripts/build-relay.mjs",
"build:computer-macos": "node config/scripts/build-computer-macos.mjs",
"smoke:computer": "node config/scripts/computer-use-smoke.mjs",
"verify:computer-native": "node config/scripts/verify-computer-native.mjs",
"build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/install-dev-cli.mjs",
"build:electron-vite": "node config/scripts/run-electron-vite-build.mjs",
"build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli",
"build:release": "pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli",
"build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:computer-macos && pnpm run build:electron-vite && pnpm run build:cli",
"build:release": "pnpm run build:relay && pnpm run build:computer-macos && pnpm run verify:computer-native && pnpm run build:electron-vite && pnpm run build:cli",
"postinstall": "pnpm rebuild electron && node config/scripts/rebuild-native-deps.mjs",
"rebuild:electron": "node config/scripts/rebuild-native-deps.mjs",
"build:unpack": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --dir",
@@ -38,7 +41,8 @@
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
"build:linux": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --linux",
"test:e2e": "npx playwright test --config tests/playwright.config.ts --project electron-headless",
"test:e2e:headful": "npx playwright test --config tests/playwright.config.ts --project electron-headful"
"test:e2e:headful": "npx playwright test --config tests/playwright.config.ts --project electron-headful",
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
</dict>
</plist>
+192
View File
@@ -0,0 +1,192 @@
---
name: computer-use
description: Use Orca's computer-use CLI to inspect and control local desktop apps through accessibility trees, screenshots, and safe UI actions. Use when an agent needs to list desktop apps, get an app state, read visible UI, click, type, press keys, scroll, drag, set values, or perform app accessibility actions. Triggers include "computer use", "orca computer", "list apps", "get app state", "read Spotify", "read Slack", "click app", "type text", "press key", "set value", "scroll app", "drag app", and desktop app interaction tasks.
---
# Computer Use
Use this skill when the task should operate through Orca's desktop computer-use surface rather than native Codex computer tools, raw AppleScript, ad hoc screenshots, or direct app internals.
## Preconditions
- Prefer the public `orca computer ...` command.
- In this Orca worktree, use `./config/scripts/orca-dev computer ...` when testing the local dev runtime.
- Prefer `--json` for agent-driven calls. Screenshot image bytes are omitted from JSON and written to `screenshot.path` when present.
- Do not push, submit forms, send messages, buy items, delete data, or change account settings unless the user explicitly asked for that specific action.
- If an app contains sensitive content, read only what the user requested and avoid unnecessary screenshots or logs.
Check runtime availability first:
```bash
orca status --json
orca computer capabilities --json
```
For local development against this worktree:
```bash
./config/scripts/orca-dev status --json
```
## Core Workflow
Use a snapshot-act-snapshot loop:
1. Discover apps:
```bash
orca computer list-apps --json
```
2. Get a fresh state for the target app:
```bash
orca computer get-app-state --app com.spotify.client --json
```
3. Choose an element from that state.
4. Perform one action:
```bash
orca computer click --app com.spotify.client --element-index 42 --json
```
5. Inspect the action result before deciding whether to act again. Actions return a fresh state:
```bash
orca computer click --app com.spotify.client --element-index 42 --json
```
Element indexes are scoped to the current app state. They can go stale after navigation, focus changes, scrolling, window changes, or app re-rendering. Never carry indexes across unrelated steps without refreshing state.
## App Selectors
Prefer bundle IDs returned by `list-apps`:
```bash
orca computer get-app-state --app com.microsoft.edgemac --json
orca computer get-app-state --app com.spotify.client --json
```
Names are acceptable when unambiguous:
```bash
orca computer get-app-state --app Spotify --json
```
Use `pid:<number>` only when bundle ID or name matching is ambiguous:
```bash
orca computer get-app-state --app pid:12345 --json
```
## Commands
```bash
orca computer permissions --json
orca computer capabilities --json
orca computer list-apps --json
orca computer list-windows --app <app> --json
orca computer get-app-state --app <app> --json
orca computer click --app <app> --element-index <index> --json
orca computer perform-secondary-action --app <app> --element-index <index> --action <name> --json
orca computer set-value --app <app> --element-index <index> --value "text" --json
orca computer type-text --app <app> --text "text" --json
orca computer press-key --app <app> --key Return --json
orca computer hotkey --app <app> --key CmdOrCtrl+A --json
orca computer paste-text --app <app> --text "text" --json
orca computer scroll --app <app> (--element-index <index> | --x <x> --y <y>) --direction down --json
orca computer drag --app <app> --from-x 100 --from-y 100 --to-x 300 --to-y 300 --json
```
Use `--no-screenshot` only when pixels are not needed. Screenshots are often the only useful signal for Electron, WebView, or canvas-heavy apps with shallow accessibility trees.
Coordinates are window-local. Use coordinates from the latest screenshot/state for the same target window.
Use `--text-stdin` or `--value-stdin` for sensitive text so payloads do not land in shell history.
On Linux and Windows, action payloads still pass through a short-lived local operation file.
```bash
printf '%s' "$TEXT" | orca computer set-value --app <app> --element-index <index> --value-stdin --json
```
## Choosing Actions
Prefer semantic actions over raw keyboard input:
- Use `set-value` for known editable fields.
- Use `click` for buttons, tabs, menu items, checkboxes, and other direct controls.
- Use `perform-secondary-action` only when the state lists a concrete action name and the user intent matches it.
- Use `type-text` after focusing a field and confirming the app has a focused text receiver.
- Use `press-key` for navigation keys, Return, Escape, shortcuts, or submitting a field after the state confirms the right target is active.
Why: keyboard input is process-targeted on macOS, but it still depends on the target app having a valid focused receiver. `set-value` targets the accessibility element directly and is more reliable when supported.
## Foreground And Background
Some actions work while the app is in the background. Treat this as app-dependent:
- `set-value` can work in the background when the app exposes a writable accessibility value.
- `click` and accessibility actions may work in the background for some native controls.
- `type-text` and `press-key` are targeted to the app process on macOS, but the app may ignore them unless it owns focus or already has an active text receiver.
If an action returns success but the UI did not change, do not repeat the same action blindly. Run `get-app-state` again, inspect the screenshot/tree, then switch to a more semantic action or bring/focus the target if needed.
## Screenshots
`get-app-state` returns an accessibility tree and, by default, a screenshot. Use both:
- Trust the tree for element indexes, names, roles, values, and actions.
- Trust the screenshot for visual confirmation, especially in Electron and WebView apps.
- If the tree is shallow, use screenshot evidence before deciding whether any action is safe.
- If screenshot capture fails or returns no image, the app may be hidden, minimized, off-screen, or have no visible window.
Use restore only when appropriate for the task:
```bash
orca computer get-app-state --app <app> --restore-window --json
```
## App-Specific Notes
### Browsers
For Edge, Chrome, and similar browsers, prefer setting the address/search field directly:
```bash
orca computer get-app-state --app com.microsoft.edgemac --json
orca computer set-value --app com.microsoft.edgemac --element-index <addressBarIndex> --value "test123" --json
orca computer press-key --app com.microsoft.edgemac --key Return --json
orca computer get-app-state --app com.microsoft.edgemac --json
```
Do not assume raw typing went to the address bar. Confirm the field or page changed after pressing Return.
### Spotify
Spotify state can update asynchronously after playback or network-backed search. After a playback click, run `get-app-state` before clicking again.
For search, prefer `set-value` on the search combobox, usually named like `What do you want to play?`. `type-text` may only work when Spotify owns focus and that field is already focused.
### Slack
Slack may expose a shallow accessibility tree while the screenshot contains the useful information. Reading visible Slack UI is acceptable when requested, but do not send messages or trigger workflows unless explicitly asked.
## Error Handling
- `app_not_found`: run `list-apps` and retry with the bundle ID.
- `element_not_found`: the index is stale; run `get-app-state` again.
- `action_failed`: inspect the element role/actions and try a more semantic action.
- Empty tree or no screenshot: the app may have no visible window, be minimized, or be blocked by permissions.
- Permission errors: the user needs to grant Accessibility or Screen Recording to `Orca Computer Use`. Run `orca computer permissions --json`, use the setup UI, then retry `orca computer get-app-state --app <bundle> --json`.
## Safety Checks
Before acting, classify the action:
- Safe: read state, list apps, inspect screenshot, focus a search box, scroll, open a harmless tab.
- Needs care: typing into a focused field, pressing Return, clicking a primary button.
- Requires explicit user permission: sending messages, posting, purchasing, deleting, submitting forms, changing settings, signing in, or exposing secrets.
When uncertain, stop after `get-app-state` and report what is visible instead of acting.
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { parseArgs } from './args'
describe('parseArgs', () => {
it('keeps an empty string as a flag value', () => {
const parsed = parseArgs(['computer', 'set-value', '--value', '', '--json'])
expect(parsed.commandPath).toEqual(['computer', 'set-value'])
expect(parsed.flags.get('value')).toBe('')
expect(parsed.flags.get('json')).toBe(true)
})
})
+5 -3
View File
@@ -28,8 +28,9 @@ export function parseArgs(argv: string[]): ParsedArgs {
}
const flag = token.slice(2)
const hasNext = i + 1 < argv.length
const next = argv[i + 1]
if (!next || next.startsWith('--')) {
if (!hasNext || next.startsWith('--')) {
flags.set(flag, true)
continue
}
@@ -61,7 +62,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
if (['open', 'status'].includes(commandPath[0])) {
return false
}
if (['repo', 'worktree', 'terminal'].includes(commandPath[0])) {
if (['repo', 'worktree', 'terminal', 'computer'].includes(commandPath[0])) {
return false
}
return ![
@@ -90,7 +91,8 @@ export function isCommandGroup(commandPath: string[]): boolean {
'clipboard',
'dialog',
'storage',
'orchestration'
'orchestration',
'computer'
].includes(commandPath[0])) ||
(commandPath.length === 2 &&
commandPath[0] === 'storage' &&
+3 -1
View File
@@ -13,6 +13,7 @@ import { BROWSER_CAPTURE_HANDLERS } from './handlers/browser-capture'
import { BROWSER_ENV_HANDLERS } from './handlers/browser-env'
import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage'
import { ORCHESTRATION_HANDLERS } from './handlers/orchestration'
import { COMPUTER_HANDLERS } from './handlers/computer'
export type HandlerContext = {
flags: Map<string, string | boolean>
@@ -38,7 +39,8 @@ function buildHandlers(): Map<string, CommandHandler> {
BROWSER_CAPTURE_HANDLERS,
BROWSER_ENV_HANDLERS,
BROWSER_STORAGE_HANDLERS,
ORCHESTRATION_HANDLERS
ORCHESTRATION_HANDLERS,
COMPUTER_HANDLERS
]
for (const group of groups) {
for (const [key, handler] of Object.entries(group)) {
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { getRequiredStringFlagAllowingEmpty } from './flags'
describe('CLI flags', () => {
it('allows required string flags to be empty when the command opts in', () => {
const flags = new Map<string, string | boolean>([['value', '']])
expect(getRequiredStringFlagAllowingEmpty(flags, 'value')).toBe('')
})
})
+11
View File
@@ -8,6 +8,17 @@ export function getRequiredStringFlag(flags: Map<string, string | boolean>, name
throw new RuntimeClientError('invalid_argument', `Missing required --${name}`)
}
export function getRequiredStringFlagAllowingEmpty(
flags: Map<string, string | boolean>,
name: string
): string {
const value = flags.get(name)
if (typeof value === 'string') {
return value
}
throw new RuntimeClientError('invalid_argument', `Missing required --${name}`)
}
export function getOptionalStringFlag(
flags: Map<string, string | boolean>,
name: string
+200 -1
View File
@@ -1,3 +1,7 @@
/* eslint-disable max-lines -- Why: CLI result formatters are centralized so handlers can stay thin RPC glue. */
import { chmodSync, lstatSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type {
BrowserProfileListResult,
BrowserTabCurrentResult,
@@ -7,6 +11,11 @@ import type {
BrowserTabProfileCloneResult,
BrowserTabProfileShowResult,
BrowserTabShowResult,
ComputerActionResult,
ComputerActionVerification,
ComputerListAppsResult,
ComputerListWindowsResult,
ComputerSnapshotResult,
CliStatusResult,
RuntimeRepoList,
RuntimeRepoSearchRefs,
@@ -33,7 +42,7 @@ export function printResult<TResult>(
formatter: (value: TResult) => string
): void {
if (json) {
console.log(JSON.stringify(response, null, 2))
console.log(JSON.stringify(prepareCliJsonResult(response), null, 2))
return
}
console.log(formatter(response.result))
@@ -298,3 +307,193 @@ export function formatTabProfileShow(result: BrowserTabProfileShowResult): strin
export function formatTabProfileClone(result: BrowserTabProfileCloneResult): string {
return `Cloned ${result.sourceBrowserPageId} to ${result.browserPageId} (${result.profileLabel ?? result.profileId ?? 'default'})`
}
export function formatGetAppState(result: ComputerSnapshotResult): string {
const app = result.snapshot.app
const bundle = app.bundleId ? `, ${app.bundleId}` : ''
const focused =
result.snapshot.focusedElementId === null ? 'none' : `#${result.snapshot.focusedElementId}`
const windowId =
result.snapshot.window.id === null || result.snapshot.window.id === undefined
? ''
: ` id:${result.snapshot.window.id}`
const origin =
result.snapshot.window.x === null ||
result.snapshot.window.x === undefined ||
result.snapshot.window.y === null ||
result.snapshot.window.y === undefined
? ''
: ` @ ${result.snapshot.window.x},${result.snapshot.window.y}`
const truncation = result.snapshot.truncation?.truncated
? ` Truncated: yes (max nodes ${result.snapshot.truncation.maxNodes ?? 'unknown'}, max depth ${result.snapshot.truncation.maxDepth ?? 'unknown'})`
: ' Truncated: no'
return [
`${app.name} (pid ${app.pid}${bundle})`,
` Window:${windowId} "${result.snapshot.window.title}" (${result.snapshot.window.width}x${result.snapshot.window.height}${origin})`,
` Elements: ${result.snapshot.elementCount} Focused: ${focused} Coordinates: ${result.snapshot.coordinateSpace}`,
truncation,
` ${formatComputerScreenshotStatus(result)}`,
'',
result.snapshot.treeText
].join('\n')
}
function prepareCliJsonResult<TResult>(
response: RuntimeRpcSuccess<TResult>
): RuntimeRpcSuccess<TResult> {
const record = response as RuntimeRpcSuccess<TResult> & {
result?: { screenshot?: { data?: unknown; format?: unknown; path?: unknown } | null }
}
const screenshot = record.result?.screenshot
if (!screenshot || typeof screenshot.data !== 'string' || screenshot.data.length === 0) {
return response
}
const extension = screenshot.format === 'png' ? 'png' : 'img'
const outputDir = computerScreenshotTempDir()
cleanupComputerScreenshots(outputDir)
const outputPath = join(outputDir, `${safeCliFileStem(response.id)}-screenshot.${extension}`)
writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64'), { mode: 0o600 })
const expiresAt = new Date(Date.now() + COMPUTER_SCREENSHOT_TTL_MS).toISOString()
return {
...response,
result: {
...record.result,
screenshot: {
...screenshot,
data: undefined,
path: outputPath,
dataOmitted: true,
expiresAt
}
}
} as RuntimeRpcSuccess<TResult>
}
const COMPUTER_SCREENSHOT_TTL_MS = 24 * 60 * 60 * 1000
function computerScreenshotTempDir(): string {
const outputDir = join(tmpdir(), 'orca-computer-use')
mkdirSync(outputDir, { recursive: true, mode: 0o700 })
const stat = lstatSync(outputDir)
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error(`Unsafe computer screenshot temp path: ${outputDir}`)
}
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
throw new Error(`Computer screenshot temp path is not owned by the current user: ${outputDir}`)
}
chmodSync(outputDir, 0o700)
return outputDir
}
function cleanupComputerScreenshots(outputDir: string): void {
const cutoff = Date.now() - COMPUTER_SCREENSHOT_TTL_MS
for (const entry of readdirSync(outputDir)) {
if (!entry.endsWith('-screenshot.png') && !entry.endsWith('-screenshot.img')) {
continue
}
const path = join(outputDir, entry)
try {
if (statSync(path).mtimeMs < cutoff) {
rmSync(path, { force: true })
}
} catch {
// Best-effort cleanup only; formatting should not fail because a temp file raced.
}
}
}
function safeCliFileStem(value: string): string {
return value.replaceAll(/[^a-zA-Z0-9._-]/g, '_')
}
export function formatListApps(result: ComputerListAppsResult): string {
if (result.apps.length === 0) {
return 'No apps found.'
}
return result.apps
.map((app) => {
const bundle = app.bundleId ? ` ${app.bundleId}` : ''
return `${app.name} pid:${app.pid}${bundle}`
})
.join('\n')
}
export function formatListWindows(result: ComputerListWindowsResult): string {
if (result.windows.length === 0) {
return `No windows found for ${result.app.name}.`
}
return result.windows
.map((window) => {
const id = window.id === null || window.id === undefined ? 'none' : String(window.id)
const origin =
window.x === null || window.x === undefined || window.y === null || window.y === undefined
? ''
: ` @ ${window.x},${window.y}`
const screen =
window.screenIndex === null || window.screenIndex === undefined
? ''
: ` screen:${window.screenIndex}`
const state = [
window.isMinimized ? 'minimized' : null,
window.isOffscreen ? 'offscreen' : null
].filter(Boolean)
const stateText = state.length > 0 ? ` ${state.join(',')}` : ''
return `[${window.index}] id:${id} "${window.title}" (${window.width}x${window.height}${origin})${screen}${stateText}`
})
.join('\n')
}
export function formatComputerAction(verb: string, result: ComputerActionResult): string {
const path = result.action?.path ? ` via ${result.action.path}` : ''
const verification = formatActionVerification(result.action?.verification)
const app = shellQuote(result.snapshot.app.bundleId ?? result.snapshot.app.name)
const windowId =
result.snapshot.window.id === null || result.snapshot.window.id === undefined
? ''
: ` --window-id ${result.snapshot.window.id}`
return `${formatActionVerb(verb)} completed${path}${verification}; ${result.snapshot.elementCount} elements in current window. Use \`orca computer get-app-state --app ${app}${windowId}\` to inspect.`
}
function formatActionVerification(verification: ComputerActionVerification | undefined): string {
if (!verification) {
return ''
}
if (verification.state === 'verified') {
return `, verified ${verification.property}`
}
return `, unverified (${verification.reason.replaceAll('_', ' ')})`
}
function formatComputerScreenshotStatus(result: ComputerSnapshotResult): string {
if (result.screenshotStatus.state === 'captured' && result.screenshot) {
const bytes = result.screenshot.data
? `${Math.round(result.screenshot.data.length * 0.75)} bytes`
: `saved to ${result.screenshot.path ?? 'temporary file'}`
const engine = result.screenshotStatus.metadata?.engine
const detail = engine
? `${result.screenshot.format}, ${bytes}, ${engine}`
: `${result.screenshot.format}, ${bytes}`
return `Screenshot captured (${detail})`
}
if (result.screenshotStatus.state === 'skipped') {
return 'Screenshot skipped (--no-screenshot)'
}
if (result.screenshotStatus.state === 'failed') {
return `Screenshot failed (${result.screenshotStatus.code}): ${result.screenshotStatus.message}`
}
return 'Screenshot was not captured'
}
function shellQuote(value: string): string {
if (/^[a-zA-Z0-9._:/@-]+$/.test(value)) {
return value
}
return `'${value.replaceAll("'", "'\\''")}'`
}
function formatActionVerb(verb: string): string {
return verb
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
+435
View File
@@ -0,0 +1,435 @@
/* eslint-disable max-lines -- Why: computer CLI coverage shares one mocked runtime setup across command contracts. */
import path from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
vi.mock('../runtime-client', () => {
class RuntimeClient {
call = callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
}
class RuntimeClientError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.code = code
}
}
class RuntimeRpcFailureError extends RuntimeClientError {
readonly response: unknown
constructor(response: unknown) {
super('runtime_error', 'runtime_error')
this.response = response
}
}
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError
}
})
import { main } from '../index'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from '../test-fixtures'
describe('orca computer CLI handlers', () => {
beforeEach(() => {
vi.restoreAllMocks()
callMock.mockReset()
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('prints group help with all computer subcommands', async () => {
await main(['computer', '--help'], '/tmp/repo')
const output = vi.mocked(console.log).mock.calls[0][0]
expect(output).toContain('get-app-state')
expect(output).toContain('hotkey')
expect(output).toContain('capabilities')
expect(output).toContain('list-apps')
expect(output).toContain('list-windows')
expect(output).toContain('paste-text')
expect(output).toContain('permissions')
expect(output).toContain('set-value')
})
it('passes list-apps through with a resolved worktree', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'feature')]),
okFixture('req_apps', { apps: [] })
)
await main(['computer', 'list-apps', '--json'], '/tmp/repo/src')
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(2, 'computer.listApps', {
worktree: `path:${path.resolve('/tmp/repo')}`
})
})
it('prints provider capabilities without resolving a worktree', async () => {
queueFixtures(
callMock,
okFixture('req_capabilities', {
platform: 'darwin',
provider: 'orca-computer-use-macos',
providerVersion: '1.0.0',
protocolVersion: 1,
supports: {
apps: { list: true, bundleIds: true, pids: true },
windows: { list: true, targetById: true, targetByIndex: true },
observation: { screenshot: true, elementFrames: true, annotatedScreenshot: false },
actions: { click: true, setValue: true, pasteText: true },
surfaces: {}
}
})
)
await main(['computer', 'capabilities'], '/tmp/repo/src')
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('computer.capabilities', {})
expect(vi.mocked(console.log).mock.calls[0][0]).toContain('orca-computer-use-macos')
})
it('opens computer permission setup without resolving a worktree', async () => {
queueFixtures(
callMock,
okFixture('req_permissions', {
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
openedSettings: false,
launchedHelper: true
})
)
await main(['computer', 'permissions'], '/tmp/repo/src')
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('computer.permissions', {})
const output = vi.mocked(console.log).mock.calls[0][0]
expect(output).toContain('Opened Orca Computer Use permission setup')
expect(output).toContain('/Applications/Orca Computer Use.app')
})
it('passes get-app-state target and observe flags', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'feature')]),
okFixture('req_state', sampleSnapshot())
)
await main(
[
'computer',
'get-app-state',
'--app',
'Finder',
'--no-screenshot',
'--restore-window',
'--json'
],
'/tmp/repo/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'computer.getAppState', {
app: 'Finder',
worktree: `path:${path.resolve('/tmp/repo')}`,
noScreenshot: true,
restoreWindow: true
})
})
it('passes list-windows target and formats window IDs', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'feature')]),
okFixture('req_windows', {
app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 },
windows: [
{
app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 },
index: 0,
id: 42,
title: 'Recents',
x: 10,
y: 20,
width: 800,
height: 600,
isMinimized: false,
isOffscreen: false,
screenIndex: 0
}
]
})
)
await main(['computer', 'list-windows', '--app', 'Finder'], '/tmp/repo/src')
expect(callMock).toHaveBeenNthCalledWith(2, 'computer.listWindows', {
app: 'Finder',
worktree: `path:${path.resolve('/tmp/repo')}`
})
const output = vi.mocked(console.log).mock.calls[0][0]
expect(output).toContain('[0] id:42 "Recents"')
})
it('does not resolve worktree when --session is explicit', async () => {
queueFixtures(callMock, okFixture('req_click', sampleSnapshot()))
await main(
[
'computer',
'click',
'--session',
'manual',
'--app',
'Finder',
'--element-index',
'3',
'--json'
],
'/tmp/repo/src'
)
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('computer.click', {
session: 'manual',
app: 'Finder',
elementIndex: 3,
x: undefined,
y: undefined,
clickCount: undefined,
mouseButton: undefined,
noScreenshot: undefined
})
})
it('maps action command flags to RPC payloads', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'feature')]),
okFixture('req_drag', sampleSnapshot())
)
await main(
[
'computer',
'drag',
'--app',
'Finder',
'--from-x',
'1',
'--from-y',
'2',
'--to-x',
'3',
'--to-y',
'4',
'--json'
],
'/tmp/repo/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'computer.drag', {
app: 'Finder',
worktree: `path:${path.resolve('/tmp/repo')}`,
fromElementIndex: undefined,
toElementIndex: undefined,
fromX: 1,
fromY: 2,
toX: 3,
toY: 4,
noScreenshot: undefined
})
})
it('maps coordinate scroll flags to RPC payloads', async () => {
queueFixtures(callMock, okFixture('req_scroll', sampleSnapshot()))
await main(
[
'computer',
'scroll',
'--session',
'manual',
'--app',
'Finder',
'--x',
'10',
'--y',
'20',
'--direction',
'down',
'--pages',
'0.5',
'--json'
],
'/tmp/repo/src'
)
expect(callMock).toHaveBeenCalledWith('computer.scroll', {
session: 'manual',
app: 'Finder',
elementIndex: undefined,
x: 10,
y: 20,
direction: 'down',
pages: 0.5,
noScreenshot: undefined
})
})
it('maps hotkey and paste-text command flags to RPC payloads', async () => {
queueFixtures(callMock, okFixture('req_hotkey', sampleSnapshot()))
await main(
[
'computer',
'hotkey',
'--session',
'manual',
'--app',
'Finder',
'--key',
'CmdOrCtrl+L',
'--no-screenshot',
'--json'
],
'/tmp/repo/src'
)
expect(callMock).toHaveBeenCalledWith('computer.hotkey', {
session: 'manual',
app: 'Finder',
key: 'CmdOrCtrl+L',
noScreenshot: true
})
callMock.mockReset()
vi.mocked(console.log).mockClear()
queueFixtures(callMock, okFixture('req_paste', sampleSnapshot()))
await main(
[
'computer',
'paste-text',
'--session',
'manual',
'--app',
'Finder',
'--text',
'hello world',
'--json'
],
'/tmp/repo/src'
)
expect(callMock).toHaveBeenCalledWith('computer.pasteText', {
session: 'manual',
app: 'Finder',
text: 'hello world',
noScreenshot: undefined
})
})
it('formats get-app-state without printing screenshot bytes in pretty mode', async () => {
queueFixtures(callMock, okFixture('req_state', sampleSnapshot()))
await main(['computer', 'get-app-state', '--session', 'manual', '--app', 'Finder'], '/tmp/repo')
const output = vi.mocked(console.log).mock.calls[0][0]
expect(output).toContain('Finder (pid 100, com.apple.finder)')
expect(output).toContain('App=com.apple.finder')
expect(output).not.toContain('base64-data')
})
it('omits screenshot bytes from JSON output and writes a screenshot path', async () => {
queueFixtures(callMock, okFixture('req_state', sampleSnapshot()))
await main(
['computer', 'get-app-state', '--session', 'manual', '--app', 'Finder', '--json'],
'/tmp/repo'
)
const output = vi.mocked(console.log).mock.calls[0][0]
expect(output).not.toContain('base64-data')
const parsed = JSON.parse(output)
expect(parsed.result.screenshot).toMatchObject({
dataOmitted: true,
format: 'png',
expiresAt: expect.any(String),
path: expect.stringContaining('orca-computer-use/req_state-screenshot.png')
})
})
it('shows coordinate space and truncation in pretty state output', async () => {
queueFixtures(callMock, okFixture('req_state', sampleSnapshot()))
await main(['computer', 'get-app-state', '--session', 'manual', '--app', 'Finder'], '/tmp/repo')
const output = vi.mocked(console.log).mock.calls[0][0]
expect(output).toContain('Coordinates: window')
expect(output).toContain('Truncated: no')
})
it('passes get-app-state window target flags through', async () => {
queueFixtures(callMock, okFixture('req_snapshot', sampleSnapshot()))
await main(
[
'computer',
'get-app-state',
'--session',
'manual',
'--app',
'Finder',
'--window-id',
'42',
'--window-index',
'0',
'--no-screenshot',
'--json'
],
'/tmp/repo/src'
)
expect(callMock).toHaveBeenCalledWith('computer.getAppState', {
session: 'manual',
app: 'Finder',
noScreenshot: true,
restoreWindow: undefined,
windowId: 42,
windowIndex: 0
})
})
})
function sampleSnapshot() {
return {
snapshot: {
id: 'snap-test',
app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 },
window: { title: 'Finder', width: 800, height: 600 },
coordinateSpace: 'window',
truncation: { truncated: false, maxNodes: 1200, maxDepth: 64, maxDepthReached: false },
treeText: 'App=com.apple.finder (pid 100)\n0 standard window Finder',
elementCount: 1,
focusedElementId: 0
},
screenshot: {
data: 'base64-data',
format: 'png',
width: 800,
height: 600,
scale: 1
},
screenshotStatus: { state: 'captured' }
}
}
+278
View File
@@ -0,0 +1,278 @@
import type {
ComputerActionResult,
ComputerListAppsResult,
ComputerListWindowsResult,
ComputerProviderCapabilities,
ComputerSnapshotResult
} from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import {
getOptionalNumberFlag,
getOptionalNonNegativeIntegerFlag,
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlagAllowingEmpty,
getRequiredStringFlag
} from '../flags'
import {
formatComputerAction,
formatGetAppState,
formatListApps,
formatListWindows,
printResult
} from '../format'
import { RuntimeClientError } from '../runtime-client'
import { getComputerCommandTarget } from '../selectors'
export const COMPUTER_HANDLERS: Record<string, CommandHandler> = {
'computer capabilities': async ({ client, json }) => {
const result = await client.call<ComputerProviderCapabilities>('computer.capabilities', {})
printResult(result, json, formatComputerCapabilities)
},
'computer list-apps': async ({ flags, client, cwd, json }) => {
const target = await getComputerCommandTarget(flags, cwd, client)
const result = await client.call<ComputerListAppsResult>('computer.listApps', {
worktree: target.worktree
})
printResult(result, json, formatListApps)
},
'computer permissions': async ({ client, json }) => {
const result = await client.call<{
platform: NodeJS.Platform
helperAppPath: string | null
openedSettings: boolean
launchedHelper: boolean
permissions?: { id: string; status: string }[]
nextStep?: string | null
}>('computer.permissions', {})
printResult(result, json, (value) => {
if (value.platform !== 'darwin') {
return 'Computer-use permission setup is only required on macOS.'
}
const firstLine = value.launchedHelper
? 'Opened Orca Computer Use permission setup.'
: 'Computer Use permissions checked.'
return [
firstLine,
`Helper app: ${value.helperAppPath}`,
`Permissions: ${value.permissions?.map((permission) => `${permission.id}=${permission.status}`).join(', ') ?? 'unknown'}`,
value.nextStep
? `Next: ${value.nextStep}`
: 'Computer Use permissions are already granted.',
value.launchedHelper
? 'Use the Allow buttons or drag "Orca Computer Use" into the macOS permission list.'
: null
]
.filter((line) => line !== null)
.join('\n')
})
},
'computer list-windows': async ({ flags, client, cwd, json }) => {
const target = await getComputerCommandTarget(flags, cwd, client)
const result = await client.call<ComputerListWindowsResult>('computer.listWindows', target)
printResult(result, json, formatListWindows)
},
'computer get-app-state': async ({ flags, client, cwd, json }) => {
const target = await getComputerCommandTarget(flags, cwd, client)
const result = await client.call<ComputerSnapshotResult>('computer.getAppState', {
...target,
noScreenshot: flags.has('no-screenshot') ? true : undefined,
restoreWindow: flags.has('restore-window') ? true : undefined,
windowId: getOptionalNumberFlag(flags, 'window-id'),
windowIndex: getOptionalNonNegativeIntegerFlag(flags, 'window-index')
})
printResult(result, json, formatGetAppState)
},
'computer click': async ({ flags, client, cwd, json }) => {
const target = await getComputerCommandTarget(flags, cwd, client)
const result = await client.call<ComputerActionResult>('computer.click', {
...target,
elementIndex: getOptionalNonNegativeIntegerFlag(flags, 'element-index'),
x: getOptionalNumberFlag(flags, 'x'),
y: getOptionalNumberFlag(flags, 'y'),
clickCount: getOptionalPositiveIntegerFlag(flags, 'click-count'),
mouseButton: getOptionalStringFlag(flags, 'mouse-button'),
...getComputerActionObserveFlags(flags)
})
printResult(result, json, (value) => formatComputerAction('click', value))
},
'computer perform-secondary-action': async ({ flags, client, cwd, json }) => {
const target = await getComputerCommandTarget(flags, cwd, client)
const result = await client.call<ComputerActionResult>('computer.performSecondaryAction', {
...target,
elementIndex: getRequiredNonNegativeIntegerFlag(flags, 'element-index'),
action: getRequiredStringFlag(flags, 'action'),
...getComputerActionObserveFlags(flags)
})
printResult(result, json, (value) => formatComputerAction('perform-secondary-action', value))
},
'computer scroll': async ({ flags, client, cwd, json }) => {
const target = await getComputerCommandTarget(flags, cwd, client)
const result = await client.call<ComputerActionResult>('computer.scroll', {
...target,
elementIndex: getOptionalNonNegativeIntegerFlag(flags, 'element-index'),
x: getOptionalNumberFlag(flags, 'x'),
y: getOptionalNumberFlag(flags, 'y'),
direction: getRequiredStringFlag(flags, 'direction'),
pages: getOptionalPositiveNumberFlag(flags, 'pages'),
...getComputerActionObserveFlags(flags)
})
printResult(result, json, (value) => formatComputerAction('scroll', value))
},
'computer drag': async ({ flags, client, cwd, json }) => {
const target = await getComputerCommandTarget(flags, cwd, client)
const result = await client.call<ComputerActionResult>('computer.drag', {
...target,
fromElementIndex: getOptionalNonNegativeIntegerFlag(flags, 'from-element-index'),
toElementIndex: getOptionalNonNegativeIntegerFlag(flags, 'to-element-index'),
fromX: getOptionalNumberFlag(flags, 'from-x'),
fromY: getOptionalNumberFlag(flags, 'from-y'),
toX: getOptionalNumberFlag(flags, 'to-x'),
toY: getOptionalNumberFlag(flags, 'to-y'),
...getComputerActionObserveFlags(flags)
})
printResult(result, json, (value) => formatComputerAction('drag', value))
},
'computer type-text': async ({ flags, client, cwd, json }) => {
const target = await getComputerCommandTarget(flags, cwd, client)
const result = await client.call<ComputerActionResult>('computer.typeText', {
...target,
text: await getTextPayload(flags, 'text'),
...getComputerActionObserveFlags(flags)
})
printResult(result, json, (value) => formatComputerAction('type-text', value))
},
'computer press-key': async ({ flags, client, cwd, json }) => {
const target = await getComputerCommandTarget(flags, cwd, client)
const result = await client.call<ComputerActionResult>('computer.pressKey', {
...target,
key: getRequiredStringFlag(flags, 'key'),
...getComputerActionObserveFlags(flags)
})
printResult(result, json, (value) => formatComputerAction('press-key', value))
},
'computer hotkey': async ({ flags, client, cwd, json }) => {
const target = await getComputerCommandTarget(flags, cwd, client)
const result = await client.call<ComputerActionResult>('computer.hotkey', {
...target,
key: getRequiredStringFlag(flags, 'key'),
...getComputerActionObserveFlags(flags)
})
printResult(result, json, (value) => formatComputerAction('hotkey', value))
},
'computer paste-text': async ({ flags, client, cwd, json }) => {
const target = await getComputerCommandTarget(flags, cwd, client)
const result = await client.call<ComputerActionResult>('computer.pasteText', {
...target,
text: await getTextPayload(flags, 'text'),
...getComputerActionObserveFlags(flags)
})
printResult(result, json, (value) => formatComputerAction('paste-text', value))
},
'computer set-value': async ({ flags, client, cwd, json }) => {
const target = await getComputerCommandTarget(flags, cwd, client)
const result = await client.call<ComputerActionResult>('computer.setValue', {
...target,
elementIndex: getRequiredNonNegativeIntegerFlag(flags, 'element-index'),
value: await getTextPayload(flags, 'value'),
...getComputerActionObserveFlags(flags)
})
printResult(result, json, (value) => formatComputerAction('set-value', value))
}
}
async function getTextPayload(
flags: Map<string, string | boolean>,
name: 'text' | 'value'
): Promise<string> {
const stdinFlag = `${name}-stdin`
if (flags.has(stdinFlag)) {
if (flags.has(name)) {
throw new RuntimeClientError(
'invalid_argument',
`Use either --${name} or --${stdinFlag}, not both`
)
}
return await readStdin()
}
return name === 'value'
? getRequiredStringFlagAllowingEmpty(flags, name)
: getRequiredStringFlag(flags, name)
}
async function readStdin(): Promise<string> {
if (process.stdin.isTTY) {
throw new RuntimeClientError('invalid_argument', 'stdin payload requested but stdin is a TTY')
}
const chunks: Buffer[] = []
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
}
return Buffer.concat(chunks).toString('utf8')
}
function getOptionalPositiveNumberFlag(
flags: Map<string, string | boolean>,
name: string
): number | undefined {
const value = getOptionalNumberFlag(flags, name)
if (value === undefined) {
return undefined
}
if (value <= 0) {
throw new RuntimeClientError('invalid_argument', `Invalid positive number for --${name}`)
}
return value
}
function formatComputerCapabilities(value: ComputerProviderCapabilities): string {
return [
`${value.provider} (${value.platform}, protocol ${value.protocolVersion})`,
` Apps: list=${value.supports.apps.list} bundleIds=${value.supports.apps.bundleIds} pids=${value.supports.apps.pids}`,
` Windows: list=${value.supports.windows.list} targetById=${value.supports.windows.targetById} targetByIndex=${value.supports.windows.targetByIndex}`,
` Observation: screenshot=${value.supports.observation.screenshot} elementFrames=${value.supports.observation.elementFrames} annotatedScreenshot=${value.supports.observation.annotatedScreenshot}`,
` Actions: ${Object.entries(value.supports.actions)
.filter(([, enabled]) => enabled)
.map(([name]) => name)
.join(', ')}`
].join('\n')
}
function getRequiredNonNegativeIntegerFlag(
flags: Map<string, string | boolean>,
name: string
): number {
const value = getOptionalNonNegativeIntegerFlag(flags, name)
if (value === undefined) {
throw new RuntimeClientError('invalid_argument', `Missing required --${name}`)
}
return value
}
function getComputerActionObserveFlags(flags: Map<string, string | boolean>): {
noScreenshot?: boolean
restoreWindow?: boolean
windowId?: number
windowIndex?: number
} {
const observeFlags: {
noScreenshot?: boolean
restoreWindow?: boolean
windowId?: number
windowIndex?: number
} = {
noScreenshot: flags.has('no-screenshot') ? true : undefined
}
if (flags.has('restore-window')) {
observeFlags.restoreWindow = true
}
const windowId = getOptionalNumberFlag(flags, 'window-id')
if (windowId !== undefined) {
observeFlags.windowId = windowId
}
const windowIndex = getOptionalNonNegativeIntegerFlag(flags, 'window-index')
if (windowIndex !== undefined) {
observeFlags.windowIndex = windowIndex
}
return observeFlags
}
+39 -2
View File
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: root and generated command help text live together so CLI discovery stays greppable. */
import type { CommandSpec } from './args'
import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args'
@@ -56,6 +57,21 @@ Orchestration:
orchestration gate-list List decision gates
orchestration reset Reset orchestration state
Computer Use:
computer permissions Open the macOS permission setup for computer-use
computer list-apps List running apps available to computer-use
computer list-windows List visible windows for a target app
computer get-app-state Capture a compact accessibility snapshot of an app
computer click Click an app element or window coordinate
computer perform-secondary-action Run an advertised accessibility action
computer scroll Scroll an app element
computer drag Drag between app elements or window coordinates
computer type-text Type literal text at the current app focus
computer press-key Press a key using xdotool-style syntax
computer hotkey Press a shortcut combination such as CmdOrCtrl+A
computer paste-text Paste text through the native clipboard path
computer set-value Set the value of a settable app element
Browser Automation:
tab create Create a new browser tab (navigates to --url)
tab list List open browser tabs
@@ -290,27 +306,48 @@ export function formatFlagHelp(flag: string): string {
command: '--command <text> Command to run in the terminal on startup',
comment: '--comment <text> Comment stored in Orca metadata',
cursor: '--cursor <n> Line cursor from a previous read (returns only new output)',
direction: '--direction <dir> Direction: horizontal|vertical (split) or up|down (scroll)',
action: '--action <name> Secondary accessibility action name',
app: '--app <app> App name, bundle ID, or pid:N',
direction:
'--direction <dir> Direction: up|down|left|right for scroll, horizontal|vertical for split',
'display-name': '--display-name <name> Override the Orca display name',
'element-index': '--element-index <n> Element index from get-app-state',
title: '--title <text> Custom title for the terminal tab (omit to reset)',
enter: '--enter Append Enter after sending text',
force: '--force Force worktree removal when supported',
for: '--for exit|tui-idle Wait condition to satisfy',
'from-element-index': '--from-element-index <n> Source element index from get-app-state',
'from-x': '--from-x <x> Source window-local x coordinate',
'from-y': '--from-y <y> Source window-local y coordinate',
help: '--help Show this help message',
interrupt: '--interrupt Send as an interrupt-style input when supported',
issue: '--issue <number|null> Linked GitHub issue number',
json: '--json Emit machine-readable JSON',
key: '--key <key> Key or combo to press, e.g. Escape or CmdOrCtrl+L',
limit: '--limit <n> Maximum number of rows to return',
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
name: '--name <name> Name for the new worktree',
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
pages: '--pages <n> Number of scroll pages',
path: '--path <path> Filesystem path to the repo',
query: '--query <text> Search text for matching refs',
ref: '--ref <ref> Base ref to persist for the repo',
repo: '--repo <selector> Repo selector such as id:<id>, name:<name>, or path:<path>',
'restore-window':
'--restore-window Bring the target app/window forward before the operation',
session: '--session <id> Snapshot namespace for a related computer-use workflow',
terminal: '--terminal <handle> Runtime-issued terminal handle',
text: '--text <text> Text to send to the terminal',
text: '--text <text> Text payload to send or type',
'text-stdin': '--text-stdin Read text payload from stdin',
'timeout-ms': '--timeout-ms <ms> Maximum wait time before timing out',
'to-element-index': '--to-element-index <n> Destination element index from get-app-state',
'to-x': '--to-x <x> Destination window-local x coordinate',
'to-y': '--to-y <y> Destination window-local y coordinate',
worktree:
'--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current',
'value-stdin': '--value-stdin Read set-value payload from stdin',
'window-id': '--window-id <id> Target a window id from list-windows',
'window-index': '--window-index <n> Target a window index from list-windows',
// Browser automation flags
element: '--element <ref> Element ref from snapshot (e.g. e3)',
url: '--url <url> URL to navigate to',
+23 -1
View File
@@ -1,5 +1,5 @@
import { isAbsolute, relative, resolve as resolvePath } from 'path'
import type { RuntimeWorktreeListResult } from '../shared/runtime-types'
import type { ComputerAppQuery, RuntimeWorktreeListResult } from '../shared/runtime-types'
import type { RuntimeClient } from './runtime-client'
import { RuntimeClientError } from './runtime-client'
import { getOptionalStringFlag, getRequiredStringFlag } from './flags'
@@ -9,6 +9,12 @@ export type BrowserCliTarget = {
page?: string
}
export type ComputerCliTarget = {
session?: string
worktree?: string
app?: ComputerAppQuery
}
export function buildCurrentWorktreeSelector(cwd: string): string {
return `path:${resolvePath(cwd)}`
}
@@ -150,3 +156,19 @@ export async function getBrowserCommandTarget(
worktree: normalizeWorktreeSelector(explicitWorktree, cwd)
}
}
export async function getComputerCommandTarget(
flags: Map<string, string | boolean>,
cwd: string,
client: RuntimeClient
): Promise<ComputerCliTarget> {
const app = getOptionalStringFlag(flags, 'app')
const session = getOptionalStringFlag(flags, 'session')
if (session) {
return { session, app }
}
return {
app,
worktree: await getBrowserWorktreeSelector(flags, cwd, client)
}
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { COMPUTER_COMMAND_SPECS } from './computer'
describe('computer command specs', () => {
it('allows explicit window targeting on action commands', () => {
const actionSpecs = COMPUTER_COMMAND_SPECS.filter((spec) =>
[
'computer click',
'computer drag',
'computer hotkey',
'computer paste-text',
'computer perform-secondary-action',
'computer press-key',
'computer scroll',
'computer set-value',
'computer type-text'
].includes(spec.path.join(' '))
)
expect(actionSpecs).not.toHaveLength(0)
for (const spec of actionSpecs) {
expect(spec.allowedFlags).toEqual(expect.arrayContaining(['window-id', 'window-index']))
}
})
})
+124
View File
@@ -0,0 +1,124 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
const COMPUTER_FLAGS = [...GLOBAL_FLAGS, 'worktree', 'session', 'app']
const COMPUTER_WINDOW_TARGET_FLAGS = ['window-id', 'window-index']
const COMPUTER_ACTION_FLAGS = [
...COMPUTER_FLAGS,
...COMPUTER_WINDOW_TARGET_FLAGS,
'restore-window',
'no-screenshot'
]
export const COMPUTER_COMMAND_SPECS: CommandSpec[] = [
{
path: ['computer', 'capabilities'],
summary: 'Show computer-use provider capabilities',
usage: 'orca computer capabilities [--json]',
allowedFlags: [...GLOBAL_FLAGS]
},
{
path: ['computer', 'list-apps'],
summary: 'List running apps available to computer-use',
usage: 'orca computer list-apps [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['computer', 'permissions'],
summary: 'Open computer-use permission setup',
usage: 'orca computer permissions [--json]',
allowedFlags: [...GLOBAL_FLAGS]
},
{
path: ['computer', 'list-windows'],
summary: 'List windows for an app available to computer-use',
usage:
'orca computer list-windows --app <name|bundle|pid:N> [--worktree <selector> | --session <id>] [--json]',
allowedFlags: COMPUTER_FLAGS
},
{
path: ['computer', 'get-app-state'],
summary: 'Capture a compact accessibility snapshot of an app',
usage:
'orca computer get-app-state --app <name|bundle|pid:N> [--window-id <id> | --window-index <n>] [--worktree <selector> | --session <id>] [--restore-window] [--no-screenshot] [--json]',
allowedFlags: [...COMPUTER_ACTION_FLAGS, ...COMPUTER_WINDOW_TARGET_FLAGS, 'restore-window']
},
{
path: ['computer', 'click'],
summary: 'Click an app element or window coordinate',
usage:
'orca computer click --app <app> (--element-index <n> | --x <x> --y <y>) [--window-id <id> | --window-index <n>] [--click-count <n>] [--mouse-button <left|right|middle>] [--restore-window] [--no-screenshot] [--json]',
allowedFlags: [
...COMPUTER_ACTION_FLAGS,
'element-index',
'x',
'y',
'click-count',
'mouse-button'
]
},
{
path: ['computer', 'perform-secondary-action'],
summary: 'Perform an advertised secondary accessibility action',
usage:
'orca computer perform-secondary-action --app <app> --element-index <n> --action <name> [--window-id <id> | --window-index <n>] [--restore-window] [--no-screenshot] [--json]',
allowedFlags: [...COMPUTER_ACTION_FLAGS, 'element-index', 'action']
},
{
path: ['computer', 'scroll'],
summary: 'Scroll an app element or window coordinate',
usage:
'orca computer scroll --app <app> (--element-index <n> | --x <x> --y <y>) --direction <up|down|left|right> [--window-id <id> | --window-index <n>] [--pages <n>] [--restore-window] [--no-screenshot] [--json]',
allowedFlags: [...COMPUTER_ACTION_FLAGS, 'element-index', 'x', 'y', 'direction', 'pages']
},
{
path: ['computer', 'drag'],
summary: 'Drag between app elements or window coordinates',
usage:
'orca computer drag --app <app> (--from-element-index <n> --to-element-index <n> | --from-x <x> --from-y <y> --to-x <x> --to-y <y>) [--window-id <id> | --window-index <n>] [--restore-window] [--no-screenshot] [--json]',
allowedFlags: [
...COMPUTER_ACTION_FLAGS,
'from-element-index',
'to-element-index',
'from-x',
'from-y',
'to-x',
'to-y'
]
},
{
path: ['computer', 'type-text'],
summary: 'Type literal text at the current app focus',
usage:
'orca computer type-text --app <app> (--text <text> | --text-stdin) [--window-id <id> | --window-index <n>] [--restore-window] [--no-screenshot] [--json]',
allowedFlags: [...COMPUTER_ACTION_FLAGS, 'text', 'text-stdin']
},
{
path: ['computer', 'press-key'],
summary: 'Press a key using xdotool-style syntax',
usage:
'orca computer press-key --app <app> --key <key> [--window-id <id> | --window-index <n>] [--restore-window] [--no-screenshot] [--json]',
allowedFlags: [...COMPUTER_ACTION_FLAGS, 'key']
},
{
path: ['computer', 'hotkey'],
summary: 'Press a platform-aware key combination',
usage:
'orca computer hotkey --app <app> --key <key-combo> [--window-id <id> | --window-index <n>] [--restore-window] [--no-screenshot] [--json]',
allowedFlags: [...COMPUTER_ACTION_FLAGS, 'key']
},
{
path: ['computer', 'paste-text'],
summary: 'Paste exact text at the current app focus',
usage:
'orca computer paste-text --app <app> (--text <text> | --text-stdin) [--window-id <id> | --window-index <n>] [--restore-window] [--no-screenshot] [--json]',
allowedFlags: [...COMPUTER_ACTION_FLAGS, 'text', 'text-stdin']
},
{
path: ['computer', 'set-value'],
summary: 'Set the value of a settable app element',
usage:
'orca computer set-value --app <app> --element-index <n> (--value <text> | --value-stdin) [--window-id <id> | --window-index <n>] [--restore-window] [--no-screenshot] [--json]',
allowedFlags: [...COMPUTER_ACTION_FLAGS, 'element-index', 'value', 'value-stdin']
}
]
+3 -1
View File
@@ -3,10 +3,12 @@ import { BROWSER_ADVANCED_COMMAND_SPECS } from './browser-advanced'
import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic'
import { CORE_COMMAND_SPECS } from './core'
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
import { COMPUTER_COMMAND_SPECS } from './computer'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
...BROWSER_BASIC_COMMAND_SPECS,
...BROWSER_ADVANCED_COMMAND_SPECS,
...ORCHESTRATION_COMMAND_SPECS
...ORCHESTRATION_COMMAND_SPECS,
...COMPUTER_COMMAND_SPECS
]
@@ -0,0 +1,453 @@
/* eslint-disable max-lines -- Why: desktop provider contract coverage shares one mocked bridge harness. */
import { execFile } from 'child_process'
import { readFileSync } from 'fs'
import { describe, expect, it, vi } from 'vitest'
import { DesktopScriptProviderClient } from './desktop-script-provider-client'
vi.mock('child_process', () => ({
execFile: vi.fn()
}))
describe('DesktopScriptProviderClient', () => {
it('normalizes list-apps responses', async () => {
mockBridgeResponse({
ok: true,
apps: [{ name: 'Notepad', bundleIdentifier: 'notepad', pid: 42 }]
})
const client = new DesktopScriptProviderClient('windows', '/tmp/runtime.ps1')
await expect(client.listApps()).resolves.toEqual({
apps: [
{
name: 'Notepad',
bundleId: 'notepad',
pid: 42,
isRunning: true,
lastUsedAt: null,
useCount: null
}
]
})
})
it('normalizes snapshots and remembers elements for follow-up actions', async () => {
mockBridgeResponse({
ok: true,
snapshot: sampleBridgeSnapshot('Text Editor', 'initial')
})
mockBridgeResponse({
ok: true,
snapshot: sampleBridgeSnapshot('Text Editor', 'changed')
})
const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py')
const initial = await client.snapshot({ app: 'Text Editor' })
expect(initial.snapshot.elementCount).toBe(1)
expect(initial.snapshot.window).toMatchObject({
title: 'Text Editor',
id: 99,
x: 10,
y: 20,
width: 300,
height: 200
})
expect(initial.screenshotStatus).toEqual({
state: 'captured',
metadata: { engine: 'unknown', windowId: 99 }
})
expect(initial.snapshot.treeText).toContain('initial')
expect(publicSnapshotKeys(initial.snapshot)).toEqual([
'app',
'coordinateSpace',
'elementCount',
'focusedElementId',
'id',
'treeText',
'truncation',
'window'
])
await client.action('setValue', {
app: 'Text Editor',
elementIndex: 0,
value: 'changed',
noScreenshot: true
})
const secondCall = vi.mocked(execFile).mock.calls[1]
const operationPath = secondCall[1]?.at(-1)
expect(typeof operationPath).toBe('string')
})
it('targets cached elements by session and explicit window id', async () => {
mockBridgeResponse({
ok: true,
snapshot: sampleBridgeSnapshot('Text Editor', 'initial')
})
mockBridgeResponse(
{
ok: true,
snapshot: sampleBridgeSnapshot('Text Editor', 'changed')
},
(operation) => {
expect(operation).toMatchObject({
tool: 'drag',
app: 'Text Editor',
windowId: 99,
windowIndex: 0,
fromElement: expect.objectContaining({ index: 0 }),
toElement: expect.objectContaining({ index: 0 })
})
expect(operation.from_x).toBeUndefined()
expect(operation.to_x).toBeUndefined()
expect(operation.windowBounds).toBeNull()
}
)
const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py')
await client.snapshot({ app: 'Text Editor', session: 'agent-a', windowId: 99 })
await client.action('drag', {
app: 'Text Editor',
session: 'agent-a',
windowId: 99,
windowIndex: 0,
fromElementIndex: 0,
toElementIndex: 0
})
})
it('forwards restore-window to desktop providers', async () => {
mockBridgeResponse(
{
ok: true,
snapshot: sampleBridgeSnapshot('Text Editor', 'initial')
},
(operation) => {
expect(operation).toMatchObject({
tool: 'get_app_state',
restoreWindow: true
})
}
)
mockBridgeResponse(
{
ok: true,
snapshot: sampleBridgeSnapshot('Text Editor', 'changed')
},
(operation) => {
expect(operation).toMatchObject({
tool: 'set_value',
restoreWindow: true
})
}
)
const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py')
await client.snapshot({ app: 'Text Editor', restoreWindow: true })
const result = await client.action('setValue', {
app: 'Text Editor',
elementIndex: 0,
value: 'changed',
restoreWindow: true,
noScreenshot: true
})
expect(result.action).toMatchObject({
path: 'accessibility',
actionName: 'setValue'
})
})
it('keeps window-index snapshots scoped to the matching session', async () => {
mockBridgeResponse({
ok: true,
snapshot: sampleBridgeSnapshot('Text Editor', 'initial')
})
const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py')
await client.snapshot({ app: 'Text Editor', session: 'agent-a', windowIndex: 0 })
await expect(
client.action('click', {
app: 'Text Editor',
session: 'agent-b',
windowIndex: 0,
elementIndex: 0
})
).rejects.toMatchObject({ code: 'element_not_found' })
})
it('explains screenshot capture failures while keeping accessibility state usable', async () => {
mockBridgeResponse({
ok: true,
snapshot: {
...sampleBridgeSnapshot('Text Editor', 'initial'),
screenshotPngBase64: null
}
})
const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py')
await expect(client.snapshot({ app: 'Text Editor' })).resolves.toMatchObject({
snapshot: {
elementCount: 1
},
screenshotStatus: {
state: 'failed',
code: 'screenshot_failed',
message: expect.stringContaining('--no-screenshot')
}
})
})
it('normalizes list-windows responses after provider handshake', async () => {
mockBridgeResponse({
ok: true,
capabilities: {
platform: 'linux',
provider: 'orca-computer-use-linux',
providerVersion: '1.0.0',
protocolVersion: 1,
supports: {
apps: { list: true, bundleIds: false, pids: true },
windows: {
list: true,
targetById: true,
targetByIndex: true,
focus: false,
moveResize: false
},
observation: {
screenshot: true,
annotatedScreenshot: false,
elementFrames: true,
ocr: false
},
actions: {
click: true,
typeText: true,
pressKey: true,
hotkey: false,
pasteText: false,
scroll: true,
drag: true,
setValue: true,
performAction: true
},
surfaces: { menus: false, dialogs: false, dock: false, menubar: false }
}
}
})
mockBridgeResponse({
ok: true,
app: { name: 'Text Editor', bundleIdentifier: 'Text Editor', pid: 100 },
windows: [
{
index: 0,
app: { name: 'Text Editor', bundleIdentifier: 'Text Editor', pid: 100 },
id: 99,
title: 'Document',
x: 10,
y: 20,
width: 300,
height: 200,
isMinimized: false,
isOffscreen: false,
screenIndex: null,
platform: { backend: 'at-spi' }
}
]
})
const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py')
await expect(client.listWindows({ app: 'Text Editor' })).resolves.toMatchObject({
app: { name: 'Text Editor', bundleId: 'Text Editor', pid: 100 },
windows: [{ index: 0, id: 99, title: 'Document' }]
})
})
it('maps bridge app errors to RuntimeClientError codes', async () => {
mockBridgeResponse({ ok: false, error: 'appNotFound("Missing")' })
const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py')
await expect(client.snapshot({ app: 'Missing' })).rejects.toMatchObject({
code: 'app_not_found'
})
})
it('maps blocked app bridge errors to a policy error', async () => {
mockBridgeResponse({ ok: false, error: 'appBlocked("1Password")' })
const client = new DesktopScriptProviderClient('windows', '/tmp/runtime.ps1')
await expect(client.snapshot({ app: '1Password' })).rejects.toMatchObject({
code: 'app_blocked'
})
})
it('maps action-specific bridge errors to actionable codes', async () => {
mockBridgeResponse({ ok: false, error: 'element value is not settable' })
mockBridgeResponse({ ok: false, error: 'Raise is not a valid secondary action' })
const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py')
await expect(client.snapshot({ app: 'Text Editor' })).rejects.toMatchObject({
code: 'value_not_settable'
})
await expect(client.snapshot({ app: 'Text Editor' })).rejects.toMatchObject({
code: 'action_not_supported'
})
})
it('rejects actions that the provider does not advertise', async () => {
mockBridgeResponse({
ok: true,
capabilities: sampleCapabilities({
hotkey: false,
pasteText: false
})
})
const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py')
await expect(
client.action('pasteText', { app: 'Text Editor', text: 'hello' })
).rejects.toMatchObject({
code: 'unsupported_capability',
message: expect.stringContaining('actions.pasteText')
})
})
it('uses provider action metadata when bridge reports the actual path', async () => {
mockBridgeResponse({
ok: true,
snapshot: sampleBridgeSnapshot('Text Editor', 'initial')
})
mockBridgeResponse({
ok: true,
action: {
path: 'accessibility',
actionName: 'Press',
fallbackReason: null
},
snapshot: sampleBridgeSnapshot('Text Editor', 'changed')
})
const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py')
await client.snapshot({ app: 'Text Editor' })
await expect(
client.action('click', {
app: 'Text Editor',
elementIndex: 0,
noScreenshot: true
})
).resolves.toMatchObject({
action: {
path: 'accessibility',
actionName: 'Press'
}
})
})
it('explains that missing element indexes require a fresh snapshot', async () => {
const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py')
await expect(
client.action('click', { app: 'Text Editor', elementIndex: 4 })
).rejects.toMatchObject({
code: 'element_not_found',
message: expect.stringContaining('run get-app-state again')
})
})
})
function mockBridgeResponse(
response: unknown,
inspectOperation?: (operation: Record<string, unknown>) => void
): void {
vi.mocked(execFile).mockImplementationOnce((_command, _args, _options, callback) => {
const operationPath = _args?.at(-1)
if (inspectOperation && typeof operationPath === 'string') {
inspectOperation(JSON.parse(readFileSync(operationPath, 'utf8')) as Record<string, unknown>)
}
const done = callback as (error: Error | null, stdout: string, stderr: string) => void
done(null, JSON.stringify(response), '')
return null as never
})
}
function sampleBridgeSnapshot(name: string, value: string) {
return {
app: { name, bundleIdentifier: name, pid: 100 },
snapshotId: 'snap-test',
windowTitle: name,
windowId: 99,
windowBounds: { x: 10, y: 20, width: 300, height: 200 },
screenshotPngBase64: 'iVBORw0KGgo=',
coordinateSpace: 'window',
truncation: { truncated: false, maxNodes: 1200, maxDepth: 64, maxDepthReached: false },
treeLines: [`0 text entry area, Value: ${value}`],
focusedSummary: 'text entry area',
elements: [
{
index: 0,
runtimeId: [0, 0],
name: 'Body',
controlType: 'text',
localizedControlType: 'text entry area',
value,
frame: { x: 1, y: 2, width: 100, height: 20 },
actions: ['SetValue']
}
]
}
}
function sampleCapabilities(actions: Partial<Record<string, boolean>> = {}) {
return {
platform: 'linux',
provider: 'orca-computer-use-linux',
providerVersion: '1.0.0',
protocolVersion: 1,
supports: {
apps: { list: true, bundleIds: false, pids: true },
windows: {
list: true,
targetById: true,
targetByIndex: true,
focus: false,
moveResize: false
},
observation: {
screenshot: true,
annotatedScreenshot: false,
elementFrames: true,
ocr: false
},
actions: {
click: true,
typeText: true,
pressKey: true,
hotkey: true,
pasteText: true,
scroll: true,
drag: true,
setValue: true,
performAction: true,
...actions
},
surfaces: { menus: false, dialogs: false, dock: false, menubar: false }
}
}
}
function publicSnapshotKeys(snapshot: unknown): string[] {
return Object.keys(snapshot as Record<string, unknown>).sort()
}
@@ -0,0 +1,742 @@
/* eslint-disable max-lines -- Why: bridge schema, request mapping, and result normalization stay together so platform scripts have one audited contract. */
import { execFile } from 'child_process'
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import type {
ComputerActionMetadata,
ComputerActionResult,
ComputerListAppsResult,
ComputerListWindowsResult,
ComputerProviderCapabilities,
ComputerSnapshotResult
} from '../../shared/runtime-types'
import {
desktopScriptPlatform,
resolveDesktopScriptProviderPath,
type DesktopScriptPlatform
} from './desktop-script-provider-paths'
import { RuntimeClientError } from './runtime-client-error'
type NativeMethod =
| 'handshake'
| 'listApps'
| 'listWindows'
| 'getAppState'
| 'click'
| 'performSecondaryAction'
| 'scroll'
| 'drag'
| 'typeText'
| 'pressKey'
| 'hotkey'
| 'pasteText'
| 'setValue'
type NativeActionMethod = Exclude<
NativeMethod,
'handshake' | 'listApps' | 'listWindows' | 'getAppState'
>
type BridgeFrame = {
x: number
y: number
width: number
height: number
}
type BridgeElement = {
index: number
runtimeId?: unknown
automationId?: string
name?: string
controlType?: string
localizedControlType?: string
className?: string
value?: string
nativeWindowHandle?: number
frame?: BridgeFrame | null
actions?: string[]
}
type BridgeSnapshot = {
snapshotId?: string
app: {
name: string
bundleIdentifier?: string
bundleId?: string
pid: number
}
windowTitle?: string
windowId?: number | null
windowBounds?: BridgeFrame | null
screenshotPngBase64?: string | null
coordinateSpace?: 'window'
truncation?: {
truncated?: boolean
maxNodes?: number
maxDepth?: number
maxDepthReached?: boolean
}
treeLines?: string[]
focusedSummary?: string | null
focusedElementId?: number | null
selectedText?: string | null
elements?: BridgeElement[]
}
type BridgeWindow = {
index: number
app: {
name: string
bundleIdentifier?: string | null
bundleId?: string | null
pid: number
}
id?: number | null
title: string
x?: number | null
y?: number | null
width: number
height: number
isMinimized?: boolean | null
isOffscreen?: boolean | null
screenIndex?: number | null
platform?: Record<string, unknown>
}
type BridgeResponse = {
ok: boolean
error?: string
capabilities?: ComputerProviderCapabilities
apps?: {
name: string
bundleIdentifier?: string | null
bundleId?: string | null
pid: number
}[]
app?: {
name: string
bundleIdentifier?: string | null
bundleId?: string | null
pid: number
}
windows?: BridgeWindow[]
snapshot?: BridgeSnapshot
action?: ComputerActionMetadata
}
type BridgeRequest = {
tool: string
app?: string
element?: BridgeElement
fromElement?: BridgeElement
toElement?: BridgeElement
x?: number
y?: number
from_x?: number
from_y?: number
to_x?: number
to_y?: number
click_count?: number
mouse_button?: string
action?: string
direction?: string
pages?: number
text?: string
key?: string
value?: string
windowBounds?: BridgeFrame | null
windowId?: number
windowIndex?: number
noScreenshot?: boolean
restoreWindow?: boolean
}
const REQUEST_TIMEOUT_MS = 30_000
export function shouldUseDesktopScriptProvider(): boolean {
return desktopScriptPlatform() !== null && resolveDesktopScriptProviderPath() !== null
}
export class DesktopScriptProviderClient {
private readonly snapshots = new Map<string, BridgeSnapshot>()
private providerCapabilities: ComputerProviderCapabilities | null = null
constructor(
private readonly platform: DesktopScriptPlatform = requiredPlatform(),
private readonly scriptPath: string = requiredScriptPath()
) {}
async listApps(): Promise<ComputerListAppsResult> {
const response = await this.callBridge({ tool: 'list_apps' })
return {
apps: (response.apps ?? []).map((app) => ({
name: app.name,
bundleId: app.bundleId ?? app.bundleIdentifier ?? null,
pid: app.pid,
isRunning: true,
lastUsedAt: null,
useCount: null
}))
}
}
async capabilities(): Promise<ComputerProviderCapabilities> {
return await this.readCapabilities()
}
async listWindows(params: Record<string, unknown>): Promise<ComputerListWindowsResult> {
const capabilities = await this.readCapabilities()
if (!capabilities.supports.windows.list) {
throw new RuntimeClientError(
'unsupported_capability',
`${capabilities.provider} does not support windows.list`
)
}
const response = await this.callBridge({
tool: 'list_windows',
app: stringParam(params, 'app')
})
return {
app: normalizeBridgeApp(response.app),
windows: (response.windows ?? []).map((window) => ({
index: window.index,
app: normalizeBridgeApp(window.app),
id: window.id ?? null,
title: window.title,
x: window.x ?? null,
y: window.y ?? null,
width: window.width,
height: window.height,
isMinimized: window.isMinimized ?? null,
isOffscreen: window.isOffscreen ?? null,
screenIndex: window.screenIndex ?? null,
platform: window.platform
}))
}
}
async snapshot(params: Record<string, unknown>): Promise<ComputerSnapshotResult> {
const app = stringParam(params, 'app')
const response = await this.callBridge({
tool: 'get_app_state',
app,
windowId: optionalNumberParam(params, 'windowId'),
windowIndex: optionalNumberParam(params, 'windowIndex'),
noScreenshot: params.noScreenshot === true,
restoreWindow: params.restoreWindow === true
})
return this.rememberAndRender(app, response, params.noScreenshot === true, params)
}
async action(
method: NativeActionMethod,
params: Record<string, unknown>
): Promise<ComputerActionResult> {
const app = stringParam(params, 'app')
await this.ensureActionSupported(method)
const explicitWindowId = optionalNumberParam(params, 'windowId')
const explicitWindowIndex = optionalNumberParam(params, 'windowIndex')
const current = this.currentSnapshot(app, explicitWindowId, params)
const element = elementParam(current, optionalNumberParam(params, 'elementIndex'))
const fromElement = elementParam(current, optionalNumberParam(params, 'fromElementIndex'))
const toElement = elementParam(current, optionalNumberParam(params, 'toElementIndex'))
const response = await this.callBridge({
tool: bridgeTool(method),
app,
element,
fromElement,
toElement,
x: optionalNumberParam(params, 'x'),
y: optionalNumberParam(params, 'y'),
from_x: optionalNumberParam(params, 'fromX'),
from_y: optionalNumberParam(params, 'fromY'),
to_x: optionalNumberParam(params, 'toX'),
to_y: optionalNumberParam(params, 'toY'),
click_count: optionalNumberParam(params, 'clickCount'),
mouse_button: optionalStringParam(params, 'mouseButton'),
action: optionalStringParam(params, 'action'),
direction: optionalStringParam(params, 'direction'),
pages: optionalNumberParam(params, 'pages'),
text: optionalStringParam(params, 'text'),
key: optionalStringParam(params, 'key'),
value: optionalStringParam(params, 'value'),
windowBounds: null,
windowId: explicitWindowId ?? current?.windowId ?? undefined,
windowIndex: explicitWindowIndex,
noScreenshot: params.noScreenshot === true,
restoreWindow: params.restoreWindow === true
})
return {
...this.rememberAndRender(app, response, params.noScreenshot === true, params),
action:
response.action ??
desktopActionMetadata(method, response.snapshot?.windowId ?? current?.windowId ?? null)
}
}
private async ensureActionSupported(method: NativeActionMethod): Promise<void> {
if (method !== 'hotkey' && method !== 'pasteText') {
return
}
const capabilities = await this.readCapabilities()
const actionKey = actionCapabilityKey(method)
if (!capabilities.supports.actions[actionKey]) {
throw new RuntimeClientError(
'unsupported_capability',
`${capabilities.provider} does not support actions.${actionKey}`
)
}
}
private async callBridge(request: BridgeRequest): Promise<BridgeResponse> {
const operationDirectory = await mkdtemp(join(tmpdir(), 'orca-computer-use-'))
const operationPath = join(operationDirectory, 'operation.json')
try {
await writeFile(operationPath, JSON.stringify(request), { encoding: 'utf8', mode: 0o600 })
const { stdout, stderr } = await execBridge(this.platform, this.scriptPath, operationPath)
let response: BridgeResponse
try {
response = JSON.parse(stdout) as BridgeResponse
} catch (error) {
throw new RuntimeClientError(
'accessibility_error',
`desktop provider returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`
)
}
if (!response.ok) {
throw mapBridgeError(response.error ?? stderr)
}
return response
} finally {
await rm(operationDirectory, { force: true, recursive: true })
}
}
private async readCapabilities(): Promise<ComputerProviderCapabilities> {
if (this.providerCapabilities) {
return this.providerCapabilities
}
const response = await this.callBridge({ tool: 'handshake' })
if (!response.capabilities) {
throw new RuntimeClientError(
'accessibility_error',
'desktop provider returned no capabilities'
)
}
this.providerCapabilities = response.capabilities
return response.capabilities
}
private rememberAndRender(
app: string,
response: BridgeResponse,
noScreenshot: boolean,
params: Record<string, unknown>
): ComputerSnapshotResult {
if (!response.snapshot) {
throw new RuntimeClientError('accessibility_error', 'desktop provider returned no snapshot')
}
this.rememberSnapshot(app, response.snapshot, params)
return renderSnapshot(response.snapshot, noScreenshot)
}
private rememberSnapshot(
query: string,
snapshot: BridgeSnapshot,
params: Record<string, unknown>
): void {
const namespace = snapshotNamespace(params)
for (const key of [
query,
snapshot.app.name,
snapshot.app.bundleId,
snapshot.app.bundleIdentifier,
String(snapshot.app.pid),
...snapshotKeysForWindow(query, snapshot),
...snapshotKeysForWindow(snapshot.app.name, snapshot),
...(snapshot.app.bundleId ? snapshotKeysForWindow(snapshot.app.bundleId, snapshot) : []),
...(snapshot.app.bundleIdentifier
? snapshotKeysForWindow(snapshot.app.bundleIdentifier, snapshot)
: []),
...snapshotKeysForWindowIndex(query, params),
...snapshotKeysForWindowIndex(snapshot.app.name, params),
...(snapshot.app.bundleId ? snapshotKeysForWindowIndex(snapshot.app.bundleId, params) : []),
...(snapshot.app.bundleIdentifier
? snapshotKeysForWindowIndex(snapshot.app.bundleIdentifier, params)
: [])
]) {
if (key) {
if (!isExplicitSnapshotNamespace(namespace)) {
this.snapshots.set(key.toLowerCase(), snapshot)
}
this.snapshots.set(namespacedSnapshotKey(namespace, key), snapshot)
}
}
}
private currentSnapshot(
app: string,
windowId: number | undefined,
params: Record<string, unknown>
): BridgeSnapshot | null {
const namespace = snapshotNamespace(params)
if (windowId !== undefined) {
const windowKey = snapshotWindowKey(app, windowId)
return (
this.snapshots.get(namespacedSnapshotKey(namespace, windowKey)) ??
(isExplicitSnapshotNamespace(namespace) ? undefined : this.snapshots.get(windowKey)) ??
null
)
}
const windowIndex = optionalNumberParam(params, 'windowIndex')
if (windowIndex !== undefined) {
const windowIndexKey = snapshotWindowIndexKey(app, windowIndex)
return (
this.snapshots.get(namespacedSnapshotKey(namespace, windowIndexKey)) ??
(isExplicitSnapshotNamespace(namespace) ? undefined : this.snapshots.get(windowIndexKey)) ??
null
)
}
return (
this.snapshots.get(namespacedSnapshotKey(namespace, app)) ??
(isExplicitSnapshotNamespace(namespace)
? undefined
: this.snapshots.get(app.toLowerCase())) ??
null
)
}
}
function execBridge(
platform: DesktopScriptPlatform,
scriptPath: string,
operationPath: string
): Promise<{ stdout: string; stderr: string }> {
const command = platform === 'windows' ? 'powershell.exe' : 'python3'
const args =
platform === 'windows'
? [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
scriptPath,
operationPath
]
: [scriptPath, operationPath]
return new Promise((resolve, reject) => {
execFile(
command,
args,
{
env: process.env,
maxBuffer: 20 * 1024 * 1024,
timeout: REQUEST_TIMEOUT_MS,
windowsHide: true
},
(error, stdout, stderr) => {
if (error) {
const message = stderr.trim() || stdout.trim() || error.message
reject(
error.killed
? new RuntimeClientError('action_timeout', message)
: mapBridgeError(message)
)
return
}
resolve({ stdout, stderr })
}
)
})
}
function renderSnapshot(snapshot: BridgeSnapshot, noScreenshot: boolean): ComputerSnapshotResult {
const bounds = snapshot.windowBounds
const treeText = renderTreeText(snapshot)
const screenshot = snapshot.screenshotPngBase64
? {
data: snapshot.screenshotPngBase64,
format: 'png' as const,
width: Math.max(1, Math.round(bounds?.width ?? 1)),
height: Math.max(1, Math.round(bounds?.height ?? 1)),
scale: 1
}
: null
return {
snapshot: {
// Why: bridge elements can be large; keep them cached internally for actions
// instead of returning duplicated metadata in every agent-facing snapshot.
id: snapshot.snapshotId ?? fallbackSnapshotId(snapshot),
app: {
name: snapshot.app.name,
bundleId: snapshot.app.bundleId ?? snapshot.app.bundleIdentifier ?? null,
pid: snapshot.app.pid
},
window: {
title: snapshot.windowTitle ?? snapshot.app.name,
id: snapshot.windowId ?? null,
x: bounds ? Math.round(bounds.x) : null,
y: bounds ? Math.round(bounds.y) : null,
width: Math.max(0, Math.round(bounds?.width ?? 0)),
height: Math.max(0, Math.round(bounds?.height ?? 0)),
isMinimized: null,
isOffscreen: null,
screenIndex: null
},
coordinateSpace: snapshot.coordinateSpace ?? 'window',
treeText,
elementCount: snapshot.elements?.length ?? 0,
focusedElementId: snapshot.focusedElementId ?? null,
truncation: {
truncated: snapshot.truncation?.truncated === true,
maxNodes: snapshot.truncation?.maxNodes,
maxDepth: snapshot.truncation?.maxDepth,
maxDepthReached: snapshot.truncation?.maxDepthReached === true
}
},
screenshot,
screenshotStatus: screenshot
? {
state: 'captured',
metadata: { engine: 'unknown', windowId: snapshot.windowId ?? null }
}
: noScreenshot
? { state: 'skipped', reason: 'no_screenshot_flag' }
: {
state: 'failed',
code: 'screenshot_failed',
message:
'desktop provider returned no image; grant screen capture permission or pass --no-screenshot to inspect accessibility state only.'
}
}
}
function fallbackSnapshotId(snapshot: BridgeSnapshot): string {
const appRef = snapshot.app.bundleId ?? snapshot.app.bundleIdentifier ?? snapshot.app.name
return `${appRef}:${snapshot.app.pid}:${snapshot.windowId ?? 'window'}`
}
function renderTreeText(snapshot: BridgeSnapshot): string {
const appRef = snapshot.app.bundleId ?? snapshot.app.bundleIdentifier ?? snapshot.app.name
const lines = [
`App=${appRef} (pid ${snapshot.app.pid})`,
`Window: "${sanitize(snapshot.windowTitle ?? snapshot.app.name)}", App: ${sanitize(snapshot.app.name)}.`,
'',
...(snapshot.treeLines ?? [])
]
if (snapshot.selectedText) {
lines.push('', `Selected text: [${sanitize(snapshot.selectedText)}]`)
} else if (snapshot.focusedSummary) {
lines.push('', `The focused UI element is ${sanitize(snapshot.focusedSummary)}.`)
}
return lines.join('\n')
}
function bridgeTool(method: NativeActionMethod): string {
return {
click: 'click',
performSecondaryAction: 'perform_secondary_action',
scroll: 'scroll',
drag: 'drag',
typeText: 'type_text',
pressKey: 'press_key',
hotkey: 'hotkey',
pasteText: 'paste_text',
setValue: 'set_value'
}[method]
}
function actionCapabilityKey(
method: NativeActionMethod
): keyof ComputerProviderCapabilities['supports']['actions'] {
const keys = {
click: 'click',
performSecondaryAction: 'performAction',
scroll: 'scroll',
drag: 'drag',
typeText: 'typeText',
pressKey: 'pressKey',
hotkey: 'hotkey',
pasteText: 'pasteText',
setValue: 'setValue'
} satisfies Record<NativeActionMethod, keyof ComputerProviderCapabilities['supports']['actions']>
return keys[method]
}
function desktopActionMetadata(method: NativeActionMethod, targetWindowId: number | null) {
const path =
method === 'pasteText'
? ('clipboard' as const)
: method === 'setValue' || method === 'performSecondaryAction'
? ('accessibility' as const)
: ('synthetic' as const)
return {
path,
actionName:
method === 'hotkey'
? 'hotkey'
: method === 'pasteText'
? 'paste'
: method === 'setValue'
? 'setValue'
: method === 'performSecondaryAction'
? 'performSecondaryAction'
: null,
fallbackReason: null,
targetWindowId,
verification:
method === 'hotkey' || method === 'pasteText'
? {
state: 'unverified' as const,
reason:
method === 'pasteText' ? ('clipboard_paste' as const) : ('synthetic_input' as const)
}
: undefined
}
}
function normalizeBridgeApp(app: BridgeResponse['app'] | BridgeWindow['app'] | undefined) {
if (!app) {
throw new RuntimeClientError('accessibility_error', 'desktop provider returned no app')
}
return {
name: app.name,
bundleId: app.bundleId ?? app.bundleIdentifier ?? null,
pid: app.pid
}
}
function elementParam(
snapshot: BridgeSnapshot | null,
index: number | undefined
): BridgeElement | undefined {
if (index === undefined) {
return undefined
}
const element = snapshot?.elements?.find((candidate) => candidate.index === index)
if (!element) {
throw new RuntimeClientError(
'element_not_found',
`element ${index} is not in the current cached snapshot; run get-app-state again and use a fresh element index`
)
}
return element
}
function snapshotKeysForWindow(query: string, snapshot: BridgeSnapshot): string[] {
return snapshot.windowId === null || snapshot.windowId === undefined
? []
: [snapshotWindowKey(query, snapshot.windowId)]
}
function snapshotWindowKey(query: string, windowId: number): string {
return `${query.toLowerCase()}#window:${windowId}`
}
function snapshotKeysForWindowIndex(query: string, params: Record<string, unknown>): string[] {
const windowIndex = optionalNumberParam(params, 'windowIndex')
return windowIndex === undefined ? [] : [snapshotWindowIndexKey(query, windowIndex)]
}
function snapshotWindowIndexKey(query: string, windowIndex: number): string {
return `${query.toLowerCase()}#window-index:${windowIndex}`
}
function snapshotNamespace(params: Record<string, unknown>): string {
const session = optionalStringParam(params, 'session')
const worktree = optionalStringParam(params, 'worktree')
return session ? `session:${session}` : worktree ? `worktree:${worktree}` : 'default'
}
function namespacedSnapshotKey(namespace: string, key: string): string {
return `${namespace}:${key.toLowerCase()}`
}
function isExplicitSnapshotNamespace(namespace: string): boolean {
return namespace !== 'default'
}
function mapBridgeError(message: string): RuntimeClientError {
const text = message.trim() || 'desktop provider failed'
if (/appNotFound|app not found/i.test(text)) {
return new RuntimeClientError('app_not_found', text)
}
if (/appBlocked|app blocked/i.test(text)) {
return new RuntimeClientError('app_blocked', text)
}
if (/unsupported capability|hotkey.*require|paste_text requires/i.test(text)) {
return new RuntimeClientError('unsupported_capability', text)
}
if (/ModuleNotFoundError: No module named 'gi'|PyGObject|python3-gi/i.test(text)) {
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.'
)
}
if (/not a valid secondary action|action.*not supported/i.test(text)) {
return new RuntimeClientError('action_not_supported', text)
}
if (/value is not settable|not settable/i.test(text)) {
return new RuntimeClientError('value_not_settable', text)
}
if (/stale element|fresh element index/i.test(text)) {
return new RuntimeClientError('element_not_found', text)
}
if (/windowStale|window stale/i.test(text)) {
return new RuntimeClientError('window_stale', text)
}
if (/No top-level|No .*window|window/i.test(text)) {
return new RuntimeClientError('window_not_found', text)
}
if (/permission|desktop session|DBUS|XDG_RUNTIME_DIR|AT-SPI/i.test(text)) {
return new RuntimeClientError('permission_denied', text)
}
if (/element|element_index/i.test(text)) {
return new RuntimeClientError('element_not_found', text)
}
return new RuntimeClientError('accessibility_error', text)
}
function requiredPlatform(): DesktopScriptPlatform {
const platform = desktopScriptPlatform()
if (!platform) {
throw new RuntimeClientError('accessibility_error', 'desktop script provider is not available')
}
return platform
}
function requiredScriptPath(): string {
const scriptPath = resolveDesktopScriptProviderPath()
if (!scriptPath) {
throw new RuntimeClientError(
'accessibility_error',
'desktop script provider script was not found'
)
}
return scriptPath
}
function stringParam(params: Record<string, unknown>, key: string): string {
const value = params[key]
if (typeof value !== 'string' || value.length === 0) {
throw new RuntimeClientError('invalid_argument', `missing ${key}`)
}
return value
}
function optionalStringParam(params: Record<string, unknown>, key: string): string | undefined {
const value = params[key]
return typeof value === 'string' ? value : undefined
}
function optionalNumberParam(params: Record<string, unknown>, key: string): number | undefined {
const value = params[key]
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
function sanitize(value: string): string {
return value.replaceAll('\n', ' ').replaceAll('\r', ' ')
}
@@ -0,0 +1,39 @@
import { existsSync } from 'fs'
import { join, resolve } from 'path'
export type DesktopScriptPlatform = 'linux' | 'windows'
export function desktopScriptPlatform(): DesktopScriptPlatform | null {
if (process.platform === 'linux') {
return 'linux'
}
if (process.platform === 'win32') {
return 'windows'
}
return null
}
export function resolveDesktopScriptProviderPath(
platform = desktopScriptPlatform()
): string | null {
const override = process.env.ORCA_COMPUTER_DESKTOP_SCRIPT_PROVIDER_PATH
if (override && existsSync(override)) {
return override
}
if (!platform) {
return null
}
const filename = platform === 'windows' ? 'runtime.ps1' : 'runtime.py'
const directory = platform === 'windows' ? 'computer-use-windows' : 'computer-use-linux'
const sourceDirectory =
platform === 'windows' ? 'native/computer-use-windows' : 'native/computer-use-linux'
const packaged = [join(process.resourcesPath ?? '', directory, filename)]
const dev = [
join(process.cwd(), sourceDirectory, filename),
resolve(__dirname, '../../', sourceDirectory, filename)
]
const candidates = process.resourcesPath ? [...packaged, ...dev] : dev
return candidates.find((candidate) => candidate && existsSync(candidate)) ?? null
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { parseKey } from './key-mapping'
describe('parseKey', () => {
it.each([
['a', { key: 'a', modifiers: [] }],
['7', { key: '7', modifiers: [] }],
[';', { key: ';', modifiers: [] }],
['Return', { key: 'Enter', modifiers: [] }],
['Escape', { key: 'Escape', modifiers: [] }],
['BackSpace', { key: 'Backspace', modifiers: [] }],
['Tab', { key: 'Tab', modifiers: [] }],
['space', { key: 'Space', modifiers: [] }],
['Page_Up', { key: 'PageUp', modifiers: [] }],
['Page_Down', { key: 'PageDown', modifiers: [] }],
['Home', { key: 'Home', modifiers: [] }],
['End', { key: 'End', modifiers: [] }],
['Up', { key: 'ArrowUp', modifiers: [] }],
['Down', { key: 'ArrowDown', modifiers: [] }],
['Left', { key: 'ArrowLeft', modifiers: [] }],
['Right', { key: 'ArrowRight', modifiers: [] }],
['Delete', { key: 'Delete', modifiers: [] }],
['Insert', { key: 'Insert', modifiers: [] }],
['F1', { key: 'F1', modifiers: [] }],
['F24', { key: 'F24', modifiers: [] }],
['KP_0', { key: 'Numpad0', modifiers: [] }],
['KP_9', { key: 'Numpad9', modifiers: [] }],
['KP_Add', { key: 'NumpadAdd', modifiers: [] }],
['KP_Subtract', { key: 'NumpadSubtract', modifiers: [] }],
['KP_Multiply', { key: 'NumpadMultiply', modifiers: [] }],
['KP_Divide', { key: 'NumpadDivide', modifiers: [] }],
['KP_Enter', { key: 'NumpadEnter', modifiers: [] }]
])('maps %s', (input, expected) => {
expect(parseKey(input)).toEqual(expected)
})
it.each([
['ctrl+a', { key: 'a', modifiers: ['Ctrl'] }],
['control+a', { key: 'a', modifiers: ['Ctrl'] }],
['ctrl+shift+t', { key: 't', modifiers: ['Ctrl', 'Shift'] }],
['alt+F4', { key: 'F4', modifiers: ['Alt'] }],
['cmd+a', { key: 'a', modifiers: ['Meta'] }],
['command+a', { key: 'a', modifiers: ['Meta'] }],
['CmdOrCtrl+a', { key: 'a', modifiers: [process.platform === 'darwin' ? 'Meta' : 'Ctrl'] }],
['super+Left', { key: 'ArrowLeft', modifiers: ['Meta'] }],
['win+Right', { key: 'ArrowRight', modifiers: ['Meta'] }]
])('maps chord %s', (input, expected) => {
expect(parseKey(input)).toEqual(expected)
})
it('rejects unknown modifiers and key names', () => {
expect(() => parseKey('hyper+a')).toThrow(expect.objectContaining({ code: 'invalid_argument' }))
expect(() => parseKey('NotAKey')).toThrow(expect.objectContaining({ code: 'invalid_argument' }))
expect(() => parseKey('F25')).toThrow(expect.objectContaining({ code: 'invalid_argument' }))
})
})
+109
View File
@@ -0,0 +1,109 @@
import { RuntimeClientError } from './runtime-client-error'
export type KeyChord = {
key: string
modifiers: string[]
}
const MODIFIER_NAMES: Record<string, string> = {
ctrl: 'Ctrl',
control: 'Ctrl',
shift: 'Shift',
alt: 'Alt',
meta: 'Meta',
super: 'Meta',
cmd: 'Meta',
command: 'Meta',
win: 'Meta'
}
const PLATFORM_MODIFIER_NAMES: Record<string, () => string> = {
cmdorctrl: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl'),
commandorcontrol: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl')
}
const KEY_NAMES: Record<string, string> = {
Return: 'Enter',
Enter: 'Enter',
Escape: 'Escape',
Esc: 'Escape',
BackSpace: 'Backspace',
Backspace: 'Backspace',
Tab: 'Tab',
space: 'Space',
Space: 'Space',
Page_Up: 'PageUp',
Page_Down: 'PageDown',
Home: 'Home',
End: 'End',
Up: 'ArrowUp',
Down: 'ArrowDown',
Left: 'ArrowLeft',
Right: 'ArrowRight',
Delete: 'Delete',
Insert: 'Insert',
KP_Add: 'NumpadAdd',
KP_Subtract: 'NumpadSubtract',
KP_Multiply: 'NumpadMultiply',
KP_Divide: 'NumpadDivide',
KP_Enter: 'NumpadEnter'
}
export function parseKey(input: string): KeyChord {
const parts = input
.split('+')
.map((part) => part.trim())
.filter(Boolean)
if (parts.length === 0) {
throw new RuntimeClientError('invalid_argument', 'key must not be empty')
}
const keyPart = parts.at(-1)!
const modifiers = parts.slice(0, -1).map(parseModifier)
return {
key: parseBaseKey(keyPart),
modifiers: dedupeModifiers(modifiers)
}
}
function parseModifier(input: string): string {
const platformModifier = PLATFORM_MODIFIER_NAMES[input.toLowerCase()]
if (platformModifier) {
return platformModifier()
}
const modifier = MODIFIER_NAMES[input.toLowerCase()]
if (!modifier) {
throw new RuntimeClientError('invalid_argument', `unknown modifier '${input}'`)
}
return modifier
}
function parseBaseKey(input: string): string {
const mapped = KEY_NAMES[input]
if (mapped) {
return mapped
}
if (/^F([1-9]|1[0-9]|2[0-4])$/.test(input)) {
return input
}
const keypadDigit = input.match(/^KP_([0-9])$/)
if (keypadDigit) {
return `Numpad${keypadDigit[1]}`
}
if (isPrintableAscii(input)) {
return input
}
throw new RuntimeClientError('invalid_argument', `unknown key '${input}'`)
}
function isPrintableAscii(input: string): boolean {
if (input.length !== 1) {
return false
}
const code = input.charCodeAt(0)
return code >= 0x20 && code <= 0x7e
}
function dedupeModifiers(modifiers: string[]): string[] {
return [...new Set(modifiers)]
}
+121
View File
@@ -0,0 +1,121 @@
import { execFile } from 'child_process'
import type { ComputerAppInfo } from '../../shared/runtime-types'
type MacOSRunningApp = ComputerAppInfo & {
activationPolicy: number
isFrontmost: boolean
}
const JXA_LIST_RUNNING_APPS = `
ObjC.import('AppKit');
const apps = $.NSWorkspace.sharedWorkspace.runningApplications;
const out = [];
for (let i = 0; i < apps.count; i += 1) {
const app = apps.objectAtIndex(i);
if (app.isTerminated) continue;
const name = ObjC.unwrap(app.localizedName) || '';
const bundleId = ObjC.unwrap(app.bundleIdentifier) || null;
const pid = Number(app.processIdentifier);
const activationPolicy = Number(app.activationPolicy);
if (!name || !Number.isInteger(pid) || pid <= 0) continue;
out.push({
name,
bundleId,
pid,
activationPolicy,
isFrontmost: Boolean(app.active)
});
}
JSON.stringify(out);
`
export async function listMacOSApps(): Promise<ComputerAppInfo[]> {
const { stdout } = await execFilePromise(
'osascript',
['-l', 'JavaScript', '-e', JXA_LIST_RUNNING_APPS],
{
encoding: 'utf8',
timeout: 5000,
maxBuffer: 1024 * 1024
}
)
const apps = parseMacOSApps(stdout).filter(hasLivePid)
return dedupeApps(apps.filter(isUserFacingApp)).map(({ name, bundleId, pid }) => ({
name,
bundleId,
pid
}))
}
function parseMacOSApps(stdout: string): MacOSRunningApp[] {
const parsed: unknown = JSON.parse(stdout)
if (!Array.isArray(parsed)) {
return []
}
return parsed.flatMap((value): MacOSRunningApp[] => {
if (!value || typeof value !== 'object') {
return []
}
const record = value as Record<string, unknown>
const name = typeof record.name === 'string' ? record.name : ''
const bundleId = typeof record.bundleId === 'string' ? record.bundleId : null
const pid = typeof record.pid === 'number' && Number.isInteger(record.pid) ? record.pid : 0
const activationPolicy =
typeof record.activationPolicy === 'number' && Number.isInteger(record.activationPolicy)
? record.activationPolicy
: -1
const isFrontmost = record.isFrontmost === true
return name && pid > 0 ? [{ name, bundleId, pid, activationPolicy, isFrontmost }] : []
})
}
function isUserFacingApp(app: MacOSRunningApp): boolean {
return app.activationPolicy === 0
}
function hasLivePid(app: MacOSRunningApp): boolean {
try {
process.kill(app.pid, 0)
return true
} catch {
// NSWorkspace can briefly retain terminated apps; filter them before provider calls.
return false
}
}
function dedupeApps(apps: MacOSRunningApp[]): MacOSRunningApp[] {
const seen = new Set<string>()
const sorted = [...apps].sort((a, b) => {
if (a.isFrontmost !== b.isFrontmost) {
return a.isFrontmost ? -1 : 1
}
return a.name.localeCompare(b.name)
})
const result: MacOSRunningApp[] = []
for (const app of sorted) {
const key = app.bundleId ? `bundle:${app.bundleId.toLowerCase()}` : `pid:${app.pid}`
if (seen.has(key)) {
continue
}
seen.add(key)
result.push(app)
}
return result
}
function execFilePromise(
file: string,
args: string[],
options: { encoding: 'utf8'; timeout: number; maxBuffer: number }
): Promise<{ stdout: string }> {
return new Promise((resolve, reject) => {
execFile(file, args, options, (error, stdout, stderr) => {
if (error) {
const detail = typeof stderr === 'string' && stderr.trim() ? `: ${stderr.trim()}` : ''
reject(new Error(`${error.message}${detail}`))
return
}
resolve({ stdout })
})
})
}
@@ -0,0 +1,180 @@
import { spawn, spawnSync } from 'child_process'
import { existsSync, readFileSync, rmSync } from 'fs'
import type * as Fs from 'fs'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { openComputerUsePermissions } from './macos-computer-use-permissions'
const resolveHelperAppPathMock = vi.hoisted(() => vi.fn())
vi.mock('child_process', () => ({
spawn: vi.fn(() => ({ unref: vi.fn() })),
spawnSync: vi.fn()
}))
vi.mock('fs', async (importOriginal) => {
const actual = (await importOriginal()) as typeof Fs
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
rmSync: vi.fn()
}
})
vi.mock('./macos-native-provider-paths', () => ({
resolveMacOSComputerUseAppPath: resolveHelperAppPathMock
}))
describe('openComputerUsePermissions', () => {
const originalPlatform = process.platform
beforeEach(() => {
vi.mocked(spawn).mockClear()
vi.mocked(spawnSync).mockClear()
vi.mocked(existsSync).mockReset()
vi.mocked(readFileSync).mockReset()
vi.mocked(rmSync).mockReset()
resolveHelperAppPathMock.mockReset()
mockPermissionStatus('{"accessibility":"granted","screenshots":"granted"}')
setPlatform('darwin')
})
afterEach(() => {
setPlatform(originalPlatform)
})
it('does not launch the setup helper when all permissions are granted', () => {
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
expect(openComputerUsePermissions()).toEqual({
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
permissionId: undefined,
openedSettings: false,
launchedHelper: false,
permissions: [
{ id: 'accessibility', status: 'granted' },
{ id: 'screenshots', status: 'granted' }
],
nextStep: null
})
expect(spawn).not.toHaveBeenCalled()
})
it('launches the helper app in permissions mode', () => {
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}')
expect(openComputerUsePermissions()).toEqual({
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
permissionId: undefined,
openedSettings: false,
launchedHelper: true,
permissions: [
{ id: 'accessibility', status: 'granted' },
{ id: 'screenshots', status: 'not-granted' }
],
nextStep: 'Grant Screen Recording to Orca Computer Use, then retry get-app-state.'
})
expect(spawn).toHaveBeenCalledTimes(1)
expect(spawnSync).toHaveBeenCalledWith(
'/usr/bin/pkill',
['-f', 'orca-computer-use-macos --permission'],
{ stdio: 'ignore' }
)
expect(spawnSync).toHaveBeenCalledWith(
'/usr/bin/pkill',
['-f', 'orca-computer-use-macos --permissions'],
{ stdio: 'ignore' }
)
expect(spawn).toHaveBeenCalledWith(
'/usr/bin/open',
['-n', '/Applications/Orca Computer Use.app', '--args', '--permissions'],
{ detached: true, stdio: 'ignore' }
)
})
it('launches a targeted permission helper flow', () => {
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
mockPermissionStatus('{"accessibility":"not-granted","screenshots":"not-granted"}')
expect(openComputerUsePermissions('accessibility')).toEqual({
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
permissionId: 'accessibility',
openedSettings: true,
launchedHelper: true,
permissions: [
{ id: 'accessibility', status: 'not-granted' },
{ id: 'screenshots', status: 'not-granted' }
],
nextStep: 'Grant Accessibility to Orca Computer Use, then retry get-app-state.'
})
expect(spawn).toHaveBeenCalledWith(
'/usr/bin/open',
['-n', '/Applications/Orca Computer Use.app', '--args', '--permission', 'accessibility'],
{ detached: true, stdio: 'ignore' }
)
})
it('returns a no-op result on non-macOS platforms', () => {
setPlatform('linux')
expect(openComputerUsePermissions()).toEqual({
platform: 'linux',
helperAppPath: null,
permissionId: undefined,
openedSettings: false,
launchedHelper: false,
permissions: [
{ id: 'accessibility', status: 'unsupported' },
{ id: 'screenshots', status: 'unsupported' }
],
nextStep: null
})
expect(spawn).not.toHaveBeenCalled()
})
it('throws when the helper app is missing on macOS', () => {
resolveHelperAppPathMock.mockReturnValue(null)
expect(() => openComputerUsePermissions()).toThrow('Orca Computer Use.app was not found')
})
it('reads permission status through the helper app bundle', async () => {
const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions')
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}')
expect(getComputerUsePermissionStatus()).toEqual({
platform: 'darwin',
permissions: [
{ id: 'accessibility', status: 'granted' },
{ id: 'screenshots', status: 'not-granted' }
]
})
expect(spawnSync).toHaveBeenCalledWith(
'/usr/bin/open',
[
'-n',
'/Applications/Orca Computer Use.app',
'--args',
'--permission-status-file',
expect.stringContaining('/status.json')
],
{ stdio: 'ignore' }
)
expect(rmSync).toHaveBeenCalled()
})
})
function mockPermissionStatus(json: string): void {
vi.mocked(spawnSync).mockReturnValue({} as ReturnType<typeof spawnSync>)
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(readFileSync).mockReturnValue(json)
}
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
}
@@ -0,0 +1,153 @@
import { spawn, spawnSync } from 'child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { RuntimeClientError } from './runtime-client-error'
import { resolveMacOSComputerUseAppPath } from './macos-native-provider-paths'
import type {
ComputerUsePermissionId,
ComputerUsePermissionSetupResult,
ComputerUsePermissionStatus,
ComputerUsePermissionStatusResult
} from '../../shared/computer-use-permissions-types'
export function openComputerUsePermissions(
permissionId?: ComputerUsePermissionId
): ComputerUsePermissionSetupResult {
if (process.platform !== 'darwin') {
return {
platform: process.platform,
helperAppPath: null,
permissionId,
openedSettings: false,
launchedHelper: false,
permissions: [
{ id: 'accessibility', status: 'unsupported' },
{ id: 'screenshots', status: 'unsupported' }
],
nextStep: null
}
}
const helperAppPath = resolveMacOSComputerUseAppPath()
if (!helperAppPath) {
throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found')
}
const status = getComputerUsePermissionStatus()
const nextStep = nextPermissionStep(status.permissions)
if (!permissionId && !nextStep) {
return {
platform: process.platform,
helperAppPath,
permissionId,
openedSettings: false,
launchedHelper: false,
permissions: status.permissions,
nextStep
}
}
closeExistingPermissionHelpers()
const helperArgs = permissionId ? ['--permission', permissionId] : ['--permissions']
const helper = spawn('/usr/bin/open', ['-n', helperAppPath, '--args', ...helperArgs], {
detached: true,
stdio: 'ignore'
})
helper.unref()
return {
platform: process.platform,
helperAppPath,
permissionId,
openedSettings: permissionId !== undefined,
launchedHelper: true,
permissions: status.permissions,
nextStep
}
}
function closeExistingPermissionHelpers(): void {
spawnSync('/usr/bin/pkill', ['-f', 'orca-computer-use-macos --permission'], {
stdio: 'ignore'
})
spawnSync('/usr/bin/pkill', ['-f', 'orca-computer-use-macos --permissions'], {
stdio: 'ignore'
})
}
export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusResult {
if (process.platform !== 'darwin') {
return {
platform: process.platform,
permissions: [
{ id: 'accessibility', status: 'unsupported' },
{ id: 'screenshots', status: 'unsupported' }
]
}
}
const helperAppPath = resolveMacOSComputerUseAppPath()
if (!helperAppPath) {
throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found')
}
const raw = readPermissionStatusFromHelperApp(helperAppPath)
return {
platform: process.platform,
permissions: [
{ id: 'accessibility', status: raw.accessibility ?? 'not-granted' },
{ id: 'screenshots', status: raw.screenshots ?? 'not-granted' }
]
}
}
function readPermissionStatusFromHelperApp(
helperAppPath: string
): Partial<Record<ComputerUsePermissionId, ComputerUsePermissionStatus>> {
const directory = mkdtempSync(join(tmpdir(), 'orca-computer-permissions-'))
const statusPath = join(directory, 'status.json')
try {
// Why: TCC can attribute direct executable probes to the parent shell;
// launch the app bundle so status uses the same identity as real actions.
const result = spawnSync(
'/usr/bin/open',
['-n', helperAppPath, '--args', '--permission-status-file', statusPath],
{ stdio: 'ignore' }
)
if (result.error) {
throw result.error
}
waitForStatusFile(statusPath)
return JSON.parse(readFileSync(statusPath, 'utf8')) as Partial<
Record<ComputerUsePermissionId, ComputerUsePermissionStatus>
>
} finally {
rmSync(directory, { force: true, recursive: true })
}
}
function waitForStatusFile(statusPath: string): void {
const deadline = Date.now() + 3_000
while (Date.now() < deadline) {
if (existsSync(statusPath)) {
return
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50)
}
throw new RuntimeClientError(
'action_timeout',
'Orca Computer Use.app did not report permission status'
)
}
function nextPermissionStep(
permissions: ComputerUsePermissionStatusResult['permissions']
): string | null {
const missing = permissions.find((permission) => permission.status !== 'granted')
if (!missing) {
return null
}
return `Grant ${missing.id === 'accessibility' ? 'Accessibility' : 'Screen Recording'} to Orca Computer Use, then retry get-app-state.`
}
@@ -0,0 +1,282 @@
/* eslint-disable max-lines -- Why: the macOS provider transport owns one lifecycle across stdio fallback and helper-app socket mode. */
import { spawn } from 'child_process'
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import type net from 'net'
import { release, tmpdir } from 'os'
import { join } from 'path'
import { randomUUID } from 'crypto'
import type {
ComputerActionResult,
ComputerListAppsResult,
ComputerListWindowsResult,
ComputerProviderCapabilities,
ComputerSnapshotResult
} from '../../shared/runtime-types'
import {
assertMacOSProviderCapability,
REQUIRED_MACOS_PROVIDER_PROTOCOL_VERSION,
type NativeActionMethod,
type NativeMethod,
type NativeResponse,
type PendingNativeRequest,
writeNativeProviderLine
} from './macos-native-provider-contract'
import { resolveMacOSComputerUseAppPath } from './macos-native-provider-paths'
import { connectMacOSProviderSocket } from './macos-native-provider-socket'
import { RuntimeClientError } from './runtime-client-error'
const REQUEST_TIMEOUT_MS = 60_000
const HELPER_CONNECT_TIMEOUT_MS = 10_000
export function shouldUseMacOSNativeProvider(): boolean {
return (
process.platform === 'darwin' && isMacOS14OrNewer() && resolveMacOSComputerUseAppPath() !== null
)
}
export class MacOSNativeProviderClient {
private socket: net.Socket | null = null
private socketStartPromise: Promise<net.Socket> | null = null
private socketPath: string | null = null
private socketDirectory: string | null = null
private socketTokenPath: string | null = null
private socketToken: string | null = null
private nextId = 1
private pending = new Map<number, PendingNativeRequest>()
private socketBuffer = ''
private providerCapabilities: ComputerProviderCapabilities | null = null
async listApps(): Promise<ComputerListAppsResult> {
return (await this.call('listApps', {})) as ComputerListAppsResult
}
async capabilities(): Promise<ComputerProviderCapabilities> {
await this.ensureCompatible()
return this.providerCapabilities!
}
async listWindows(params: unknown): Promise<ComputerListWindowsResult> {
await this.ensureCapability('windows', 'list')
return (await this.call('listWindows', params)) as ComputerListWindowsResult
}
async snapshot(params: unknown): Promise<ComputerSnapshotResult> {
return (await this.call('getAppState', params)) as ComputerSnapshotResult
}
async action(method: NativeActionMethod, params: unknown): Promise<ComputerActionResult> {
return (await this.call(method, params)) as ComputerActionResult
}
shutdown(): void {
const socket = this.socket
const token = this.socketToken
this.socket = null
this.socketStartPromise = null
this.providerCapabilities = null
if (socket && !socket.destroyed) {
const id = this.nextId++
socket.write(`${JSON.stringify({ id, method: 'terminate', params: {}, token })}\n`)
socket.end()
}
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer)
pending.reject(
new RuntimeClientError('accessibility_error', 'native macOS provider shut down')
)
this.pending.delete(id)
}
this.cleanupSocketDirectory()
}
private async call(method: NativeMethod, params: unknown): Promise<unknown> {
if (method !== 'handshake') {
await this.ensureCompatible()
}
return await this.send(method, params)
}
private async send(method: NativeMethod, params: unknown): Promise<unknown> {
const id = this.nextId++
const helperAppPath = resolveMacOSComputerUseAppPath()
if (!helperAppPath) {
throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found')
}
const transport = await this.ensureSocketStarted(helperAppPath)
const token = this.socketToken
const line = `${JSON.stringify({ id, method, params, token })}\n`
const result = new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id)
this.shutdown()
reject(
new RuntimeClientError('action_timeout', `native macOS provider ${method} timed out`)
)
}, REQUEST_TIMEOUT_MS)
this.pending.set(id, { resolve, reject, timer })
})
try {
await writeNativeProviderLine(transport, line)
} catch (error) {
const pending = this.pending.get(id)
if (pending) {
clearTimeout(pending.timer)
this.pending.delete(id)
}
throw new RuntimeClientError(
'accessibility_error',
error instanceof Error ? error.message : String(error)
)
}
return await result
}
private async ensureCompatible(): Promise<void> {
if (this.providerCapabilities) {
return
}
const capabilities = await this.readCapabilities()
if (capabilities.protocolVersion === REQUIRED_MACOS_PROVIDER_PROTOCOL_VERSION) {
this.providerCapabilities = capabilities
return
}
this.shutdown()
const restarted = await this.readCapabilities()
if (restarted.protocolVersion !== REQUIRED_MACOS_PROVIDER_PROTOCOL_VERSION) {
throw new RuntimeClientError(
'provider_incompatible',
`native macOS provider protocol ${restarted.protocolVersion} is incompatible with required protocol ${REQUIRED_MACOS_PROVIDER_PROTOCOL_VERSION}`
)
}
this.providerCapabilities = restarted
}
private async readCapabilities(): Promise<ComputerProviderCapabilities> {
return (await this.send('handshake', {})) as ComputerProviderCapabilities
}
private async ensureCapability(
group: keyof ComputerProviderCapabilities['supports'],
capability: string
): Promise<void> {
await this.ensureCompatible()
if (assertMacOSProviderCapability(this.providerCapabilities, group, capability)) {
return
}
throw new RuntimeClientError(
'unsupported_capability',
`native macOS provider does not support ${String(group)}.${capability}`
)
}
private async ensureSocketStarted(helperAppPath: string): Promise<net.Socket> {
if (this.socket && !this.socket.destroyed) {
return this.socket
}
if (this.socketStartPromise) {
return await this.socketStartPromise
}
this.socketStartPromise = this.startSocket(helperAppPath)
try {
return await this.socketStartPromise
} finally {
this.socketStartPromise = null
}
}
private async startSocket(helperAppPath: string): Promise<net.Socket> {
this.socketDirectory = mkdtempSync(join(tmpdir(), 'orca-computer-use-'))
chmodSync(this.socketDirectory, 0o700)
this.socketPath = join(this.socketDirectory, 'provider.sock')
this.socketToken = randomUUID()
this.socketTokenPath = join(this.socketDirectory, 'provider.token')
writeFileSync(this.socketTokenPath, this.socketToken, { encoding: 'utf8', mode: 0o600 })
// Why: macOS TCC attaches Accessibility/Screen Recording to the helper
// app bundle; direct child-process stdio cannot reliably read AX windows.
const opener = spawn(
'/usr/bin/open',
[
'-n',
helperAppPath,
'--args',
'--agent',
this.socketPath,
'--token-file',
this.socketTokenPath
],
{ detached: true, stdio: 'ignore' }
)
opener.unref()
try {
const socket = await connectMacOSProviderSocket(this.socketPath, HELPER_CONNECT_TIMEOUT_MS)
socket.setEncoding('utf8')
socket.on('data', (chunk: string) => this.handleSocketData(chunk))
socket.on('close', () => this.handleSocketClose())
socket.on('error', (error) => this.handleTransportError(error))
this.socket = socket
return socket
} catch (error) {
this.cleanupSocketDirectory()
this.socketPath = null
this.socketTokenPath = null
this.socketToken = null
throw error
}
}
private handleSocketData(chunk: string): void {
this.socketBuffer += chunk
this.socketBuffer = this.consumeLines(this.socketBuffer)
}
private consumeLines(buffer: string): string {
let remaining = buffer
while (true) {
const newline = remaining.indexOf('\n')
if (newline < 0) {
return remaining
}
const line = remaining.slice(0, newline)
remaining = remaining.slice(newline + 1)
if (line.trim()) {
this.handleLine(line)
}
}
}
private handleLine(line: string): void {
let response: NativeResponse
try {
response = JSON.parse(line) as NativeResponse
} catch {
return
}
const pending = this.pending.get(response.id)
if (!pending) {
return
}
clearTimeout(pending.timer)
this.pending.delete(response.id)
if (response.ok) {
pending.resolve(response.result)
return
}
pending.reject(new RuntimeClientError(response.error.code, response.error.message))
}
private handleSocketClose(): void {
this.socket = null
this.cleanupSocketDirectory()
this.rejectPending(
new RuntimeClientError('accessibility_error', 'native macOS helper app connection closed')
)
}
private handleTransportError(error: Error): void {
this.rejectPending(new RuntimeClientError('accessibility_error', error.message))
}
private cleanupSocketDirectory(): void {
if (!this.socketDirectory) {
return
}
rmSync(this.socketDirectory, { recursive: true, force: true })
this.socketDirectory = null
this.socketPath = null
this.socketTokenPath = null
}
private rejectPending(error: Error): void {
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer)
pending.reject(error)
this.pending.delete(id)
}
}
}
function isMacOS14OrNewer(): boolean {
const darwinMajor = Number.parseInt(release().split('.')[0] ?? '', 10)
return Number.isFinite(darwinMajor) && darwinMajor >= 23
}
@@ -0,0 +1,56 @@
import type net from 'net'
import type { ComputerProviderCapabilities } from '../../shared/runtime-types'
export type NativeMethod =
| 'handshake'
| 'listApps'
| 'listWindows'
| 'getAppState'
| 'click'
| 'performSecondaryAction'
| 'scroll'
| 'drag'
| 'typeText'
| 'pressKey'
| 'hotkey'
| 'pasteText'
| 'setValue'
| 'terminate'
export type NativeActionMethod = Exclude<
NativeMethod,
'handshake' | 'listApps' | 'listWindows' | 'getAppState' | 'terminate'
>
export type NativeResponse =
| { id: number; ok: true; result: unknown }
| { id: number; ok: false; error: { code: string; message: string } }
export type PendingNativeRequest = {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: NodeJS.Timeout
}
export const REQUIRED_MACOS_PROVIDER_PROTOCOL_VERSION = 1
export function assertMacOSProviderCapability(
capabilities: ComputerProviderCapabilities | null,
group: keyof ComputerProviderCapabilities['supports'],
capability: string
): boolean {
const groupCapabilities = capabilities?.supports[group] as Record<string, boolean> | undefined
return groupCapabilities?.[capability] === true
}
export function writeNativeProviderLine(transport: net.Socket, line: string): Promise<void> {
return new Promise((resolve, reject) => {
transport.write(line, (error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
@@ -0,0 +1,44 @@
import { existsSync } from 'fs'
import { join, resolve } from 'path'
export function resolveMacOSComputerUseAppPath(): string | null {
const override = process.env.ORCA_COMPUTER_MACOS_HELPER_APP_PATH
if (override && existsSync(override)) {
return override
}
const packaged = [join(process.resourcesPath ?? '', 'Orca Computer Use.app')]
const dev = [
join(process.cwd(), 'native/computer-use-macos/.build/release/Orca Computer Use.app'),
resolve(__dirname, '../../native/computer-use-macos/.build/release/Orca Computer Use.app')
]
const candidates = process.resourcesPath ? [...packaged, ...dev] : dev
return candidates.find((candidate) => candidate && existsSync(candidate)) ?? null
}
export function resolveMacOSComputerUseExecutablePath(): string | null {
const appPath = resolveMacOSComputerUseAppPath()
if (!appPath) {
return null
}
const executablePath = join(appPath, 'Contents', 'MacOS', 'orca-computer-use-macos')
return existsSync(executablePath) ? executablePath : null
}
export function resolveMacOSNativeProviderPath(): string | null {
const override = process.env.ORCA_COMPUTER_MACOS_PROVIDER_PATH
if (override && existsSync(override)) {
return override
}
const packaged = [join(process.resourcesPath ?? '', 'computer-use-macos/orca-computer-use-macos')]
const dev = [
join(process.cwd(), 'native/computer-use-macos/.build/debug/orca-computer-use-macos'),
join(process.cwd(), 'native/computer-use-macos/.build/release/orca-computer-use-macos'),
resolve(__dirname, '../../native/computer-use-macos/.build/debug/orca-computer-use-macos')
]
const candidates = process.resourcesPath ? [...packaged, ...dev] : dev
return candidates.find((candidate) => candidate && existsSync(candidate)) ?? null
}
@@ -0,0 +1,37 @@
import net from 'net'
import { RuntimeClientError } from './runtime-client-error'
export async function connectMacOSProviderSocket(
socketPath: string,
timeoutMs: number
): Promise<net.Socket> {
const deadline = Date.now() + timeoutMs
let lastError: Error | null = null
while (Date.now() < deadline) {
try {
return await connectSocket(socketPath)
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
throw new RuntimeClientError(
'action_timeout',
`native macOS helper app did not open its socket: ${lastError?.message ?? 'timed out'}`
)
}
function connectSocket(socketPath: string): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = net.createConnection(socketPath)
const onError = (error: Error) => {
socket.destroy()
reject(error)
}
socket.once('error', onError)
socket.once('connect', () => {
socket.off('error', onError)
resolve(socket)
})
})
}
+51
View File
@@ -0,0 +1,51 @@
import { Notification, shell, systemPreferences } from 'electron'
const ACCESSIBILITY_SETTINGS_URL =
'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'
const DEFAULT_ACCESSIBILITY_INSTRUCTIONS =
'System Settings -> Privacy & Security -> Accessibility -> enable Orca'
const activePermissionNotifications = new Set<Notification>()
/** Probe accessibility permissions; lazy -- invoked only on first failure path. */
export async function checkAccessibilityPermission(): Promise<{
ok: boolean
instructions?: string
}> {
if (process.platform !== 'darwin') {
return { ok: true }
}
try {
const ok = systemPreferences.isTrustedAccessibilityClient(false)
return ok ? { ok: true } : { ok: false, instructions: DEFAULT_ACCESSIBILITY_INSTRUCTIONS }
} catch {
return { ok: false, instructions: DEFAULT_ACCESSIBILITY_INSTRUCTIONS }
}
}
/** Surface a notification through Orca's existing notification system (do not duplicate UI). */
export function notifyPermissionRequired(instructions: string): void {
if (!Notification.isSupported()) {
return
}
const notification = new Notification({
title: 'Accessibility permission required',
body: instructions
})
activePermissionNotifications.add(notification)
const release = (): void => {
activePermissionNotifications.delete(notification)
}
notification.on('close', release)
notification.on('click', () => {
release()
if (process.platform === 'darwin') {
void shell.openExternal(ACCESSIBILITY_SETTINGS_URL)
}
})
setTimeout(release, 5 * 60 * 1000)
notification.show()
}
@@ -0,0 +1,9 @@
export class RuntimeClientError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.name = 'RuntimeClientError'
this.code = code
}
}
+227
View File
@@ -0,0 +1,227 @@
import { fork, type ChildProcess } from 'child_process'
import { join } from 'path'
import type {
ComputerActionResult,
ComputerListAppsResult,
ComputerListWindowsResult,
ComputerProviderCapabilities,
ComputerSnapshotResult
} from '../../shared/runtime-types'
import { RuntimeClientError } from './runtime-client-error'
type ComputerSidecarMethod =
| 'capabilities'
| 'listApps'
| 'listWindows'
| 'getAppState'
| 'click'
| 'performSecondaryAction'
| 'scroll'
| 'drag'
| 'typeText'
| 'pressKey'
| 'hotkey'
| 'pasteText'
| 'setValue'
type ComputerSidecarRequest = {
id: number
method: ComputerSidecarMethod
params: unknown
}
type ComputerSidecarResponse =
| { id: number; ok: true; result: unknown }
| { id: number; ok: false; error: { code: string; message: string } }
type PendingRequest = {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: NodeJS.Timeout
}
const REQUEST_TIMEOUT_MS = 60_000
let sidecar: ComputerSidecarProcess | null = null
export function shouldUseComputerSidecar(): boolean {
return (
(process.platform === 'darwin' ||
process.platform === 'linux' ||
process.platform === 'win32') &&
typeof process.versions.electron === 'string' &&
process.env.ORCA_COMPUTER_SIDECAR !== '1'
)
}
export async function callComputerSidecarListApps(): Promise<ComputerListAppsResult> {
return (await getComputerSidecar().call('listApps', {})) as ComputerListAppsResult
}
export async function callComputerSidecarCapabilities(): Promise<ComputerProviderCapabilities> {
return (await getComputerSidecar().call('capabilities', {})) as ComputerProviderCapabilities
}
export async function callComputerSidecarListWindows(
params: unknown
): Promise<ComputerListWindowsResult> {
return (await getComputerSidecar().call('listWindows', params)) as ComputerListWindowsResult
}
export async function callComputerSidecarSnapshot(
params: unknown
): Promise<ComputerSnapshotResult> {
return (await getComputerSidecar().call('getAppState', params)) as ComputerSnapshotResult
}
export async function callComputerSidecarAction(
method: Exclude<
ComputerSidecarMethod,
'capabilities' | 'listApps' | 'listWindows' | 'getAppState'
>,
params: unknown
): Promise<ComputerActionResult> {
return (await getComputerSidecar().call(method, params)) as ComputerActionResult
}
export function resetComputerSidecarForTest(): void {
sidecar?.shutdown()
sidecar = null
}
function getComputerSidecar(): ComputerSidecarProcess {
if (!sidecar) {
sidecar = new ComputerSidecarProcess(getComputerSidecarEntryPath())
}
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')
}
function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null {
try {
return require('electron').app
} catch {
return null
}
}
class ComputerSidecarProcess {
private child: ChildProcess | null = null
private nextId = 1
private pending = new Map<number, PendingRequest>()
constructor(private readonly entryPath: string) {}
call(method: ComputerSidecarMethod, params: unknown): Promise<unknown> {
const child = this.ensureStarted()
const id = this.nextId++
const request: ComputerSidecarRequest = { id, method, params }
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id)
this.shutdown()
reject(new RuntimeClientError('action_timeout', `computer sidecar ${method} timed out`))
}, REQUEST_TIMEOUT_MS)
this.pending.set(id, { resolve, reject, timer })
child.send?.(request, (error) => {
if (!error) {
return
}
clearTimeout(timer)
this.pending.delete(id)
reject(new RuntimeClientError('accessibility_error', error.message))
})
})
}
shutdown(): void {
const child = this.child
this.child = null
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer)
pending.reject(new RuntimeClientError('accessibility_error', 'computer sidecar shut down'))
this.pending.delete(id)
}
child?.kill('SIGTERM')
}
private ensureStarted(): ChildProcess {
if (this.child && !this.child.killed) {
return this.child
}
const child = fork(this.entryPath, [], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
ORCA_COMPUTER_SIDECAR: '1'
},
...(process.platform === 'win32' ? { windowsHide: true } : {})
})
child.on('message', (message) => this.handleMessage(message))
child.on('exit', (code, signal) => this.handleExit(code, signal))
child.on('error', (error) => this.handleError(error))
this.child = child
return child
}
private handleMessage(message: unknown): void {
if (!isSidecarResponse(message)) {
return
}
const pending = this.pending.get(message.id)
if (!pending) {
return
}
clearTimeout(pending.timer)
this.pending.delete(message.id)
if (message.ok) {
pending.resolve(message.result)
return
}
pending.reject(new RuntimeClientError(message.error.code, message.error.message))
}
private handleExit(code: number | null, signal: NodeJS.Signals | null): void {
this.child = null
const detail = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`
const error = new RuntimeClientError(
'accessibility_error',
`computer sidecar exited with ${detail}`
)
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer)
pending.reject(error)
this.pending.delete(id)
}
}
private handleError(error: Error): void {
const wrapped = new RuntimeClientError('accessibility_error', error.message)
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer)
pending.reject(wrapped)
this.pending.delete(id)
}
}
}
function isSidecarResponse(message: unknown): message is ComputerSidecarResponse {
if (!message || typeof message !== 'object') {
return false
}
const record = message as Record<string, unknown>
return typeof record.id === 'number' && typeof record.ok === 'boolean'
}
+137
View File
@@ -0,0 +1,137 @@
/* eslint-disable max-lines -- Why: sidecar dispatch keeps one method table shared across native providers. */
import {
MacOSNativeProviderClient,
shouldUseMacOSNativeProvider
} from './macos-native-provider-client'
import {
DesktopScriptProviderClient,
shouldUseDesktopScriptProvider
} from './desktop-script-provider-client'
import { RuntimeClientError } from './runtime-client-error'
type SidecarRequest = {
id: number
method: string
params?: Record<string, unknown>
}
const nativeMacOSProvider = shouldUseMacOSNativeProvider() ? new MacOSNativeProviderClient() : null
const desktopScriptProvider = shouldUseDesktopScriptProvider()
? new DesktopScriptProviderClient()
: null
process.once('disconnect', shutdownProviders)
process.once('SIGTERM', () => {
shutdownProviders()
process.exit(0)
})
process.once('SIGINT', () => {
shutdownProviders()
process.exit(130)
})
process.once('beforeExit', shutdownProviders)
process.on('message', (message: unknown) => {
void handleMessage(message)
})
async function handleMessage(message: unknown): Promise<void> {
if (!isRequest(message)) {
return
}
try {
const result = await dispatch(message.method, message.params ?? {})
process.send?.({ id: message.id, ok: true, result })
} catch (error) {
const mapped = errorToResponse(error)
process.send?.({ id: message.id, ok: false, error: mapped })
}
}
async function dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
const provider = nativeMacOSProvider ?? desktopScriptProvider
if (!provider) {
throw new RuntimeClientError(
'unsupported_capability',
`computer-use has no native provider for ${process.platform}`
)
}
switch (method) {
case 'capabilities': {
return await provider.capabilities()
}
case 'listApps': {
return await provider.listApps()
}
case 'listWindows': {
return await provider.listWindows(params)
}
case 'getAppState': {
return await provider.snapshot(params)
}
case 'click': {
return await provider.action('click', params)
}
case 'performSecondaryAction': {
return await provider.action('performSecondaryAction', params)
}
case 'scroll': {
return await provider.action('scroll', params)
}
case 'drag': {
return await provider.action('drag', params)
}
case 'typeText': {
return await provider.action('typeText', params)
}
case 'pressKey': {
return await provider.action('pressKey', params)
}
case 'hotkey': {
return await provider.action('hotkey', params)
}
case 'pasteText': {
return await provider.action('pasteText', params)
}
case 'setValue': {
return await provider.action('setValue', params)
}
default:
throw new RuntimeClientError(
'invalid_argument',
`unknown computer sidecar method '${method}'`
)
}
}
function isRequest(message: unknown): message is SidecarRequest {
if (!message || typeof message !== 'object') {
return false
}
const record = message as Record<string, unknown>
return (
typeof record.id === 'number' &&
typeof record.method === 'string' &&
(record.params === undefined || (typeof record.params === 'object' && record.params !== null))
)
}
function errorToResponse(error: unknown): { code: string; message: string } {
if (
error instanceof Error &&
'code' in error &&
typeof (error as { code: unknown }).code === 'string'
) {
return { code: (error as { code: string }).code, message: error.message }
}
return {
code: 'accessibility_error',
message: error instanceof Error ? error.message : String(error)
}
}
function shutdownProviders(): void {
nativeMacOSProvider?.shutdown()
}
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, openComputerUsePermissionsMock, getComputerUsePermissionStatusMock } =
vi.hoisted(() => ({
handleMock: vi.fn(),
openComputerUsePermissionsMock: vi.fn(),
getComputerUsePermissionStatusMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock
}
}))
vi.mock('../computer/macos-computer-use-permissions', () => ({
getComputerUsePermissionStatus: getComputerUsePermissionStatusMock,
openComputerUsePermissions: openComputerUsePermissionsMock
}))
import { registerComputerUsePermissionHandlers } from './computer-use-permissions'
describe('registerComputerUsePermissionHandlers', () => {
beforeEach(() => {
handleMock.mockReset()
getComputerUsePermissionStatusMock.mockReset()
openComputerUsePermissionsMock.mockReset()
})
it('launches the computer-use helper setup', async () => {
const result = {
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
permissionId: 'accessibility',
openedSettings: false,
launchedHelper: true
}
openComputerUsePermissionsMock.mockReturnValue(result)
registerComputerUsePermissionHandlers()
const registration = handleMock.mock.calls.find(
([channel]) => channel === 'computerUsePermissions:openSetup'
)
expect(registration).toBeTruthy()
await expect(registration![1](null, { id: 'accessibility' })).resolves.toBe(result)
expect(openComputerUsePermissionsMock).toHaveBeenCalledWith('accessibility')
})
it('returns computer-use permission status', async () => {
const result = {
platform: 'darwin',
permissions: [
{ id: 'accessibility', status: 'granted' },
{ id: 'screenshots', status: 'not-granted' }
]
}
getComputerUsePermissionStatusMock.mockReturnValue(result)
registerComputerUsePermissionHandlers()
const registration = handleMock.mock.calls.find(
([channel]) => channel === 'computerUsePermissions:getStatus'
)
expect(registration).toBeTruthy()
await expect(registration![1]()).resolves.toBe(result)
expect(getComputerUsePermissionStatusMock).toHaveBeenCalledWith()
})
})
+28
View File
@@ -0,0 +1,28 @@
import { ipcMain } from 'electron'
import type {
ComputerUsePermissionId,
ComputerUsePermissionSetupResult,
ComputerUsePermissionStatusResult
} from '../../shared/computer-use-permissions-types'
export function registerComputerUsePermissionHandlers(): void {
ipcMain.handle(
'computerUsePermissions:openSetup',
async (
_event,
args?: { id?: ComputerUsePermissionId }
): Promise<ComputerUsePermissionSetupResult> => {
const { openComputerUsePermissions } =
await import('../computer/macos-computer-use-permissions')
return openComputerUsePermissions(args?.id)
}
)
ipcMain.handle(
'computerUsePermissions:getStatus',
async (): Promise<ComputerUsePermissionStatusResult> => {
const { getComputerUsePermissionStatus } =
await import('../computer/macos-computer-use-permissions')
return getComputerUsePermissionStatus()
}
)
}
@@ -11,6 +11,7 @@ const {
registerMemoryHandlersMock,
registerNotificationHandlersMock,
registerDeveloperPermissionHandlersMock,
registerComputerUsePermissionHandlersMock,
registerSettingsHandlersMock,
registerTelemetryHandlersMock,
registerShellHandlersMock,
@@ -45,6 +46,7 @@ const {
registerMemoryHandlersMock: vi.fn(),
registerNotificationHandlersMock: vi.fn(),
registerDeveloperPermissionHandlersMock: vi.fn(),
registerComputerUsePermissionHandlersMock: vi.fn(),
registerSettingsHandlersMock: vi.fn(),
registerTelemetryHandlersMock: vi.fn(),
registerShellHandlersMock: vi.fn(),
@@ -118,6 +120,10 @@ vi.mock('./developer-permissions', () => ({
registerDeveloperPermissionHandlers: registerDeveloperPermissionHandlersMock
}))
vi.mock('./computer-use-permissions', () => ({
registerComputerUsePermissionHandlers: registerComputerUsePermissionHandlersMock
}))
vi.mock('./settings', () => ({
registerSettingsHandlers: registerSettingsHandlersMock
}))
@@ -207,6 +213,7 @@ describe('registerCoreHandlers', () => {
registerMemoryHandlersMock.mockReset()
registerNotificationHandlersMock.mockReset()
registerDeveloperPermissionHandlersMock.mockReset()
registerComputerUsePermissionHandlersMock.mockReset()
registerSettingsHandlersMock.mockReset()
registerTelemetryHandlersMock.mockReset()
registerShellHandlersMock.mockReset()
@@ -266,6 +273,7 @@ describe('registerCoreHandlers', () => {
expect(registerMemoryHandlersMock).toHaveBeenCalledWith(store)
expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store, runtime)
expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled()
expect(registerComputerUsePermissionHandlersMock).toHaveBeenCalled()
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store)
expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store)
expect(registerSessionHandlersMock).toHaveBeenCalledWith(store)
+2
View File
@@ -19,6 +19,7 @@ import { registerRuntimeHandlers } from './runtime'
import { registerNotificationHandlers } from './notifications'
import { registerOnboardingHandlers } from './onboarding'
import { registerDeveloperPermissionHandlers } from './developer-permissions'
import { registerComputerUsePermissionHandlers } from './computer-use-permissions'
import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser'
import { registerSessionHandlers } from './session'
import { registerSettingsHandlers } from './settings'
@@ -86,6 +87,7 @@ export function registerCoreHandlers(
registerNotificationHandlers(store, runtime)
registerOnboardingHandlers(store)
registerDeveloperPermissionHandlers()
registerComputerUsePermissionHandlers()
registerSettingsHandlers(store)
registerTelemetryHandlers(store)
registerBrowserHandlers()
+11
View File
@@ -3,6 +3,7 @@
// format human-facing messages. Centralizing this mapping keeps the allowlist
// auditable in one place instead of spread across per-method branches.
import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core'
import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types'
export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
return {
@@ -46,8 +47,18 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
'invalid_limit'
])
const COMPUTER_PASSTHROUGH_CODES: ReadonlySet<string> = new Set(Object.values(COMPUTER_ERROR_CODES))
export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure {
const message = error instanceof Error ? error.message : String(error)
if (
error instanceof Error &&
'code' in error &&
typeof (error as { code: unknown }).code === 'string' &&
COMPUTER_PASSTHROUGH_CODES.has((error as { code: string }).code)
) {
return errorResponse(id, meta, (error as { code: string }).code, message)
}
if (RUNTIME_PASSTHROUGH_CODES.has(message)) {
return errorResponse(id, meta, message, message)
}
@@ -0,0 +1,341 @@
/* eslint-disable max-lines -- Why: computer RPC coverage shares one mocked registry setup across all method contracts. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildRegistry } from '../core'
const computerMocks = vi.hoisted(() => ({
callComputerSidecarAction: vi.fn(),
callComputerSidecarCapabilities: vi.fn(),
callComputerSidecarListApps: vi.fn(),
callComputerSidecarListWindows: vi.fn(),
callComputerSidecarSnapshot: vi.fn(),
resetComputerSidecarForTest: vi.fn(),
openComputerUsePermissions: vi.fn()
}))
vi.mock('../../../computer/sidecar-client', () => ({
callComputerSidecarAction: computerMocks.callComputerSidecarAction,
callComputerSidecarCapabilities: computerMocks.callComputerSidecarCapabilities,
callComputerSidecarListApps: computerMocks.callComputerSidecarListApps,
callComputerSidecarListWindows: computerMocks.callComputerSidecarListWindows,
callComputerSidecarSnapshot: computerMocks.callComputerSidecarSnapshot,
resetComputerSidecarForTest: computerMocks.resetComputerSidecarForTest
}))
vi.mock('../../../computer/macos-computer-use-permissions', () => ({
openComputerUsePermissions: computerMocks.openComputerUsePermissions
}))
import { COMPUTER_METHODS, resetComputerSessionsForTest } from './computer'
describe('computer RPC methods', () => {
beforeEach(() => {
computerMocks.callComputerSidecarAction.mockReset()
computerMocks.callComputerSidecarCapabilities.mockReset()
computerMocks.callComputerSidecarListApps.mockReset()
computerMocks.callComputerSidecarListWindows.mockReset()
computerMocks.callComputerSidecarSnapshot.mockReset()
computerMocks.resetComputerSidecarForTest.mockReset()
computerMocks.openComputerUsePermissions.mockReset()
resetComputerSessionsForTest()
computerMocks.resetComputerSidecarForTest.mockClear()
})
it('registers all computer methods', () => {
const registry = buildRegistry(COMPUTER_METHODS)
expect([...registry.keys()].sort()).toEqual([
'computer.capabilities',
'computer.click',
'computer.drag',
'computer.getAppState',
'computer.hotkey',
'computer.listApps',
'computer.listWindows',
'computer.pasteText',
'computer.performSecondaryAction',
'computer.permissions',
'computer.pressKey',
'computer.scroll',
'computer.setValue',
'computer.typeText'
])
})
it('resets the sidecar test process', () => {
resetComputerSessionsForTest()
expect(computerMocks.resetComputerSidecarForTest).toHaveBeenCalledTimes(1)
})
it('lists running apps through the sidecar', async () => {
const result = {
apps: [{ name: 'Finder', bundleId: 'com.apple.finder', pid: 100 }]
}
computerMocks.callComputerSidecarListApps.mockResolvedValue(result)
await expect(call('computer.listApps', {})).resolves.toBe(result)
expect(computerMocks.callComputerSidecarListApps).toHaveBeenCalledWith()
})
it('returns provider capabilities through the sidecar', async () => {
const result = { platform: 'darwin', provider: 'orca-computer-use-macos', protocolVersion: 1 }
computerMocks.callComputerSidecarCapabilities.mockResolvedValue(result)
await expect(call('computer.capabilities', {})).resolves.toBe(result)
expect(computerMocks.callComputerSidecarCapabilities).toHaveBeenCalledWith()
})
it('opens computer-use permission setup', async () => {
const result = {
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
openedSettings: false,
launchedHelper: true
}
computerMocks.openComputerUsePermissions.mockReturnValue(result)
await expect(call('computer.permissions', {})).resolves.toBe(result)
expect(computerMocks.openComputerUsePermissions).toHaveBeenCalledWith()
})
it('lists windows through the sidecar', async () => {
const result = {
app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 },
windows: []
}
const params = { app: 'Finder', worktree: 'path:/tmp/repo' }
computerMocks.callComputerSidecarListWindows.mockResolvedValue(result)
await expect(call('computer.listWindows', params)).resolves.toBe(result)
expect(computerMocks.callComputerSidecarListWindows).toHaveBeenCalledWith(params)
})
it('gets app state through the sidecar', async () => {
const snapshot = {
snapshot: {
id: 'snap-test',
app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 },
window: { title: 'Finder', width: 100, height: 100 },
treeText: 'tree',
elementCount: 1,
focusedElementId: null
},
screenshot: null,
screenshotStatus: { state: 'skipped', reason: 'no_screenshot_flag' }
}
const params = {
app: 'Finder',
worktree: 'path:/tmp/repo',
noScreenshot: true,
restoreWindow: true,
windowId: 123
}
computerMocks.callComputerSidecarSnapshot.mockResolvedValue(snapshot)
await expect(call('computer.getAppState', params)).resolves.toBe(snapshot)
expect(computerMocks.callComputerSidecarSnapshot).toHaveBeenCalledWith(params)
})
it('rejects missing app in getAppState schema', () => {
const method = findMethod('computer.getAppState')
expect(() => method.params!.parse({})).toThrow()
})
it('rejects ambiguous window targeting', () => {
expect(() =>
findMethod('computer.getAppState').params!.parse({
app: 'Finder',
windowId: 1,
windowIndex: 0
})
).toThrow(/either --window-id or --window-index/)
expect(() =>
findMethod('computer.click').params!.parse({
app: 'Finder',
elementIndex: 0,
windowId: 1,
windowIndex: 0
})
).toThrow(/either --window-id or --window-index/)
})
it('rejects incomplete pointer action coordinates', () => {
expect(() => findMethod('computer.click').params!.parse({ app: 'Finder' })).toThrow(
/Click requires/
)
expect(() => findMethod('computer.click').params!.parse({ app: 'Finder', x: 1 })).toThrow(
/both --x and --y/
)
expect(() =>
findMethod('computer.click').params!.parse({ app: 'Finder', elementIndex: 0, x: 1, y: 2 })
).toThrow(/either --element-index or coordinate flags/)
expect(() =>
findMethod('computer.scroll').params!.parse({ app: 'Finder', direction: 'down' })
).toThrow(/Scroll requires/)
expect(() =>
findMethod('computer.scroll').params!.parse({
app: 'Finder',
elementIndex: 0,
x: 1,
y: 2,
direction: 'down'
})
).toThrow(/either --element-index or coordinate flags/)
expect(() =>
findMethod('computer.drag').params!.parse({ app: 'Finder', fromX: 1, fromY: 2 })
).toThrow(/Drag coordinates/)
expect(() =>
findMethod('computer.drag').params!.parse({ app: 'Finder', fromElementIndex: 1 })
).toThrow(/both --from-element-index and --to-element-index/)
expect(() =>
findMethod('computer.drag').params!.parse({
app: 'Finder',
fromElementIndex: 0,
toElementIndex: 1,
fromX: 1,
fromY: 2,
toX: 3,
toY: 4
})
).toThrow(/either element indexes or coordinate flags/)
})
it('rejects unsupported scroll directions', () => {
expect(() =>
findMethod('computer.scroll').params!.parse({
app: 'Finder',
elementIndex: 0,
direction: 'diagonal'
})
).toThrow()
expect(() =>
findMethod('computer.scroll').params!.parse({
app: 'Finder',
elementIndex: 0,
direction: 'down',
pages: 0
})
).toThrow()
})
it('dispatches pointer and element actions through the sidecar', async () => {
computerMocks.callComputerSidecarAction.mockResolvedValue({ ok: true })
await call('computer.click', {
app: 'Finder',
worktree: 'path:/tmp/repo',
elementIndex: 0,
clickCount: 2,
mouseButton: 'left',
noScreenshot: true
})
await call('computer.performSecondaryAction', {
app: 'Finder',
elementIndex: 0,
action: 'Raise'
})
await call('computer.drag', {
app: 'Finder',
fromX: 1,
fromY: 2,
toX: 3,
toY: 4
})
expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(1, 'click', {
app: 'Finder',
worktree: 'path:/tmp/repo',
elementIndex: 0,
clickCount: 2,
mouseButton: 'left',
noScreenshot: true
})
expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(
2,
'performSecondaryAction',
{
app: 'Finder',
elementIndex: 0,
action: 'Raise'
}
)
expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(3, 'drag', {
app: 'Finder',
fromX: 1,
fromY: 2,
toX: 3,
toY: 4
})
})
it('dispatches keyboard and text actions through the sidecar', async () => {
computerMocks.callComputerSidecarAction.mockResolvedValue({ ok: true })
await call('computer.typeText', { app: 'Finder', text: 'hello', noScreenshot: true })
await call('computer.pressKey', { app: 'Finder', key: 'Return' })
await call('computer.hotkey', { app: 'Finder', key: 'CmdOrCtrl+L' })
await call('computer.pasteText', { app: 'Finder', text: 'long text' })
expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(1, 'typeText', {
app: 'Finder',
text: 'hello',
noScreenshot: true
})
expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(2, 'pressKey', {
app: 'Finder',
key: 'Return'
})
expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(3, 'hotkey', {
app: 'Finder',
key: 'CmdOrCtrl+L'
})
expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(4, 'pasteText', {
app: 'Finder',
text: 'long text'
})
})
it('dispatches scroll and setValue actions through the sidecar', async () => {
computerMocks.callComputerSidecarAction.mockResolvedValue({ ok: true })
await call('computer.scroll', {
app: 'Finder',
elementIndex: 0,
direction: 'down',
pages: 2
})
await call('computer.setValue', {
app: 'Finder',
elementIndex: 1,
value: ''
})
expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(1, 'scroll', {
app: 'Finder',
elementIndex: 0,
direction: 'down',
pages: 2
})
expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(2, 'setValue', {
app: 'Finder',
elementIndex: 1,
value: ''
})
})
})
function findMethod(name: string) {
const method = COMPUTER_METHODS.find((candidate) => candidate.name === name)
if (!method) {
throw new Error(`missing method ${name}`)
}
return method
}
async function call(name: string, params: Record<string, unknown>) {
const method = findMethod(name)
const parsed = method.params ? method.params.parse(params) : undefined
return await method.handler(parsed, {
runtime: { getRuntimeId: () => 'runtime-1' } as never
})
}
+301
View File
@@ -0,0 +1,301 @@
/* eslint-disable max-lines -- Why: computer RPC schemas and sidecar dispatch stay together so provider behavior is audited in one place. */
import { z } from 'zod'
import {
callComputerSidecarAction,
callComputerSidecarCapabilities,
callComputerSidecarListApps,
callComputerSidecarListWindows,
callComputerSidecarSnapshot,
resetComputerSidecarForTest
} from '../../../computer/sidecar-client'
import { defineMethod, type RpcMethod } from '../core'
import {
OptionalBoolean,
OptionalFiniteNumber,
OptionalPlainString,
OptionalString,
requiredStringAllowingEmpty,
requiredString
} from '../schemas'
const OptionalNonNegativeInt = z.number().int().nonnegative().optional()
const OptionalPositiveInt = z.number().int().positive().optional()
const ComputerTarget = z.object({
app: requiredString('Missing app'),
session: OptionalString,
worktree: OptionalPlainString
})
const ComputerObserveTargetBase = ComputerTarget.extend({
noScreenshot: OptionalBoolean,
restoreWindow: OptionalBoolean,
windowId: OptionalNonNegativeInt,
windowIndex: OptionalNonNegativeInt
})
function validateWindowTarget(
value: { windowId?: number; windowIndex?: number },
ctx: z.RefinementCtx
): void {
if (value.windowId !== undefined && value.windowIndex !== undefined) {
ctx.addIssue({
code: 'custom',
message: 'Window targeting accepts either --window-id or --window-index, not both'
})
}
}
const ComputerObserveTarget = ComputerObserveTargetBase.superRefine(validateWindowTarget)
const ListApps = z.object({
worktree: OptionalPlainString
})
const ListWindows = ComputerTarget
const Click = ComputerObserveTargetBase.extend({
elementIndex: OptionalNonNegativeInt,
x: OptionalFiniteNumber,
y: OptionalFiniteNumber,
clickCount: OptionalPositiveInt,
mouseButton: z.enum(['left', 'right', 'middle']).optional()
}).superRefine((value, ctx) => {
validateWindowTarget(value, ctx)
const hasElement = value.elementIndex !== undefined
const hasX = value.x !== undefined
const hasY = value.y !== undefined
if (!hasElement && !(hasX && hasY)) {
ctx.addIssue({
code: 'custom',
message: 'Click requires --element-index or both --x and --y'
})
}
if (hasX !== hasY) {
ctx.addIssue({
code: 'custom',
message: 'Click coordinates require both --x and --y'
})
}
if (hasElement && (hasX || hasY)) {
ctx.addIssue({
code: 'custom',
message: 'Click accepts either --element-index or coordinate flags, not both'
})
}
})
const PerformSecondaryAction = ComputerObserveTargetBase.extend({
elementIndex: OptionalNonNegativeInt,
action: requiredString('Missing action')
}).superRefine((value, ctx) => {
validateWindowTarget(value, ctx)
if (value.elementIndex === undefined) {
ctx.addIssue({ code: 'custom', message: 'Missing element index' })
}
})
const Scroll = ComputerObserveTargetBase.extend({
elementIndex: OptionalNonNegativeInt,
x: OptionalFiniteNumber,
y: OptionalFiniteNumber,
direction: z.enum(['up', 'down', 'left', 'right']),
pages: z.number().positive().optional()
}).superRefine((value, ctx) => {
validateWindowTarget(value, ctx)
const hasElement = value.elementIndex !== undefined
const hasX = value.x !== undefined
const hasY = value.y !== undefined
if (!hasElement && !(hasX && hasY)) {
ctx.addIssue({
code: 'custom',
message: 'Scroll requires --element-index or both --x and --y'
})
}
if (hasX !== hasY) {
ctx.addIssue({
code: 'custom',
message: 'Scroll coordinates require both --x and --y'
})
}
if (hasElement && (hasX || hasY)) {
ctx.addIssue({
code: 'custom',
message: 'Scroll accepts either --element-index or coordinate flags, not both'
})
}
})
const Drag = ComputerObserveTargetBase.extend({
fromElementIndex: OptionalNonNegativeInt,
toElementIndex: OptionalNonNegativeInt,
fromX: OptionalFiniteNumber,
fromY: OptionalFiniteNumber,
toX: OptionalFiniteNumber,
toY: OptionalFiniteNumber
}).superRefine((value, ctx) => {
validateWindowTarget(value, ctx)
const hasElementPair = value.fromElementIndex !== undefined && value.toElementIndex !== undefined
const hasPartialElementPair =
value.fromElementIndex !== undefined || value.toElementIndex !== undefined
const coordinateKeys = [value.fromX, value.fromY, value.toX, value.toY]
const hasCoordinatePair = coordinateKeys.every((coordinate) => coordinate !== undefined)
const hasPartialCoordinatePair = coordinateKeys.some((coordinate) => coordinate !== undefined)
if (hasElementPair && hasCoordinatePair) {
ctx.addIssue({
code: 'custom',
message: 'Drag accepts either element indexes or coordinate flags, not both'
})
}
if (!hasElementPair && !hasCoordinatePair) {
ctx.addIssue({
code: 'custom',
message: 'Drag requires --from-element-index and --to-element-index, or all coordinate flags'
})
}
if (hasPartialElementPair && !hasElementPair) {
ctx.addIssue({
code: 'custom',
message: 'Drag element targeting requires both --from-element-index and --to-element-index'
})
}
if (hasPartialCoordinatePair && !hasCoordinatePair) {
ctx.addIssue({
code: 'custom',
message: 'Drag coordinates require --from-x, --from-y, --to-x, and --to-y'
})
}
})
const TypeText = ComputerObserveTargetBase.extend({
text: requiredString('Missing text')
}).superRefine(validateWindowTarget)
const PressKey = ComputerObserveTargetBase.extend({
key: requiredString('Missing key')
}).superRefine(validateWindowTarget)
const Hotkey = ComputerObserveTargetBase.extend({
key: requiredString('Missing key')
}).superRefine(validateWindowTarget)
const PasteText = ComputerObserveTargetBase.extend({
text: requiredString('Missing text')
}).superRefine(validateWindowTarget)
const SetValue = ComputerObserveTargetBase.extend({
elementIndex: OptionalNonNegativeInt,
value: requiredStringAllowingEmpty('Missing value')
}).superRefine((value, ctx) => {
validateWindowTarget(value, ctx)
if (value.elementIndex === undefined) {
ctx.addIssue({ code: 'custom', message: 'Missing element index' })
}
})
export function resetComputerSessionsForTest(): void {
resetComputerSidecarForTest()
}
export const COMPUTER_METHODS: RpcMethod[] = [
defineMethod({
name: 'computer.capabilities',
params: z.object({}),
handler: async () => {
return await callComputerSidecarCapabilities()
}
}),
defineMethod({
name: 'computer.listApps',
params: ListApps,
handler: async () => {
return await callComputerSidecarListApps()
}
}),
defineMethod({
name: 'computer.permissions',
params: z.object({}),
handler: async () => {
const { openComputerUsePermissions } =
await import('../../../computer/macos-computer-use-permissions')
return openComputerUsePermissions()
}
}),
defineMethod({
name: 'computer.listWindows',
params: ListWindows,
handler: async (params) => {
return await callComputerSidecarListWindows(params)
}
}),
defineMethod({
name: 'computer.getAppState',
params: ComputerObserveTarget,
handler: async (params) => {
return await callComputerSidecarSnapshot(params)
}
}),
defineMethod({
name: 'computer.click',
params: Click,
handler: async (params) => {
return await callComputerSidecarAction('click', params)
}
}),
defineMethod({
name: 'computer.performSecondaryAction',
params: PerformSecondaryAction,
handler: async (params) => {
return await callComputerSidecarAction('performSecondaryAction', params)
}
}),
defineMethod({
name: 'computer.scroll',
params: Scroll,
handler: async (params) => {
return await callComputerSidecarAction('scroll', params)
}
}),
defineMethod({
name: 'computer.drag',
params: Drag,
handler: async (params) => {
return await callComputerSidecarAction('drag', params)
}
}),
defineMethod({
name: 'computer.typeText',
params: TypeText,
handler: async (params) => {
return await callComputerSidecarAction('typeText', params)
}
}),
defineMethod({
name: 'computer.pressKey',
params: PressKey,
handler: async (params) => {
return await callComputerSidecarAction('pressKey', params)
}
}),
defineMethod({
name: 'computer.hotkey',
params: Hotkey,
handler: async (params) => {
return await callComputerSidecarAction('hotkey', params)
}
}),
defineMethod({
name: 'computer.pasteText',
params: PasteText,
handler: async (params) => {
return await callComputerSidecarAction('pasteText', params)
}
}),
defineMethod({
name: 'computer.setValue',
params: SetValue,
handler: async (params) => {
return await callComputerSidecarAction('setValue', params)
}
})
]
+3 -1
View File
@@ -9,6 +9,7 @@ import { ORCHESTRATION_METHODS } from './orchestration'
import { NOTIFICATION_METHODS } from './notifications'
import { STATS_METHODS } from './stats'
import { ACCOUNT_METHODS } from './accounts'
import { COMPUTER_METHODS } from './computer'
// Why: a flat manifest keeps registration order explicit and provides one
// grep-point for "what methods does the RPC server expose?" — useful when
@@ -23,5 +24,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...ORCHESTRATION_METHODS,
...NOTIFICATION_METHODS,
...STATS_METHODS,
...ACCOUNT_METHODS
...ACCOUNT_METHODS,
...COMPUTER_METHODS
]
+4
View File
@@ -77,6 +77,10 @@ export function requiredString(message: string) {
.pipe(z.string().min(1, message))
}
export function requiredStringAllowingEmpty(message: string) {
return z.unknown().refine((value): value is string => typeof value === 'string', { message })
}
export function requiredNumber(message: string) {
return z
.unknown()
+11
View File
@@ -129,6 +129,11 @@ import type {
DeveloperPermissionRequestResult,
DeveloperPermissionState
} from '../shared/developer-permissions-types'
import type {
ComputerUsePermissionId,
ComputerUsePermissionSetupResult,
ComputerUsePermissionStatusResult
} from '../shared/computer-use-permissions-types'
import type {
ClaudeUsageBreakdownKind,
ClaudeUsageBreakdownRow,
@@ -807,6 +812,12 @@ export type PreloadApi = {
request: (args: { id: DeveloperPermissionId }) => Promise<DeveloperPermissionRequestResult>
openSettings: (args: { id: DeveloperPermissionId }) => Promise<void>
}
computerUsePermissions: {
getStatus: () => Promise<ComputerUsePermissionStatusResult>
openSetup: (args?: {
id?: ComputerUsePermissionId
}) => Promise<ComputerUsePermissionSetupResult>
}
shell: {
openPath: (path: string) => Promise<void>
openUrl: (url: string) => Promise<void>
+6
View File
@@ -1037,6 +1037,12 @@ const api = {
ipcRenderer.invoke('developerPermissions:openSettings', args)
},
computerUsePermissions: {
getStatus: (): Promise<unknown> => ipcRenderer.invoke('computerUsePermissions:getStatus'),
openSetup: (args?: { id?: string }): Promise<unknown> =>
ipcRenderer.invoke('computerUsePermissions:openSetup', args)
},
shell: {
openPath: (path: string): Promise<void> => ipcRenderer.invoke('shell:openPath', path),
@@ -0,0 +1,238 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { Accessibility, Camera, Copy, ExternalLink, RefreshCw, ShieldCheck } from 'lucide-react'
import { toast } from 'sonner'
import type {
ComputerUsePermissionId,
ComputerUsePermissionState,
ComputerUsePermissionStatus
} from '../../../../shared/computer-use-permissions-types'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import type { SettingsSearchEntry } from './settings-search'
const COMPUTER_USE_SKILL_INSTALL_COMMAND =
'npx skills add https://github.com/stablyai/orca --skill computer-use'
export const COMPUTER_USE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Computer Use',
description: 'Allow agents to inspect screenshots and operate local apps when you ask.',
keywords: [
'computer use',
'accessibility',
'screen recording',
'screenshot',
'automation',
'skill'
]
}
]
type PermissionDefinition = {
id: ComputerUsePermissionId
label: string
description: string
icon: ReactNode
}
const PERMISSIONS: PermissionDefinition[] = [
{
id: 'accessibility',
label: 'Accessibility',
description: 'Read app interface trees and perform requested actions.',
icon: <Accessibility className="size-4" />
},
{
id: 'screenshots',
label: 'Screenshots',
description: 'Capture app windows so agents can inspect visual state.',
icon: <Camera className="size-4" />
}
]
function statusLabel(status: ComputerUsePermissionStatus | undefined): string {
switch (status) {
case 'granted':
return 'Granted'
case 'unsupported':
return 'macOS only'
case 'not-granted':
default:
return 'Not enabled'
}
}
function statusClass(status: ComputerUsePermissionStatus | undefined): string {
if (status === 'granted') {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
}
return 'border-border bg-muted text-muted-foreground'
}
export function ComputerUsePane(): React.JSX.Element {
const [platform, setPlatform] = useState<NodeJS.Platform | null>(null)
const [states, setStates] = useState<ComputerUsePermissionState[]>([])
const [loading, setLoading] = useState(true)
const [pendingId, setPendingId] = useState<ComputerUsePermissionId | null>(null)
const stateById = useMemo(
() => new Map(states.map((state) => [state.id, state.status] as const)),
[states]
)
const refresh = useCallback(async (): Promise<void> => {
setLoading(true)
try {
const result = await window.api.computerUsePermissions.getStatus()
setPlatform(result.platform)
setStates(result.permissions)
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Could not load Computer Use permissions'
)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
// Why: users grant these in System Settings, so refresh when focus returns
// instead of polling while the settings pane is open.
useEffect(() => {
const onFocus = (): void => {
void refresh()
}
window.addEventListener('focus', onFocus)
return () => window.removeEventListener('focus', onFocus)
}, [refresh])
const openPermission = async (id: ComputerUsePermissionId): Promise<void> => {
setPendingId(id)
try {
const result = await window.api.computerUsePermissions.openSetup({ id })
if (result.launchedHelper) {
toast.message('Opened macOS Privacy & Security')
} else {
toast.message('Computer Use permissions are only required on macOS')
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Could not open Computer Use permissions'
)
} finally {
setPendingId(null)
}
}
const copySkillInstallCommand = async (): Promise<void> => {
try {
await window.api.ui.writeClipboardText(COMPUTER_USE_SKILL_INSTALL_COMMAND)
toast.success('Copied skill install command.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to copy install command.')
}
}
const isMac = platform === null || platform === 'darwin'
return (
<div className="space-y-5">
{isMac ? (
<>
<div className="flex items-start justify-between gap-4 rounded-lg border border-border/60 bg-muted/25 px-4 py-3">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<ShieldCheck className="size-4" />
Allow Orca to use local apps when you ask.
</div>
<p className="text-xs text-muted-foreground">
Computer Use needs macOS privacy permissions before agents can inspect and operate
app windows.
</p>
</div>
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => void refresh()}>
<RefreshCw className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<div className="divide-y divide-border/60 rounded-lg border border-border/60">
{PERMISSIONS.map((permission) => {
const status = stateById.get(permission.id)
const pending = pendingId === permission.id
return (
<div
key={permission.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 text-muted-foreground">{permission.icon}</div>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{permission.label}</span>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${statusClass(
status
)}`}
>
{statusLabel(status)}
</span>
</div>
<p className="text-xs text-muted-foreground">{permission.description}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
disabled={pending || status === 'unsupported'}
onClick={() => void openPermission(permission.id)}
className="shrink-0 gap-1.5"
>
<ExternalLink className="size-3.5" />
{pending ? 'Opening...' : 'Open'}
</Button>
</div>
)
})}
</div>
</>
) : null}
<div className="space-y-2 rounded-lg border border-border/60 px-4 py-3">
<div className="space-y-1">
<p className="text-sm font-medium">Install Computer Use Skill</p>
<p className="text-xs text-muted-foreground">
Run this once in an agent project so agents know how to use Orca&apos;s computer
controls.
</p>
</div>
<div className="flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
{COMPUTER_USE_SKILL_INSTALL_COMMAND}
</code>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => void copySkillInstallCommand()}
aria-label="Copy Computer Use skill install command"
>
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Copy
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
)
}
@@ -7,8 +7,10 @@ import {
FlaskConical,
GitBranch,
Globe,
Info,
Keyboard,
Lock,
MousePointerClick,
ShieldCheck,
Palette,
Server,
@@ -32,6 +34,7 @@ import { ShortcutsPane, SHORTCUTS_PANE_SEARCH_ENTRIES } from './ShortcutsPane'
import { TerminalPane } from './TerminalPane'
import { useGhosttyImport } from './useGhosttyImport'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import ghosttyIcon from '../../../../../resources/ghostty.svg'
import { RepositoryPane, getRepositoryPaneSearchEntries } from './RepositoryPane'
import { getTerminalPaneSearchEntries } from './terminal-search'
@@ -47,6 +50,7 @@ import {
DeveloperPermissionsPane,
DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES
} from './DeveloperPermissionsPane'
import { ComputerUsePane, COMPUTER_USE_PANE_SEARCH_ENTRIES } from './ComputerUsePane'
import { PrivacyPane } from './PrivacyPane'
import { PRIVACY_PANE_SEARCH_ENTRIES } from './privacy-search'
import { SettingsSidebar } from './SettingsSidebar'
@@ -62,6 +66,7 @@ type SettingsNavTarget =
| 'appearance'
| 'terminal'
| 'notifications'
| 'computer-use'
| 'developer-permissions'
| 'privacy'
| 'shortcuts'
@@ -92,6 +97,16 @@ function getFallbackVisibleSection(sections: SettingsNavSection[]): SettingsNavS
return sections.at(0)
}
function computerUsePlatformLabel(args: { isWindows: boolean; isMac: boolean }): string {
if (args.isWindows) {
return 'Windows'
}
if (!args.isMac) {
return 'Linux'
}
return 'This platform'
}
// Why: after a sidebar jump the target section is now in the viewport center
// rather than the top, which can make it less obvious which section just
// scrolled into view. Pulsing the border for a moment reassures the user that
@@ -157,6 +172,8 @@ function Settings(): React.JSX.Element {
const systemPrefersDark = useSystemPrefersDark()
const isWindows = isWindowsUserAgent()
const isMac = isMacUserAgent()
const showComputerUsePreviewTooltip = !isMac
const computerUsePlatform = computerUsePlatformLabel({ isWindows, isMac })
// Why: the Terminal settings section shares one search index with the
// sidebar. We trim platform-only entries on other platforms so search never
// reveals controls that the renderer will intentionally hide.
@@ -414,6 +431,14 @@ function Settings(): React.JSX.Element {
icon: Bell,
searchEntries: NOTIFICATIONS_PANE_SEARCH_ENTRIES
},
{
id: 'computer-use',
title: 'Computer Use',
description: 'Enable agents to control any app on your computer.',
icon: MousePointerClick,
searchEntries: COMPUTER_USE_PANE_SEARCH_ENTRIES,
badge: 'Beta'
},
...(isMac
? [
{
@@ -756,6 +781,39 @@ function Settings(): React.JSX.Element {
<NotificationsPane settings={settings} updateSettings={updateSettings} />
</SettingsSection>
<SettingsSection
id="computer-use"
title="Computer Use"
badge="Beta"
badgeAccessory={
showComputerUsePreviewTooltip ? (
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label={`${computerUsePlatform} Computer Use preview details`}
>
<Info className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="max-w-72">
<span>
{computerUsePlatform} Computer Use is an early preview. Some apps and
desktop environments may behave inconsistently.
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null
}
description="Enable agents to control any app on your computer."
searchEntries={COMPUTER_USE_PANE_SEARCH_ENTRIES}
>
<ComputerUsePane />
</SettingsSection>
{isMac ? (
<SettingsSection
id="developer-permissions"
@@ -10,6 +10,7 @@ type SettingsSectionProps = {
children: React.ReactNode
className?: string
badge?: string
badgeAccessory?: React.ReactNode
/** Rendered in the section header's upper-right corner — intended for
* section-scoped actions (e.g. "Import from Ghostty") that would otherwise
* crowd the settings list as their own row. */
@@ -24,6 +25,7 @@ export function SettingsSection({
children,
className,
badge,
badgeAccessory,
headerAction
}: SettingsSectionProps): React.JSX.Element | null {
const query = useAppStore((state) => state.settingsSearchQuery)
@@ -52,6 +54,7 @@ export function SettingsSection({
{badge}
</span>
) : null}
{badgeAccessory}
</h2>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
+1
View File
@@ -241,6 +241,7 @@ export type UISlice = {
| 'browser'
| 'appearance'
| 'terminal'
| 'computer-use'
| 'developer-permissions'
| 'shortcuts'
| 'repo'
@@ -0,0 +1,23 @@
export type ComputerUsePermissionId = 'accessibility' | 'screenshots'
export type ComputerUsePermissionStatus = 'granted' | 'not-granted' | 'unsupported'
export type ComputerUsePermissionState = {
id: ComputerUsePermissionId
status: ComputerUsePermissionStatus
}
export type ComputerUsePermissionStatusResult = {
platform: NodeJS.Platform
permissions: ComputerUsePermissionState[]
}
export type ComputerUsePermissionSetupResult = {
platform: NodeJS.Platform
helperAppPath: string | null
permissionId?: ComputerUsePermissionId
openedSettings: boolean
launchedHelper: boolean
permissions?: ComputerUsePermissionState[]
nextStep?: string | null
}
+188
View File
@@ -482,3 +482,191 @@ export type BrowserErrorCode =
| 'browser_debugger_detached'
| 'browser_timeout'
| 'browser_error'
// Computer-use types (see docs/computer-use/plan.md §4 and §12.6).
export const COMPUTER_ERROR_CODES = {
app_not_found: 'app_not_found',
app_blocked: 'app_blocked',
window_not_found: 'window_not_found',
window_stale: 'window_stale',
provider_incompatible: 'provider_incompatible',
unsupported_capability: 'unsupported_capability',
permission_denied: 'permission_denied',
element_not_found: 'element_not_found',
element_not_clickable: 'element_not_clickable',
action_not_supported: 'action_not_supported',
value_not_settable: 'value_not_settable',
invalid_argument: 'invalid_argument',
action_timeout: 'action_timeout',
screenshot_failed: 'screenshot_failed',
accessibility_error: 'accessibility_error'
} as const
export type ComputerErrorCode = keyof typeof COMPUTER_ERROR_CODES
export type ComputerAppQuery = string
export type ComputerSessionTarget = {
session?: string
worktree?: string
app?: ComputerAppQuery
}
export type ComputerListAppsArgs = {
worktree?: string
}
export type ComputerAppInfo = {
name: string
bundleId: string | null
pid: number
}
export type ComputerWindowInfo = {
id?: number | null
title: string
x?: number | null
y?: number | null
width: number
height: number
isMinimized?: boolean | null
isOffscreen?: boolean | null
screenIndex?: number | null
platform?: Record<string, unknown>
}
export type ComputerSnapshotData = {
id: string
app: ComputerAppInfo
window: ComputerWindowInfo
coordinateSpace: 'window'
treeText: string
elementCount: number
focusedElementId: number | null
truncation?: {
truncated: boolean
maxNodes?: number
maxDepth?: number
maxDepthReached?: boolean
}
}
export type ComputerScreenshotData = {
data?: string
format: 'png'
width: number
height: number
scale: number
path?: string
dataOmitted?: boolean
expiresAt?: string
}
export type ComputerScreenshotMetadata = {
engine?: 'screenCaptureKit' | 'cgWindowList' | 'unknown'
windowId?: number | null
}
export type ComputerScreenshotStatus =
| { state: 'captured'; metadata?: ComputerScreenshotMetadata }
| { state: 'skipped'; reason: 'no_screenshot_flag' }
| {
state: 'failed'
code: ComputerErrorCode
message: string
metadata?: ComputerScreenshotMetadata
}
export type ComputerActionMetadata = {
path: 'accessibility' | 'synthetic' | 'clipboard'
actionName?: string | null
fallbackReason?: string | null
targetWindowId?: number | null
verification?: ComputerActionVerification
}
export type ComputerActionVerification =
| {
state: 'verified'
property: 'focusedText' | 'selection'
expected?: string | null
actualPreview?: string | null
}
| {
state: 'unverified'
reason: 'synthetic_input' | 'clipboard_paste' | 'provider_unavailable' | 'window_changed'
}
export type ComputerSnapshotResult = {
snapshot: ComputerSnapshotData
screenshot: ComputerScreenshotData | null
screenshotStatus: ComputerScreenshotStatus
}
export type ComputerActionResult = ComputerSnapshotResult & {
action?: ComputerActionMetadata
}
export type ComputerProviderCapabilities = {
platform: NodeJS.Platform
provider: string
providerVersion: string
protocolVersion: number
supports: {
apps: {
list: boolean
bundleIds: boolean
pids: boolean
}
windows: {
list: boolean
targetById: boolean
targetByIndex: boolean
focus: boolean
moveResize: boolean
}
observation: {
screenshot: boolean
annotatedScreenshot: boolean
elementFrames: boolean
ocr: boolean
}
actions: {
click: boolean
typeText: boolean
pressKey: boolean
hotkey: boolean
pasteText: boolean
scroll: boolean
drag: boolean
setValue: boolean
performAction: boolean
}
surfaces: {
menus: boolean
dialogs: boolean
dock: boolean
menubar: boolean
}
}
}
export type ComputerWindowListWindow = ComputerWindowInfo & {
app: ComputerAppInfo
index: number
isMain?: boolean | null
}
export type ComputerListWindowsResult = {
app: ComputerAppInfo
windows: ComputerWindowListWindow[]
}
export type ComputerListAppsResult = {
apps: (ComputerAppInfo & {
isRunning: boolean
lastUsedAt: string | null
useCount: number | null
})[]
}
+66
View File
@@ -0,0 +1,66 @@
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import type { ComputerActionResult, ComputerSnapshotResult } from '../../src/shared/runtime-types'
import {
ensureGeditLaunched,
killGedit,
parseJsonOutput,
runOrcaCli
} from './helpers/computer-driver'
const isLinux = process.platform === 'linux'
const e2eOptIn = process.env.ORCA_COMPUTER_E2E === '1'
describe.skipIf(!isLinux || !e2eOptIn)('computer-use Linux e2e (gedit)', () => {
beforeAll(async () => {
await ensureGeditLaunched()
})
afterAll(async () => {
await killGedit()
})
test('gedit exposes a basic accessibility tree', async () => {
const result = await runOrcaCli(['computer', 'get-app-state', '--app', 'gedit', '--json'])
const envelope = parseJsonOutput<{ result: ComputerSnapshotResult }>(result.stdout)
expect(envelope.result.snapshot.elementCount).toBeGreaterThan(0)
expect(envelope.result.snapshot.coordinateSpace).toBe('window')
expect(envelope.result.snapshot.truncation?.truncated).toBe(false)
expect(envelope.result.screenshot?.data).toBeUndefined()
expect(envelope.result.screenshot?.path).toContain('orca-computer-use-')
})
test('paste-text mutates the test-owned document', async () => {
const marker = `orca-linux-paste-${Date.now()}`
const action = parseJsonOutput<{ result: ComputerActionResult }>(
(
await runOrcaCli([
'computer',
'paste-text',
'--app',
'gedit',
'--text',
marker,
'--restore-window',
'--no-screenshot',
'--json'
])
).stdout
)
expect(action.result.action?.path).toBe('clipboard')
const after = parseJsonOutput<{ result: ComputerSnapshotResult }>(
(
await runOrcaCli([
'computer',
'get-app-state',
'--app',
'gedit',
'--no-screenshot',
'--json'
])
).stdout
)
expect(after.result.snapshot.treeText).toContain(marker)
})
})
+154
View File
@@ -0,0 +1,154 @@
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import type {
ComputerActionResult,
ComputerListAppsResult,
ComputerSnapshotResult
} from '../../src/shared/runtime-types'
import {
ensureTextEditLaunched,
findRoleIndex,
killTextEdit,
parseJsonOutput,
runOrcaCli
} from './helpers/computer-driver'
const isMac = process.platform === 'darwin'
const e2eOptIn = process.env.ORCA_COMPUTER_E2E === '1'
describe.skipIf(!isMac || !e2eOptIn)('computer-use macOS e2e (TextEdit)', () => {
beforeAll(async () => {
await ensureTextEditLaunched()
})
afterAll(async () => {
await killTextEdit()
})
test('list-apps includes TextEdit', async () => {
const result = await runOrcaCli(['computer', 'list-apps', '--json'])
const envelope = parseJsonOutput<{ result: ComputerListAppsResult }>(result.stdout)
expect(envelope.result.apps.some((app) => app.name === 'TextEdit')).toBe(true)
})
test('get-app-state returns TextEdit state', async () => {
const result = await runOrcaCli(['computer', 'get-app-state', '--app', 'TextEdit', '--json'])
const envelope = parseJsonOutput<{ result: ComputerSnapshotResult }>(result.stdout)
expect(envelope.result.snapshot.app.name).toBe('TextEdit')
expect(envelope.result.snapshot.elementCount).toBeGreaterThan(0)
expect(envelope.result.snapshot.treeText).toContain('Window:')
})
test('click, type-text, and re-observe show inserted text', async () => {
const before = parseJsonOutput<{ result: ComputerSnapshotResult }>(
(await runOrcaCli(['computer', 'get-app-state', '--app', 'TextEdit', '--json'])).stdout
)
const textTarget = findRoleIndex(
before.result.snapshot.treeText,
/^\s*(\d+)\s+(text entry area|text field|HTML content)(?:\s|$)/m
)
expect(textTarget).toBeGreaterThanOrEqual(0)
await runOrcaCli([
'computer',
'click',
'--app',
'TextEdit',
'--element-index',
String(textTarget)
])
const marker = `orca computer e2e ${Date.now()}`
await runOrcaCli(['computer', 'type-text', '--app', 'TextEdit', '--text', marker])
const after = parseJsonOutput<{ result: ComputerSnapshotResult }>(
(await runOrcaCli(['computer', 'get-app-state', '--app', 'TextEdit', '--json'])).stdout
)
expect(after.result.snapshot.treeText).toContain(marker)
})
test('paste-text and hotkey verify TextEdit text replacement', async () => {
const first = parseJsonOutput<{ result: ComputerActionResult }>(
(
await runOrcaCli([
'computer',
'paste-text',
'--app',
'TextEdit',
'--text',
'orca paste first',
'--no-screenshot',
'--json'
])
).stdout
)
expect(first.result.action?.path).toBe('accessibility')
expect(first.result.action?.verification?.state).toBe('verified')
const selectAll = parseJsonOutput<{ result: ComputerActionResult }>(
(
await runOrcaCli([
'computer',
'hotkey',
'--app',
'TextEdit',
'--key',
'CmdOrCtrl+A',
'--no-screenshot',
'--json'
])
).stdout
)
expect(selectAll.result.action?.actionName).toBe('AXSelectAll')
expect(selectAll.result.action?.verification?.state).toBe('verified')
const marker = `orca paste final ${Date.now()}`
const second = parseJsonOutput<{ result: ComputerActionResult }>(
(
await runOrcaCli([
'computer',
'paste-text',
'--app',
'TextEdit',
'--text',
marker,
'--no-screenshot',
'--json'
])
).stdout
)
expect(second.result.action?.actionName).toBe('AXReplaceSelection')
expect(second.result.action?.verification).toMatchObject({
state: 'verified',
property: 'focusedText',
expected: marker
})
const after = parseJsonOutput<{ result: ComputerSnapshotResult }>(
(
await runOrcaCli([
'computer',
'get-app-state',
'--app',
'TextEdit',
'--no-screenshot',
'--json'
])
).stdout
)
expect(after.result.snapshot.treeText).toContain(marker)
expect(after.result.snapshot.treeText).not.toContain('orca paste first')
})
test('screenshot capture returns image metadata', async () => {
const result = await runOrcaCli(['computer', 'get-app-state', '--app', 'TextEdit', '--json'])
const envelope = parseJsonOutput<{ result: ComputerSnapshotResult }>(result.stdout)
expect(envelope.result.screenshotStatus.state).toBe('captured')
expect(envelope.result.screenshot?.format).toBe('png')
expect(envelope.result.screenshot?.data).toBeUndefined()
expect(envelope.result.screenshot?.dataOmitted).toBe(true)
expect(envelope.result.screenshot?.path).toContain('orca-computer-use-')
})
})
+66
View File
@@ -0,0 +1,66 @@
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import type { ComputerActionResult, ComputerSnapshotResult } from '../../src/shared/runtime-types'
import {
ensureNotepadLaunched,
killNotepad,
parseJsonOutput,
runOrcaCli
} from './helpers/computer-driver'
const isWindows = process.platform === 'win32'
const e2eOptIn = process.env.ORCA_COMPUTER_E2E === '1'
describe.skipIf(!isWindows || !e2eOptIn)('computer-use Windows e2e (Notepad)', () => {
beforeAll(async () => {
await ensureNotepadLaunched()
})
afterAll(async () => {
await killNotepad()
})
test('Notepad exposes a basic accessibility tree', async () => {
const result = await runOrcaCli(['computer', 'get-app-state', '--app', 'Notepad', '--json'])
const envelope = parseJsonOutput<{ result: ComputerSnapshotResult }>(result.stdout)
expect(envelope.result.snapshot.elementCount).toBeGreaterThan(0)
expect(envelope.result.snapshot.coordinateSpace).toBe('window')
expect(envelope.result.snapshot.truncation?.truncated).toBe(false)
expect(envelope.result.screenshot?.data).toBeUndefined()
expect(envelope.result.screenshot?.path).toContain('orca-computer-use-')
})
test('paste-text mutates the test-owned document', async () => {
const marker = `orca-windows-paste-${Date.now()}`
const action = parseJsonOutput<{ result: ComputerActionResult }>(
(
await runOrcaCli([
'computer',
'paste-text',
'--app',
'Notepad',
'--text',
marker,
'--restore-window',
'--no-screenshot',
'--json'
])
).stdout
)
expect(action.result.action?.path).toBe('clipboard')
const after = parseJsonOutput<{ result: ComputerSnapshotResult }>(
(
await runOrcaCli([
'computer',
'get-app-state',
'--app',
'Notepad',
'--no-screenshot',
'--json'
])
).stdout
)
expect(after.result.snapshot.treeText).toContain(marker)
})
})
+127
View File
@@ -0,0 +1,127 @@
import { execFile, spawn, type ChildProcess } from 'child_process'
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)
let textEditTempDir: string | null = null
let linuxTempDir: string | null = null
let windowsTempDir: string | null = null
let geditProcess: ChildProcess | null = null
let notepadProcess: ChildProcess | null = null
export type CliResult = {
stdout: string
stderr: string
}
export async function runOrcaCli(args: string[]): Promise<CliResult> {
const devCli = join(process.cwd(), 'config/scripts/orca-dev')
const command = process.env.ORCA_COMPUTER_CLI ?? devCli
const cliArgs = process.env.ORCA_COMPUTER_CLI ? args : args
try {
const result = await execFileAsync(command, cliArgs, {
maxBuffer: 20 * 1024 * 1024
})
return { stdout: result.stdout, stderr: result.stderr }
} catch (error) {
if (error && typeof error === 'object' && 'stdout' in error && 'stderr' in error) {
const output = error as { message: string; stdout: string; stderr: string }
throw new Error(`${output.message}\nstdout:\n${output.stdout}\nstderr:\n${output.stderr}`)
}
throw error
}
}
export async function ensureTextEditLaunched(): Promise<void> {
await killTextEdit()
textEditTempDir = await mkdtemp(join(tmpdir(), 'orca-computer-e2e-'))
const filePath = join(textEditTempDir, 'textedit-target.txt')
await writeFile(filePath, 'seed', 'utf8')
await execFileAsync('open', ['-a', 'TextEdit', '-n', filePath])
await delay(5500)
}
export async function killTextEdit(): Promise<void> {
try {
await execFileAsync('killall', ['TextEdit'])
} catch {
// TextEdit may already be closed by the user or the OS.
}
if (textEditTempDir) {
await rm(textEditTempDir, { force: true, recursive: true })
textEditTempDir = null
}
}
export async function ensureGeditLaunched(): Promise<void> {
await killGedit()
linuxTempDir = await mkdtemp(join(tmpdir(), 'orca-computer-linux-e2e-'))
const filePath = join(linuxTempDir, 'gedit-target.txt')
await writeFile(filePath, 'seed', 'utf8')
geditProcess = spawn('gedit', [filePath], { detached: true, stdio: 'ignore' })
geditProcess.unref()
await delay(3500)
}
export async function killGedit(): Promise<void> {
if (geditProcess?.pid) {
try {
process.kill(-geditProcess.pid, 'SIGTERM')
} catch {
// The test-owned gedit process may already be closed.
}
geditProcess = null
}
if (linuxTempDir) {
await rm(linuxTempDir, { force: true, recursive: true })
linuxTempDir = null
}
}
export async function ensureNotepadLaunched(): Promise<void> {
await killNotepad()
windowsTempDir = await mkdtemp(join(tmpdir(), 'orca-computer-windows-e2e-'))
const filePath = join(windowsTempDir, 'notepad-target.txt')
await writeFile(filePath, 'seed', 'utf8')
notepadProcess = spawn('notepad.exe', [filePath], { detached: true, stdio: 'ignore' })
notepadProcess.unref()
await delay(2500)
}
export async function killNotepad(): Promise<void> {
if (notepadProcess?.pid) {
try {
await execFileAsync('taskkill.exe', ['/PID', String(notepadProcess.pid), '/T', '/F'])
} catch {
// The test-owned Notepad process may already be closed.
}
notepadProcess = null
}
if (windowsTempDir) {
await rm(windowsTempDir, { force: true, recursive: true })
windowsTempDir = null
}
}
export function findRoleIndex(treeText: string, role: string | RegExp): number {
const matcher =
typeof role === 'string'
? new RegExp(`^\\s*(\\d+)\\s+${escapeRegExp(role)}(?:\\s|$)`, 'm')
: role
const match = treeText.match(matcher)
return match?.[1] ? Number.parseInt(match[1], 10) : -1
}
export function parseJsonOutput<T>(stdout: string): T {
return JSON.parse(stdout) as T
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function escapeRegExp(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['tests/e2e/computer-*.e2e.ts'],
testTimeout: 60_000,
hookTimeout: 60_000
}
})
+1
View File
@@ -0,0 +1 @@
*.png