basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6 7/**8 * Computer Use bootstrap state machine (cua-driver backend).9 *10 * cua-driver is a persistent daemon (`CuaDriver serve` under com.trycua.driver)11 * fronted by a thin `cua-driver mcp` stdio proxy. Tools only work once the12 * daemon has BOTH macOS grants (Accessibility + Screen Recording).13 *14 * First-use permission flow — driven so the user grants ONE permission at a15 * time, and so we can reliably detect progress (the two problems with the16 * native `permissions grant`: it requests both at once, and while its daemon17 * sits in the all-or-nothing gate `permissions status` reports `unknown`, so a18 * partial grant is undetectable). Instead:19 *20 * 1. Run a status-only daemon with `serve --no-permissions-gate` (launched21 * via `open -a CuaDriver` so it carries the com.trycua.driver identity).22 * With the gate off it SERVES IMMEDIATELY even with no grants, so23 * `permissions status --json` returns accurate PER-PERMISSION booleans.24 * 2. POLL status every 5s. Open the System Settings pane for whichever25 * permission is still missing — Accessibility first, then Screen26 * Recording — one at a time, guiding the user.27 * 3. Granting Screen Recording force-restarts the daemon → the next poll28 * reads `unknown`; we relaunch the status daemon and keep polling.29 * 4. Once both are granted, tear the status daemon down and spawn the real30 * proxy. Any residual restart is absorbed by the client's reconnect.31 */32 33import { execFile, spawnSync } from 'node:child_process';34import { promisify } from 'node:util';35import { rmSync } from 'node:fs';36import { homedir } from 'node:os';37import { join } from 'node:path';38import type { ComputerUseClient } from './client.js';39import { isPackageSpecApproved, saveInstallState } from './install-state.js';40import { approvalKey, binaryPath } from './constants.js';41import { ensureInstalled } from './downloader.js';42 43const execFileAsync = promisify(execFile);44 45export interface BootstrapContext {46 signal: AbortSignal;47 updateOutput?: (output: string) => void;48 /** Treat the first-use install as pre-approved (YOLO / AUTO_EDIT / AUTO). */49 autoApproveInstall?: boolean;50}51 52/**53 * Result of a permission probe:54 * - 'ok' both grants present55 * - 'accessibility' Accessibility missing56 * - 'screenRecording' Accessibility present, Screen Recording missing57 * - 'unknown' couldn't read status (no daemon yet / restarting)58 */59export type PermissionProbeResult =60 | 'ok'61 | 'accessibility'62 | 'screenRecording'63 | 'unknown';64 65/** A running status daemon we can tear down. */66export interface StatusDaemon {67 kill: () => void;68}69 70export interface BootstrapDeps {71 homeDir: string;72 approvalKey: string;73 platform: NodeJS.Platform;74 promptInstallApproval: (key: string) => Promise<boolean>;75 install: (onProgress?: (m: string) => void) => Promise<string>;76 /**77 * Launch a status-only daemon (`serve --no-permissions-gate` via78 * `open -a CuaDriver`) so `permissions status` returns per-permission79 * booleans even before any grant. Returns a handle to tear it down.80 */81 startStatusDaemon: () => StatusDaemon;82 /** Read current TCC status (`permissions status --json`). */83 probePermissions: () => Promise<PermissionProbeResult>;84 /** Open the System Settings pane for one permission so the user can grant it. */85 openPermissionPane: (kind: 'accessibility' | 'screenRecording') => void;86 /** Poll interval. Default 5000ms. */87 pollIntervalMs?: number;88 /** Total poll timeout. Default 10 min. */89 pollTimeoutMs?: number;90}91 92/**93 * Parse `cua-driver permissions status --json` into a probe result.94 * Shape: `{ accessibility: bool, screen_recording: bool, ... }`.95 */96export function parsePermissionsStatus(json: string): PermissionProbeResult {97 try {98 const o = JSON.parse(json) as {99 accessibility?: boolean;100 screen_recording?: boolean;101 };102 if (typeof o.accessibility !== 'boolean') return 'unknown';103 if (!o.accessibility) return 'accessibility';104 if (!o.screen_recording) return 'screenRecording';105 return 'ok';106 } catch {107 return 'unknown';108 }109}110 111const SOCKET = () =>112 join(homedir(), 'Library', 'Caches', 'cua-driver', 'cua-driver.sock');113 114function killServeDaemons(): void {115 try {116 spawnSync(117 'pkill',118 ['-f', 'CuaDriver.app/Contents/MacOS/cua-driver serve'],119 {120 stdio: 'ignore',121 },122 );123 } catch {124 // ignore125 }126 try {127 rmSync(SOCKET(), { force: true });128 } catch {129 // ignore130 }131}132 133/** Probe via the window-free `permissions status --json` CLI (non-blocking). */134export async function probePermissionsViaStatus(): Promise<PermissionProbeResult> {135 try {136 const { stdout } = await execFileAsync(137 binaryPath(homedir()),138 ['permissions', 'status', '--json'],139 { timeout: 10_000, env: process.env as NodeJS.ProcessEnv },140 );141 return parsePermissionsStatus(stdout);142 } catch {143 return 'unknown';144 }145}146 147/**148 * Launch the status-only daemon. `open -a CuaDriver` gives it the149 * com.trycua.driver TCC identity; `--no-permissions-gate` makes it serve150 * immediately so status reads work before grants land. Kills any prior daemon151 * first so there is exactly one.152 */153export function startStatusDaemonProcess(): StatusDaemon {154 killServeDaemons();155 try {156 spawnSync(157 'open',158 [159 '-n',160 '-g',161 '-a',162 'CuaDriver',163 '--args',164 'serve',165 '--no-permissions-gate',166 ],167 { stdio: 'ignore' },168 );169 } catch {170 // ignore — the poll loop reports 'unknown' and retries.171 }172 return { kill: killServeDaemons };173}174 175/** Open the System Settings privacy pane for a permission. */176export function openPermissionPaneProcess(177 kind: 'accessibility' | 'screenRecording',178): void {179 const anchor =180 kind === 'accessibility'181 ? 'Privacy_Accessibility'182 : 'Privacy_ScreenCapture';183 try {184 spawnSync(185 'open',186 [`x-apple.systempreferences:com.apple.preference.security?${anchor}`],187 { stdio: 'ignore' },188 );189 } catch {190 // ignore — the message still tells the user where to go.191 }192}193 194/** Production defaults — instantiated lazily so tests can override per call. */195function defaultDeps(): BootstrapDeps {196 const home = homedir();197 return {198 homeDir: home,199 approvalKey: approvalKey(),200 platform: process.platform,201 promptInstallApproval: async (key) => {202 process.stderr.write(203 `\n[Computer Use] First-time setup\n` +204 ` Driver: ${key}\n` +205 ` This downloads a ~20MB signed + notarized binary into ~/.qwen/computer-use/.\n` +206 ` Computer Use can click, type, and read your desktop apps in the background.\n` +207 ` On macOS you'll be guided through Accessibility and Screen Recording permissions next.\n` +208 `Set QWEN_COMPUTER_USE_AUTO_APPROVE=1 to skip this prompt.\n`,209 );210 return process.env['QWEN_COMPUTER_USE_AUTO_APPROVE'] === '1';211 },212 install: (onProgress) => ensureInstalled({ home, onProgress }),213 startStatusDaemon: startStatusDaemonProcess,214 probePermissions: probePermissionsViaStatus,215 openPermissionPane: openPermissionPaneProcess,216 };217}218 219export async function runBootstrap(220 client: ComputerUseClient,221 ctx: BootstrapContext,222 depsOverride?: Partial<BootstrapDeps>,223): Promise<void> {224 const deps: BootstrapDeps = { ...defaultDeps(), ...depsOverride };225 const pollIntervalMs = deps.pollIntervalMs ?? 5000;226 const pollTimeoutMs = deps.pollTimeoutMs ?? 10 * 60_000;227 228 // A warm client (already started this session) has already passed the install229 // gate, the download, and the permission flow — short-circuit before any of230 // them run again. This MUST precede the install gate and `deps.install()`:231 // a started client implies the binary already exists, and otherwise a unit232 // test that injects a started fake client still triggers the real downloader233 // (network + ~20MB) and writes install-state into the repo CWD. (review #1)234 if (client.isStarted()) return;235 236 // Step 1: install approval gate (gates the download).237 const approved = await isPackageSpecApproved(deps.homeDir, deps.approvalKey);238 if (!approved) {239 if (ctx.autoApproveInstall) {240 ctx.updateOutput?.('Computer Use install auto-approved (approval mode).');241 } else {242 ctx.updateOutput?.(243 'Computer Use needs a one-time driver download (first use).',244 );245 const ok = await deps.promptInstallApproval(deps.approvalKey);246 if (!ok) {247 throw new Error(248 `Computer Use install declined by user. Re-invoke the tool to be prompted again.`,249 );250 }251 }252 await saveInstallState(deps.homeDir, {253 approvedPackageSpec: deps.approvalKey,254 approvedAtIso: new Date().toISOString(),255 });256 }257 258 // Step 2: ensure the binary is present (download on first use; no-op after).259 await deps.install(ctx.updateOutput);260 261 // Step 3: macOS permission flow (one permission at a time; see file header).262 if (deps.platform === 'darwin') {263 await ensurePermissions(deps, ctx, pollIntervalMs, pollTimeoutMs);264 }265 266 // Step 4: spawn the proxy against the now-granted daemon.267 await client.start(ctx.updateOutput);268}269 270async function ensurePermissions(271 deps: BootstrapDeps,272 ctx: BootstrapContext,273 pollIntervalMs: number,274 pollTimeoutMs: number,275): Promise<void> {276 // A status-only (no-gate) daemon so `permissions status` reports per-277 // permission booleans throughout — this is what makes partial grants278 // detectable and lets us guide one permission at a time.279 let daemon = deps.startStatusDaemon();280 let openedAccessibility = false;281 let openedScreenRecording = false;282 283 try {284 const startedAt = Date.now();285 for (;;) {286 if (ctx.signal.aborted)287 throw new Error('Computer Use bootstrap aborted.');288 if (Date.now() - startedAt > pollTimeoutMs) {289 throw new Error(290 `Computer Use permission grant timed out after ${Math.round(291 pollTimeoutMs / 1000,292 )}s. Re-invoke the tool to retry.`,293 );294 }295 await sleep(pollIntervalMs);296 const probe = await deps.probePermissions();297 298 if (probe === 'ok') return;299 300 if (probe === 'unknown') {301 // No serving daemon — first launch still coming up, or the daemon was302 // restarted by a Screen-Recording grant. Relaunch and keep polling.303 daemon.kill();304 daemon = deps.startStatusDaemon();305 const elapsed = Math.round((Date.now() - startedAt) / 1000);306 ctx.updateOutput?.(307 `Bringing up Computer Use permissions check… (${elapsed}s)`,308 );309 continue;310 }311 312 if (probe === 'accessibility') {313 if (!openedAccessibility) {314 openedAccessibility = true;315 deps.openPermissionPane('accessibility');316 ctx.updateOutput?.(317 'Step 1/2 — In the System Settings window that opened ' +318 '(Privacy & Security → Accessibility), turn ON CuaDriver. ' +319 'This continues automatically.',320 );321 } else {322 const elapsed = Math.round((Date.now() - startedAt) / 1000);323 ctx.updateOutput?.(324 `Waiting for Accessibility… (${elapsed}s) — enable CuaDriver in System Settings.`,325 );326 }327 continue;328 }329 330 // probe === 'screenRecording'331 if (!openedScreenRecording) {332 openedScreenRecording = true;333 deps.openPermissionPane('screenRecording');334 ctx.updateOutput?.(335 'Step 2/2 — Accessibility granted. Now in System Settings ' +336 '(Privacy & Security → Screen & System Audio Recording), turn ON ' +337 'CuaDriver. macOS will ask to restart CuaDriver — allow it; that is ' +338 'expected. This continues automatically.',339 );340 } else {341 const elapsed = Math.round((Date.now() - startedAt) / 1000);342 ctx.updateOutput?.(343 `Waiting for Screen Recording… (${elapsed}s) — enable CuaDriver in System Settings.`,344 );345 }346 }347 } finally {348 daemon.kill();349 }350}351 352const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));353 