Files
openclaw/extensions/cua-computer/src/actions.ts
Peter Steinberger f695be341c feat(cua-computer): add experimental Windows/Linux computer-use fulfiller (#112267)
* feat(cua-computer): add experimental Windows/Linux computer-use fulfiller

Bundled plugin that fulfills the capability-based computer.act + screen.snapshot
node contract on Windows and Linux by supervising a pinned cua-driver 0.10.x
daemon over MCP stdio. macOS keeps the Peekaboo fulfiller; this plugin is
disabled by default and never available on darwin.

Grounded in cua-driver 0.10.0 source (tool schemas, refusal codes, coordinate
spaces, session/daemon lifecycle). Notable safety and correctness properties:
- Deny-by-default env allowlist so OpenClaw secrets (provider/channel tokens,
  CUA_API_KEY) never reach the separately installed daemon; telemetry and
  update checks forced off.
- Version-gated handshake (exact-minor pin + capability/schema version),
  time-bounded so a corrected driver recovers without a node restart.
- Robust daemon supervision: full readiness-budget polling, startup-race
  tolerance, signal-death and spawn-error recovery, shared-daemon lifecycle
  (never killed on dispose).
- Frame authorization preserved within upstream limits (generation + full live
  geometry; capture refused when screen and screenshot geometry diverge).
- Action mapping refuses inputs cua-driver cannot faithfully deliver:
  layout-shifted keys, modifier-held drag/scroll, Linux modifier clicks,
  hold_key/mouse down-up, non-positive scroll; drag duration clamped.

* fix(cua-computer): satisfy lint, test-types, dead-code, and docs-map gates
2026-07-21 05:58:04 -07:00

177 lines
5.2 KiB
TypeScript

import { z } from "zod";
import type { CuaLastFrame } from "./frame.js";
const COMPUTER_ACTIONS = [
"left_click",
"right_click",
"middle_click",
"double_click",
"triple_click",
"mouse_move",
"left_click_drag",
"left_mouse_down",
"left_mouse_up",
"scroll",
"type",
"key",
"hold_key",
] as const;
export const ComputerActParamsSchema = z.strictObject({
action: z.enum(COMPUTER_ACTIONS),
displayFrameId: z.string().optional(),
x: z.number().finite().nonnegative().optional(),
y: z.number().finite().nonnegative().optional(),
fromX: z.number().finite().nonnegative().optional(),
fromY: z.number().finite().nonnegative().optional(),
text: z.string().optional(),
keys: z.string().optional(),
modifiers: z.string().optional(),
scrollDirection: z.enum(["up", "down", "left", "right"]).optional(),
scrollAmount: z.number().int().positive().optional(),
durationMs: z.number().int().nonnegative().optional(),
screenIndex: z.number().int().nonnegative().optional(),
refWidth: z.number().int().positive().optional(),
});
export type ComputerActParams = z.infer<typeof ComputerActParamsSchema>;
const MODIFIER_ALIASES = new Map<string, string>([
["ctrl", "ctrl"],
["control", "ctrl"],
["shift", "shift"],
["alt", "alt"],
["menu", "alt"],
["option", "alt"],
["mod1", "alt"],
["cmd", "meta"],
["command", "meta"],
["meta", "meta"],
["super", "meta"],
["win", "meta"],
["windows", "meta"],
["mod4", "meta"],
]);
const KEY_ALIASES = new Map<string, string>([
["return", "enter"],
["enter", "enter"],
["tab", "tab"],
["escape", "escape"],
["esc", "escape"],
["space", "space"],
["backspace", "backspace"],
["delete", "delete"],
["del", "delete"],
["insert", "insert"],
["ins", "insert"],
["home", "home"],
["end", "end"],
["pageup", "pageup"],
["pgup", "pageup"],
["pagedown", "pagedown"],
["pgdn", "pagedown"],
["up", "up"],
["down", "down"],
["left", "left"],
["right", "right"],
["capslock", "capslock"],
["numlock", "numlock"],
// Punctuation aliases are intentionally absent: they resolve to characters
// whose shift/AltGr state is layout-dependent and dropped by cua-driver, the
// same reason single punctuation chars are rejected below. Route them to the
// `type` action instead.
]);
for (let index = 1; index <= 12; index += 1) {
KEY_ALIASES.set(`f${index}`, `f${index}`);
}
function unsupportedKey(message: string): Error {
return new Error(`COMPUTER_UNSUPPORTED_KEY: ${message}`);
}
export function normalizeModifiers(value: string | undefined): string[] {
if (!value?.trim()) {
return [];
}
return value.split("+").map((entry) => {
const raw = entry.trim();
const normalized = MODIFIER_ALIASES.get(raw.toLowerCase());
if (!normalized) {
throw unsupportedKey(`unknown modifier ${JSON.stringify(raw)}`);
}
return normalized;
});
}
function normalizeKey(value: string): string {
const raw = value.trim();
if (!raw) {
throw unsupportedKey("key chord contains an empty key");
}
const lowered = raw.toLowerCase();
const modifier = MODIFIER_ALIASES.get(lowered);
if (modifier) {
return modifier;
}
const named = KEY_ALIASES.get(lowered);
if (named) {
return named;
}
// cua-driver 0.10 resolves single characters through VkKeyScanW/keysym lookups
// and keeps only the base virtual key, dropping the shift/AltGr state the
// active layout needs (keyboard.rs key_name_to_vk). ASCII letters are unshifted
// in every Latin layout, so they stay valid chord keys (e.g. ctrl+c). Digits
// and punctuation are shifted on some layouts (AZERTY digits, US symbols), so
// they are rejected toward the `type` action rather than mis-sent.
if (/^[a-z]$/i.test(raw)) {
return lowered;
}
if (raw.length === 1) {
throw unsupportedKey(
`single-character key ${JSON.stringify(raw)} loses layout shift state in cua-driver; use the type action instead`,
);
}
throw unsupportedKey(`unknown key ${JSON.stringify(raw)}`);
}
export function parseKeyChord(value: string | undefined): { key: string; modifiers: string[] } {
const segments = value?.split("+").map((entry) => entry.trim()) ?? [];
const rawKey = segments.pop();
if (!rawKey) {
throw unsupportedKey("key chord is empty");
}
const modifiers = segments.map((entry) => {
const normalized = MODIFIER_ALIASES.get(entry.toLowerCase());
if (!normalized) {
throw unsupportedKey(`unknown modifier ${JSON.stringify(entry)}`);
}
return normalized;
});
return { key: normalizeKey(rawKey), modifiers };
}
export function scalePoint(
frame: CuaLastFrame,
x: number | undefined,
y: number | undefined,
label: string,
): { x: number; y: number } {
if (x === undefined || y === undefined) {
throw new Error(`COMPUTER_INVALID_REQUEST: ${label} coordinates are required`);
}
if (x >= frame.deliveredWidth || y >= frame.deliveredHeight) {
throw new Error(
`COMPUTER_INVALID_REQUEST: ${label} coordinates are outside the captured primary-display frame`,
);
}
return {
x: Math.min(frame.nativeWidth - 1, Math.round((x * frame.nativeWidth) / frame.deliveredWidth)),
y: Math.min(
frame.nativeHeight - 1,
Math.round((y * frame.nativeHeight) / frame.deliveredHeight),
),
};
}