CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
tool.ts494 linesDownload Raw Back to computer-use
1/**2 * @license3 * Copyright 2025 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6 7import {8  BaseDeclarativeTool,9  BaseToolInvocation,10  Kind,11  ToolConfirmationOutcome,12  type ToolInvocation,13  type ToolResult,14  type ToolCallConfirmationDetails,15  type ToolConfirmationPayload,16} from '../tools.js';17import type { PermissionDecision } from '../../permissions/types.js';18import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';19import type { Part, PartListUnion } from '@google/genai';20import { ComputerUseClient } from './client.js';21import type { ComputerUseToolName, ComputerUseToolSchema } from './schemas.js';22import { COMPUTER_USE_SCHEMAS } from './schemas.js';23import { safeJsonStringify } from '../../utils/safeJsonStringify.js';24import { runBootstrap } from './bootstrap.js';25import { isPackageSpecApproved, saveInstallState } from './install-state.js';26import { approvalKey, resolveMaxImageDimension } from './constants.js';27import { type Config } from '../../config/config.js';28import { homedir } from 'node:os';29 30type ComputerUseParams = Record<string, unknown>;31 32const INSTALL_REASON =33  'This downloads the Computer Use driver (~20MB, signed + notarized) into ~/.qwen/computer-use/ the first time. ' +34  'Computer Use can click, type, and read your desktop apps in the background. ' +35  "On macOS you'll be guided through Accessibility / Screen Recording permissions next.";36 37/**38 * Tools / params that perform irreversible or sensitive actions and must NOT be39 * silently auto-approved in AUTO_EDIT mode. They surface a confirmation in40 * AUTO_EDIT; AUTO still routes them through its classifier (getDefaultPermission41 * stays 'ask'); YOLO still auto-approves everything.42 *   - kill_app          force-kills a PID43 *   - launch_app        launches arbitrary apps (incl. with CDP debug ports)44 *   - start_recording   captures the screen to disk45 *   - set_config        mutates driver configuration46 *   - replay_trajectory re-invokes every recorded tool call in a dir via the47 *     same dispatch path — it replays arbitrary actions (kill_app, launch_app,48 *     page execute_javascript, …). Gating the wrapper is the only chokepoint we49 *     have; the replayed sub-actions run inside cua-driver. (review round 2)50 *   - page action 'execute_javascript'           — arbitrary JS in the user's51 *     logged-in browser (cookie / credential exfiltration)52 *   - page action 'enable_javascript_apple_events' — permanently patches the53 *     browser's prefs + quits/relaunches it (more persistent than the one-shot54 *     execute_javascript). (review round 2)55 */56const HIGH_RISK_TOOLS = new Set<ComputerUseToolName>([57  'kill_app',58  'launch_app',59  'start_recording',60  'set_config',61  'replay_trajectory',62]);63 64const HIGH_RISK_PAGE_ACTIONS = new Set([65  'execute_javascript',66  'enable_javascript_apple_events',67]);68 69// Fail fast at module load if a high-risk entry isn't a real tool name. The70// Set<ComputerUseToolName> typing already rejects typos at compile time; this71// also catches the name union drifting from the schema set at runtime. A typo72// would otherwise silently disable the gate for that tool. (review round 3)73for (const t of HIGH_RISK_TOOLS) {74  if (!(t in COMPUTER_USE_SCHEMAS)) {75    throw new Error(`HIGH_RISK_TOOLS contains unknown tool: ${t}`);76  }77}78 79export function isHighRiskCall(80  upstreamName: string,81  params: Record<string, unknown>,82): boolean {83  if (HIGH_RISK_TOOLS.has(upstreamName as ComputerUseToolName)) return true;84  return (85    upstreamName === 'page' &&86    HIGH_RISK_PAGE_ACTIONS.has(params['action'] as string)87  );88}89 90class ComputerUseInvocation extends BaseToolInvocation<91  ComputerUseParams,92  ToolResult93> {94  constructor(95    private readonly upstreamName: ComputerUseToolName,96    params: ComputerUseParams,97    private readonly config?: Config,98  ) {99    super(params);100  }101 102  getDescription(): string {103    return safeJsonStringify(this.params);104  }105 106  /**107   * Always returns 'ask' so every desktop action surfaces through the108   * standard tool-permission dialog. The PermissionManager rule system109   * handles "always allow" per tool via ProceedAlwaysTool — that's the110   * single source of truth for repeat-approval behavior.111   *112   * Earlier this returned 'allow' once the install-state file existed,113   * which conflated install approval with per-action approval and114   * effectively granted blanket permission for all 9 computer_use__*115   * tools (including mutating actions like click / type_text / drag)116   * after the first install confirmation. See PR #4590 review for the117   * full discussion.118   */119  override async getDefaultPermission(): Promise<PermissionDecision> {120    return 'ask';121  }122 123  /**124   * Builds the confirmation dialog. Two variants:125   *126   * 1. Install not yet approved → show install info (download size,127   *    permission flow to follow). onConfirm writes the install state128   *    so runBootstrap() inside execute() skips its env-var fallback129   *    prompt for headless contexts.130   *131   * 2. Install already approved → show per-action info (which tool +132   *    which args) so the user can decide whether THIS specific action133   *    is OK to perform.134   *135   * Both variants set permissionRules so the standard "Always allow"136   * outcomes (ProceedAlwaysTool / ProceedAlwaysUser / ProceedAlwaysProject)137   * add a rule via PermissionManager — subsequent calls of the SAME138   * tool then skip the dialog. Different tools each need their own139   * "always allow" choice; install approval no longer grants blanket140   * access.141   *142   * On Cancel: install state is NOT written; execute() / runBootstrap()143   * will use the env-var fallback (QWEN_COMPUTER_USE_AUTO_APPROVE),144   * which defaults to refusing — producing a clear error message.145   */146  override async getConfirmationDetails(147    _abortSignal: AbortSignal,148  ): Promise<ToolCallConfirmationDetails> {149    const permissionRules = [`computer_use__${this.upstreamName}`];150    const installApproved = await isPackageSpecApproved(151      homedir(),152      approvalKey(),153    );154 155    const onConfirm = async (156      outcome: ToolConfirmationOutcome,157      _payload?: ToolConfirmationPayload,158    ) => {159      // Any non-Cancel outcome means the user approved THIS call. Write install160      // state (idempotent) so runBootstrap() can skip its env-var fallback161      // prompt. PermissionManager handles per-tool "always allow" via162      // permissionRules — install state is no longer a blanket grant.163      if (outcome !== ToolConfirmationOutcome.Cancel) {164        await saveInstallState(homedir(), {165          approvedPackageSpec: approvalKey(),166          approvedAtIso: new Date().toISOString(),167        });168      }169    };170 171    // High-risk calls (review round 1) surface as 'mcp' type so AUTO_EDIT does172    // NOT silently auto-approve them — isAutoEditApproved() only auto-approves173    // 'edit'/'info'. AUTO still routes them through its classifier (this tool's174    // getDefaultPermission stays 'ask'); YOLO still auto-approves everything.175    if (isHighRiskCall(this.upstreamName, this.params)) {176      // NOTE: args are deliberately NOT folded into `title` — no mcp177      // confirmation surface (TUI / non-interactive / ACP) renders the mcp178      // title, so it would be dead text. The args reach the user via the179      // tool-header line (getDescription()). The gate's job is forcing the180      // confirmation (mcp type → not AUTO_EDIT-auto-approved). (review round 3)181      return {182        type: 'mcp',183        title: installApproved184          ? `Allow high-risk Computer Use (${this.upstreamName})`185          : `Allow high-risk Computer Use (${this.upstreamName}) — first use also downloads the driver`,186        serverName: 'cua-driver',187        toolName: this.upstreamName,188        toolDisplayName: `computer_use__${this.upstreamName}`,189        permissionRules,190        onConfirm,191      };192    }193 194    // Non-high-risk: 'info'. The install variant is a SUPERSET — always show195    // Args (the first call can be a mutating action the user must see), then196    // append INSTALL_REASON when install isn't yet approved. (review round 1)197    const argsJson = safeJsonStringify(this.params);198    const prompt = installApproved199      ? `Tool: computer_use__${this.upstreamName}\n\nArgs: ${argsJson}\n\nThis will act on your desktop via the Computer Use binary.`200      : `Tool: computer_use__${this.upstreamName}\n\nArgs: ${argsJson}\n\n${INSTALL_REASON}`;201 202    return {203      type: 'info',204      title: `Allow Computer Use (${this.upstreamName})`,205      prompt,206      permissionRules,207      onConfirm,208    };209  }210 211  async execute(212    signal: AbortSignal,213    updateOutput?: (output: string) => void,214  ): Promise<ToolResult> {215    const client = ComputerUseClient.shared();216 217    // Push the configured screenshot longest-edge cap (setting + env override)218    // onto the shared client BEFORE start: it is applied via set_config once the219    // driver connects (and re-applied on reconnect). undefined → leave the220    // driver default. Cheap + idempotent to set on every call.221    client.setMaxImageDimension(222      resolveMaxImageDimension(this.config?.getComputerUseMaxImageDimension()),223    );224    client.setIdleTimeoutMs(this.config?.getComputerUseIdleTimeoutMs());225 226    // If the user confirmed through the pre-execution dialog, the install state227    // was already written by onConfirm — runBootstrap will skip promptInstallApproval.228    // But several approval modes auto-approve the tool call and bypass that229    // dialog entirely (so onConfirm never runs and install state is never230    // written): YOLO (needsConfirmation() returns false), AUTO_EDIT231    // (isAutoEditApproved() auto-approves info-type tools — all computer_use__*232    // tools are info), and AUTO (classifier-approved calls). In those modes233    // pass autoApproveInstall so the bootstrap honors the already-granted call234    // approval instead of refusing with "install declined by user". DEFAULT235    // still shows the dialog; PLAN blocks. Headless / SDK contexts (no config)236    // fall back to the env-var path in bootstrap's default promptInstallApproval.237    // Reaching execute() means the scheduler already approved THIS call — via238    // the confirmation dialog, a persisted always-allow rule, or an auto-approve239    // mode (YOLO / AUTO_EDIT / AUTO). Treat any of those as install consent. The240    // subtle case is a saved always-allow rule: it SUPPRESSES the dialog, so241    // onConfirm never writes install-state, and in DEFAULT mode bootstrap would242    // then fall into the headless refuse path and dead-end ("install declined")243    // on every retry. Headless / SDK contexts (no config) keep the env-var244    // fallback in bootstrap's default promptInstallApproval. (review round 1)245    const autoApproveInstall = !!this.config;246    await runBootstrap(client, { signal, updateOutput, autoApproveInstall });247 248    let mcpResult: CallToolResult;249    try {250      mcpResult = await client.callTool(this.upstreamName, this.params);251    } catch (err) {252      const message = err instanceof Error ? err.message : String(err);253      return {254        llmContent: `Computer Use tool '${this.upstreamName}' failed: ${message}`,255        returnDisplay: `Error: ${message}`,256        error: { message },257      };258    }259 260    // Transform MCP content blocks into GenAI Parts, preserving image/audio261    // parts so the model can actually "see" screenshots from get_window_state.262    // We also forward cua-driver's `structuredContent`: several tools put the263    // load-bearing data ONLY there, not in the human-readable `content` text —264    // e.g. list_windows' content is just "Found N window(s)" while the real265    // window_id / bounds / is_on_screen live in structuredContent.windows.266    // Dropping it left the model guessing window_ids and failing every267    // screenshot/click on the wrong window.268    // NOTE: mcp-tool.ts has an analogous private transformation (transformMcpContentToParts /269    // transformImageAudioBlock); those helpers are not exported so we replicate270    // the pattern here. A future PR should extract a shared utility.271    const llmContent = buildLlmContent(272      mcpResult.content,273      this.upstreamName,274      mcpResult.structuredContent,275    );276    const returnDisplay = buildDisplayText(mcpResult.content);277 278    if (mcpResult.isError) {279      const errorText =280        returnDisplay || `Tool '${this.upstreamName}' returned isError=true`;281      return {282        llmContent: llmContent || errorText,283        returnDisplay: errorText,284        error: { message: errorText },285      };286    }287 288    return {289      llmContent,290      returnDisplay,291    };292  }293}294 295export class ComputerUseTool extends BaseDeclarativeTool<296  ComputerUseParams,297  ToolResult298> {299  constructor(300    private readonly upstreamName: ComputerUseToolName,301    schema: ComputerUseToolSchema,302    private readonly config?: Config,303  ) {304    const qwenName = `computer_use__${upstreamName}`;305    super(306      qwenName,307      qwenName, // displayName == name; no MCP branding in UI308      schema.description,309      Kind.Other,310      schema.parameterSchema,311      true, // isOutputMarkdown — many results are JSON-ish text or screenshots312      true, // canUpdateOutput — bootstrap streams progress313      true, // shouldDefer — surface only via ToolSearch314      false, // alwaysLoad315      `computer use desktop click type screenshot mouse keyboard scroll drag automation gui app native`,316    );317  }318 319  /**320   * Coerce parameter types before schema validation.321   * Models can send the wrong JS type for a field:322   *  - qwen3.6 sends `element_index: 2` (number) but upstream wants "2" (string)323   *  - Some models send `x: "500"` (string) but upstream wants 500 (number)324   * Pre-coercing avoids spurious validation failures without loosening schema types.325   */326  override validateToolParams(params: ComputerUseParams): string | null {327    const coerced = coerceTypes(328      params,329      this.parameterSchema as Record<string, unknown>,330    );331    return super.validateToolParams(coerced as ComputerUseParams);332  }333 334  override build(335    params: ComputerUseParams,336  ): ToolInvocation<ComputerUseParams, ToolResult> {337    const coerced = coerceTypes(338      params,339      this.parameterSchema as Record<string, unknown>,340    );341    return super.build(coerced as ComputerUseParams);342  }343 344  protected createInvocation(345    params: ComputerUseParams,346  ): ToolInvocation<ComputerUseParams, ToolResult> {347    return new ComputerUseInvocation(this.upstreamName, params, this.config);348  }349}350 351/**352 * Walk schema properties and coerce values to the type declared by the schema.353 *354 * Direction 1 (string → number): schema says integer/number, model sent a355 * numeric string (e.g. `x: "500"`). Garbage strings are left untouched so356 * they still fail schema validation with a clear error.357 *358 * Direction 2 (number → string): schema says string, model sent a number359 * (e.g. `element_index: 2` when upstream expects `"2"`). Coerce via String().360 */361export function coerceTypes(362  params: Record<string, unknown>,363  schema: Record<string, unknown>,364): Record<string, unknown> {365  const properties = (366    schema as { properties?: Record<string, { type?: string }> }367  ).properties;368  if (!properties) return params;369  const result: Record<string, unknown> = { ...params };370  for (const [key, value] of Object.entries(result)) {371    const fieldType = properties[key]?.type;372    // Direction 1: string value, schema wants integer/number → parse373    if (374      (fieldType === 'integer' || fieldType === 'number') &&375      typeof value === 'string'376    ) {377      const trimmed = value.trim();378      // Only coerce if the string is a clean numeric — don't swallow garbage.379      const matchesType =380        fieldType === 'integer'381          ? /^-?\d+$/.test(trimmed)382          : /^-?\d+(\.\d+)?$/.test(trimmed);383      if (matchesType) {384        const parsed =385          fieldType === 'integer' ? parseInt(trimmed, 10) : parseFloat(trimmed);386        if (Number.isFinite(parsed)) {387          result[key] = parsed;388        }389      }390    }391    // Direction 2: number value, schema wants string → stringify392    // (qwen3.6 sometimes sends element_index: 2 instead of "2")393    else if (fieldType === 'string' && typeof value === 'number') {394      result[key] = String(value);395    }396  }397  return result;398}399 400/**401 * @deprecated Use coerceTypes instead. Kept for backward compatibility.402 */403export const coerceNumericStrings = coerceTypes;404 405// ---------------------------------------------------------------------------406// Content transformation helpers407// ---------------------------------------------------------------------------408 409type RawContentBlock = CallToolResult['content'][number];410 411/**412 * Converts MCP content blocks to a GenAI PartListUnion.413 * - Text-only results → plain string (preserves existing caller expectations).414 * - Mixed or image/audio results → Part[] so the model can see screenshots.415 */416export function buildLlmContent(417  content: RawContentBlock[],418  toolName: string,419  structuredContent?: unknown,420): PartListUnion {421  const parts: Part[] = [];422 423  for (const block of content) {424    if (block.type === 'text' && block.text) {425      parts.push({ text: block.text });426    } else if (427      (block.type === 'image' || block.type === 'audio') &&428      block.mimeType &&429      block.data430    ) {431      parts.push({432        text: `[Tool '${toolName}' provided the following ${block.type} data with mime-type: ${block.mimeType}]`,433      });434      parts.push({435        inlineData: {436          mimeType: block.mimeType,437          data: block.data,438        },439      });440    }441    // Other block types (resource, resource_link, etc.) are currently ignored442    // for computer-use; extend here if the MCP server introduces them.443  }444 445  // Forward structuredContent (real window_ids, bounds, on-screen flags, etc.)446  // that the terse `content` text omits. Strip `tree_markdown` first — that447  // field is get_window_state's AX tree, already rendered into the `content`448  // text above, so re-emitting it here would roughly double the token cost.449  const structuredText = stringifyStructured(structuredContent);450  if (structuredText) {451    parts.push({ text: `Structured result: ${structuredText}` });452  }453 454  // If every part is a text Part, collapse to a plain string so callers that455  // do string operations on llmContent (e.g. error-path concatenation) keep456  // working without changes.457  const hasNonText = parts.some((p) => p.inlineData !== undefined);458  if (!hasNonText) {459    return parts460      .map((p) => p.text ?? '')461      .filter(Boolean)462      .join('\n');463  }464 465  return parts;466}467 468/**469 * Builds the human-readable display string (text only, no binary data).470 */471export function buildDisplayText(content: RawContentBlock[]): string {472  return content473    .map((block) => (block.type === 'text' ? (block.text ?? '') : ''))474    .filter(Boolean)475    .join('\n');476}477 478/**479 * Serialize a tool result's `structuredContent` for the model, dropping the480 * `tree_markdown` field (get_window_state's AX tree, already present in the481 * `content` text — re-emitting it would roughly double the token cost).482 * Returns undefined when there is nothing useful to forward.483 */484export function stringifyStructured(structured: unknown): string | undefined {485  if (!structured || typeof structured !== 'object') return undefined;486  const rest: Record<string, unknown> = {};487  for (const [k, v] of Object.entries(structured as Record<string, unknown>)) {488    if (k === 'tree_markdown') continue;489    rest[k] = v;490  }491  if (Object.keys(rest).length === 0) return undefined;492  return safeJsonStringify(rest);493}494 
basant307/AI_Governance_Project · CoolFace