534 lines
18 KiB
JavaScript
534 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from 'node:child_process'
|
|
import { existsSync, mkdtempSync, readFileSync, 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: [
|
|
'-c',
|
|
[
|
|
'import ast, pathlib',
|
|
'source=pathlib.Path("native/computer-use-linux/runtime.py").read_text(encoding="utf-8")',
|
|
'ast.parse(source)',
|
|
'print("syntax-ok")'
|
|
].join(';')
|
|
],
|
|
enabled: true
|
|
},
|
|
{
|
|
name: 'native provider argument guardrails',
|
|
run: verifyNativeArgumentGuardrails,
|
|
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,
|
|
env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' },
|
|
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 verifyNativeArgumentGuardrails() {
|
|
const linux = readFileSync(join(repoRoot, 'native/computer-use-linux/runtime.py'), 'utf8')
|
|
const macos = readFileSync(
|
|
join(repoRoot, 'native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift'),
|
|
'utf8'
|
|
)
|
|
const windows = readFileSync(join(repoRoot, 'native/computer-use-windows/runtime.ps1'), 'utf8')
|
|
const failures = []
|
|
if (linux.includes('count or 1') || linux.includes('pages or 1')) {
|
|
failures.push('Linux provider must not coerce explicit zero action values to defaults')
|
|
}
|
|
if (!linux.includes('1 if count is None else count')) {
|
|
failures.push('Linux click_count default must only apply when the value is missing')
|
|
}
|
|
if (!linux.includes('1 if pages is None else pages')) {
|
|
failures.push('Linux pages default must only apply when the value is missing')
|
|
}
|
|
const linuxScrollDefaultPattern = ['str(direction or ', '"down").lower()'].join('')
|
|
if (linux.includes(linuxScrollDefaultPattern) || !linux.includes('direction is required')) {
|
|
failures.push('Linux scroll direction must be required instead of defaulting to down')
|
|
}
|
|
if (
|
|
!linux.includes('def require_non_empty_string(value, name):') ||
|
|
!linux.includes('type_text(require_non_empty_string(operation.get("text"), "text"))') ||
|
|
!linux.includes('press_key(require_non_empty_string(operation.get("key"), "key"))')
|
|
) {
|
|
failures.push(
|
|
'Linux text/key actions must reject missing payloads instead of sending empty input'
|
|
)
|
|
}
|
|
if (!linux.includes('"targetById": False')) {
|
|
failures.push('Linux provider must not advertise stable window-id targeting')
|
|
}
|
|
if (
|
|
!linux.includes('def parse_positive_pid(value):') ||
|
|
!linux.includes('requested_pid is not None and pid_of(app) == requested_pid')
|
|
) {
|
|
failures.push('Linux pid:N selectors must only match positive process ids')
|
|
}
|
|
if (!linux.includes('"windowId": None') || !linux.includes('"windowIndex": window_index')) {
|
|
failures.push('Linux snapshots must expose AT-SPI child targets as windowIndex, not windowId')
|
|
}
|
|
if (
|
|
!linux.includes(
|
|
'def first_descendant(root, predicate, max_nodes=MAX_NODES, max_depth=MAX_DEPTH)'
|
|
)
|
|
) {
|
|
failures.push('Linux focused-element traversal must share the snapshot node/depth budget')
|
|
}
|
|
if (linux.includes('first_descendant(child, predicate)')) {
|
|
failures.push('Linux focused-element traversal must not recurse outside the snapshot budget')
|
|
}
|
|
if (
|
|
linux.includes('return screenshot_payload(best_data, best_width, best_height, original_width)')
|
|
) {
|
|
failures.push('Linux screenshot payloads must not return oversized best-effort PNGs')
|
|
}
|
|
if (!linux.includes('write_clipboard("")')) {
|
|
failures.push(
|
|
'Linux paste_text must clear the temporary clipboard text when no prior value was readable'
|
|
)
|
|
}
|
|
if (!linux.includes('"screenshotError": screenshot.get("error") if screenshot else None')) {
|
|
failures.push('Linux size-dropped screenshots must report a structured screenshotError')
|
|
}
|
|
if (!linux.includes('screenshot exceeded the computer-use payload cap after downscaling')) {
|
|
failures.push('Linux screenshot cap failures must explain the size-drop recovery path')
|
|
}
|
|
if (linux.includes('operation.get("restoreWindow") or has_state')) {
|
|
failures.push('Linux keyboard focus checks must verify focus after --restore-window')
|
|
}
|
|
if (!linux.includes('restoreWindow was requested but the target window is still not focused')) {
|
|
failures.push('Linux keyboard focus failures after restore must explain the recovery state')
|
|
}
|
|
if (!linux.includes('"verification code"') || !linux.includes('return "[redacted]"')) {
|
|
failures.push('Linux secure fields must redact verification-code/password-style values')
|
|
}
|
|
if (!linux.includes('re.search(r"(^|[^a-z0-9])pin([^a-z0-9]|$)", label)')) {
|
|
failures.push(
|
|
'Linux PIN redaction must match standalone PIN labels without hiding spin buttons'
|
|
)
|
|
}
|
|
if (linux.includes('"password", "passcode", "pin", "secret"')) {
|
|
failures.push('Linux secure-field redaction must not treat pin as a raw substring')
|
|
}
|
|
if (!linux.includes('def restore_window(app, window=None):')) {
|
|
failures.push('Linux restore must accept the selected window target')
|
|
}
|
|
if (!linux.includes('Atspi.Component.grab_focus(component)')) {
|
|
failures.push('Linux restore must try AT-SPI focus on the selected window before PID fallback')
|
|
}
|
|
if (/restore_window\(app\)(?!:)/.test(linux)) {
|
|
failures.push('Linux restore call sites must pass the selected window target')
|
|
}
|
|
if (!linux.includes('even when the click uses an accessibility path')) {
|
|
failures.push(
|
|
'Linux clicks must activate the target window before accessibility or pointer input'
|
|
)
|
|
}
|
|
if (
|
|
!linux.includes('compact_browser_tabs=is_browser_app(query, app)') ||
|
|
!linux.includes('inactive browser tabs omitted')
|
|
) {
|
|
failures.push('Linux browser snapshots must compact large inactive browser tab strips')
|
|
}
|
|
if (
|
|
!linux.includes('record.get("isSelected")') ||
|
|
!linux.includes('sanitize_text(record.get("value")) == "1"')
|
|
) {
|
|
failures.push('Linux browser tab compaction must preserve selected-tab fallbacks')
|
|
}
|
|
if (!linux.includes('pointer wheel events should land in the requested app window')) {
|
|
failures.push(
|
|
'Linux synthetic scroll must activate the target window before posting pointer input'
|
|
)
|
|
}
|
|
if (!linux.includes('pointer drags are synthetic global input')) {
|
|
failures.push(
|
|
'Linux synthetic drag must activate the target window before posting pointer input'
|
|
)
|
|
}
|
|
if (
|
|
!linux.includes('"pageup": "Page_Up"') ||
|
|
!linux.includes('"pagedown": "Page_Down"') ||
|
|
!linux.includes('"insert": "Insert"')
|
|
) {
|
|
failures.push('Linux key aliases must accept common PageUp/PageDown/Insert names')
|
|
}
|
|
if (
|
|
!macos.includes('private func typeText') ||
|
|
!macos.includes('TextInput.replaceSelection(focused.element, with: text)')
|
|
) {
|
|
failures.push(
|
|
'macOS typeText must try verified focused AX text replacement before synthetic keyboard fallback'
|
|
)
|
|
}
|
|
if (!macos.includes('even when the click uses an AX action path')) {
|
|
failures.push(
|
|
'macOS clicks must activate the target window before accessibility or pointer input'
|
|
)
|
|
}
|
|
if (
|
|
!macos.includes('if visibleWindowCount > 0') ||
|
|
!macos.includes('ProviderError.coded("permission_denied", "app') ||
|
|
!macos.includes('has visible windows but no accessibility window')
|
|
) {
|
|
failures.push('macOS visible windows without AX access must be reported as permission_denied')
|
|
}
|
|
if (!macos.includes('"pid:\\(app.pid)"')) {
|
|
failures.push(
|
|
'macOS snapshot cache must alias follow-up actions by the documented pid:N selector'
|
|
)
|
|
}
|
|
const macOSElementSignature = swiftFunctionBody(macos, 'elementSignature')
|
|
if (
|
|
macOSElementSignature?.includes('node.value') ||
|
|
macOSElementSignature?.includes('node.placeholder')
|
|
) {
|
|
failures.push(
|
|
'macOS element identity must not include mutable text value or placeholder content'
|
|
)
|
|
}
|
|
const macOSCurrentSnapshot = swiftFunctionBody(macos, 'currentSnapshot')
|
|
const macOSPruneIndex = macOSCurrentSnapshot?.indexOf('pruneSnapshotCache()') ?? -1
|
|
const macOSCachedIndex = macOSCurrentSnapshot?.indexOf('cachedSnapshot(params: params)') ?? -1
|
|
if (macOSPruneIndex < 0 || macOSCachedIndex < 0 || macOSPruneIndex > macOSCachedIndex) {
|
|
failures.push('macOS cached snapshots must be pruned before cached element lookup')
|
|
}
|
|
if (
|
|
!macos.includes('"pageup": 116') ||
|
|
!macos.includes('"pagedown": 121') ||
|
|
!macos.includes('"insert": 114')
|
|
) {
|
|
failures.push('macOS key aliases must accept common PageUp/PageDown/Insert names')
|
|
}
|
|
if (
|
|
!macos.includes('haystack.contains("verification code")') ||
|
|
!macos.includes('return "[redacted]"')
|
|
) {
|
|
failures.push('macOS secure fields must redact verification-code/password-style values')
|
|
}
|
|
if (windows.includes('if ([bool]$Operation.restoreWindow) { return }')) {
|
|
failures.push('Windows keyboard focus checks must verify focus after --restore-window')
|
|
}
|
|
if (
|
|
!windows.includes('function Test-OrcaSensitiveElement') ||
|
|
!windows.includes('"verification code"') ||
|
|
!windows.includes('if (Test-OrcaSensitiveElement $Element) { return "[redacted]" }')
|
|
) {
|
|
failures.push(
|
|
'Windows secure fields must redact labeled verification-code/password-style values'
|
|
)
|
|
}
|
|
if (
|
|
!windows.includes('function Get-OrcaRequiredString($Value, [string]$Name)') ||
|
|
!windows.includes('Send-OrcaText $handle (Get-OrcaRequiredString $Operation.text "text")') ||
|
|
!windows.includes('Send-OrcaKey $handle (Get-OrcaRequiredString $Operation.key "key")')
|
|
) {
|
|
failures.push(
|
|
'Windows text/key actions must reject missing payloads instead of sending empty input'
|
|
)
|
|
}
|
|
if (!windows.includes('even when UI Automation handles the click')) {
|
|
failures.push(
|
|
'Windows clicks must activate the target window before UI Automation or pointer input'
|
|
)
|
|
}
|
|
if (
|
|
!windows.includes('Render-OrcaTree $root $windowFrame (Test-OrcaBrowserProcess $process)') ||
|
|
!windows.includes('inactive browser tabs omitted')
|
|
) {
|
|
failures.push('Windows browser snapshots must compact large inactive browser tab strips')
|
|
}
|
|
if (
|
|
!windows.includes('[bool]$record.isSelected') ||
|
|
!windows.includes('(Format-OrcaSnapshotText $record.value) -eq "1"')
|
|
) {
|
|
failures.push('Windows browser tab compaction must preserve selected-tab fallbacks')
|
|
}
|
|
if (!windows.includes('restoreWindow was requested but the target window is still not focused')) {
|
|
failures.push('Windows keyboard focus failures after restore must explain the recovery state')
|
|
}
|
|
const windowFrameFunction = powerShellFunctionBody(windows, 'Get-OrcaWindowFrame')
|
|
if (!windowFrameFunction?.includes('$null')) {
|
|
failures.push('Windows window-frame fallback must return null when bounds are unavailable')
|
|
}
|
|
if (windowFrameFunction?.includes('screenshot_failed')) {
|
|
failures.push('Windows window-frame fallback must not return screenshot failure objects')
|
|
}
|
|
const renderedElementIndexFunction = powerShellFunctionBody(
|
|
windows,
|
|
'Get-OrcaRenderedElementIndex'
|
|
)
|
|
if (!renderedElementIndexFunction?.includes('$null')) {
|
|
failures.push('Windows rendered element index parsing must return null for non-index lines')
|
|
}
|
|
if (renderedElementIndexFunction?.includes('screenshot_failed')) {
|
|
failures.push(
|
|
'Windows rendered element index parsing must not return screenshot failure objects'
|
|
)
|
|
}
|
|
if (windows.includes('New-OrcaScreenshotPayload $bestBytes $bestWidth $bestHeight')) {
|
|
failures.push('Windows screenshot payloads must not return oversized best-effort PNGs')
|
|
}
|
|
const boundedScreenshotFunction = powerShellFunctionBody(
|
|
windows,
|
|
'Get-OrcaBoundedScreenshotPayload'
|
|
)
|
|
if (!boundedScreenshotFunction?.includes('error = [pscustomobject]')) {
|
|
failures.push('Windows screenshot cap failures must return a structured error object')
|
|
}
|
|
if (
|
|
!boundedScreenshotFunction?.includes(
|
|
'screenshot exceeded the computer-use payload cap after downscaling'
|
|
)
|
|
) {
|
|
failures.push('Windows screenshot cap failures must explain the size-drop recovery path')
|
|
}
|
|
if (
|
|
!windows.includes(
|
|
'screenshotError = if ($null -ne $screenshot) { $screenshot.error } else { $null }'
|
|
)
|
|
) {
|
|
failures.push('Windows size-dropped screenshots must report a structured screenshotError')
|
|
}
|
|
if (
|
|
windows.includes(
|
|
'Send-OrcaMouseClick $handle $point.x $point.y $Operation.mouse_button ([int]$Operation.click_count)'
|
|
)
|
|
) {
|
|
failures.push('Windows click_count must be validated before Send-OrcaMouseClick')
|
|
}
|
|
if (
|
|
!windows.includes('$clickCount = Get-OrcaPositiveInteger $Operation.click_count "click_count"')
|
|
) {
|
|
failures.push('Windows click_count default must be handled by Get-OrcaPositiveInteger')
|
|
}
|
|
if (
|
|
!windows.includes('@("pageup", "page_up")') ||
|
|
!windows.includes('@("pagedown", "page_down")') ||
|
|
!windows.includes('{ return "{INSERT}" }')
|
|
) {
|
|
failures.push('Windows key aliases must accept common PageUp/PageDown/Insert names')
|
|
}
|
|
|
|
for (const failure of failures) {
|
|
console.error(`[computer-native] ${failure}`)
|
|
}
|
|
return failures.length === 0
|
|
}
|
|
|
|
function powerShellFunctionBody(source, name) {
|
|
const start = source.indexOf(`function ${name}`)
|
|
if (start < 0) {
|
|
return null
|
|
}
|
|
const bodyStart = source.indexOf('{', start)
|
|
if (bodyStart < 0) {
|
|
return null
|
|
}
|
|
let depth = 0
|
|
for (let index = bodyStart; index < source.length; index++) {
|
|
const character = source[index]
|
|
if (character === '{') {
|
|
depth++
|
|
} else if (character === '}') {
|
|
depth--
|
|
if (depth === 0) {
|
|
return source.slice(start, index + 1)
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function swiftFunctionBody(source, name) {
|
|
const start = source.indexOf(`func ${name}`)
|
|
if (start < 0) {
|
|
return null
|
|
}
|
|
const bodyStart = source.indexOf('{', start)
|
|
if (bodyStart < 0) {
|
|
return null
|
|
}
|
|
let depth = 0
|
|
for (let index = bodyStart; index < source.length; index++) {
|
|
const character = source[index]
|
|
if (character === '{') {
|
|
depth++
|
|
} else if (character === '}') {
|
|
depth--
|
|
if (depth === 0) {
|
|
return source.slice(start, index + 1)
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
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("'", "'\\''")}'`
|
|
}
|