diff --git a/docs/nodes/computer-use.md b/docs/nodes/computer-use.md index 2aa76fb35152..3995daa4fa6b 100644 --- a/docs/nodes/computer-use.md +++ b/docs/nodes/computer-use.md @@ -19,7 +19,7 @@ Provider selection never falls back per action. Switching providers closes the a - **macOS fulfiller:** app setting **Allow Computer Control** enabled. It defaults on; an explicit off choice stays off. - **macOS fulfiller:** choose **Peekaboo** (default) or **CUA**. CUA is selectable only when the pinned driver is present in the signed app bundle; development builds without that artifact show **driver not bundled**. - **macOS fulfiller:** **Accessibility** and **Screen Recording** granted to OpenClaw. The native Peekaboo path also requires Event Posting access for its CoreGraphics input primitives. -- **Windows/Linux fulfiller:** bundled `cua-computer` plugin enabled. Its package includes the pinned CUA Driver SDK 0.19.3 runtime; no `cua-driver` executable, daemon, or MCP server is configured. +- **Windows/Linux fulfiller:** bundled `cua-computer` plugin enabled on Windows x64/ARM64 or glibc-based Linux x64/ARM64. Its package includes the pinned CUA Driver SDK 0.19.3 runtime; no `cua-driver` executable, daemon, or MCP server is configured. - The pairing update that includes `computer.act` approved on the gateway. - A vision-capable agent model. - Tool policy that exposes `computer`. The default `coding` profile does not. Add `computer` to `tools.alsoAllow`; sandboxed agents also need it in `tools.sandbox.tools.alsoAllow`. @@ -72,9 +72,17 @@ The bundled `cua-computer` plugin provides an experimental fulfiller for Windows openclaw plugins enable cua-computer ``` -2. Start `openclaw node run` from the interactive desktop session. The plugin creates its configured SDK runtime lazily, then creates one OpenClaw-owned trusted session for the node-host command execution. It closes that session and shuts down the runtime when the command host stops or restarts. +2. Verify the node-local SDK package before starting the node: -3. Add `computer.act` to the Gateway allowlist. This plugin registers `computer.act` as a dangerous plugin node command, so enabling the plugin alone is not enough; the operator must opt in explicitly: + ```bash + openclaw doctor --lint --only cua-computer/driver-artifacts + ``` + + OpenClaw checks the SDK package version, the selected OS/CPU package version, regular-file identity, and the pinned SHA-256 digest of the native library and Node runtime. A clean check prints `no findings`. If it reports a `COMPUTER_DRIVER_*` error, reinstall or update OpenClaw on this node host and run the check again. Do not download a standalone `cua-driver` executable or add one to `PATH`; Windows and Linux use the npm-installed in-process SDK. + +3. Start `openclaw node run` from the interactive desktop session. The plugin repeats the artifact verification at startup before it imports native code, creates its configured SDK runtime lazily, then creates one OpenClaw-owned trusted session for the node-host command execution. It closes that session and shuts down the runtime when the command host stops or restarts. + +4. Add `computer.act` to the Gateway allowlist. This plugin registers `computer.act` as a dangerous plugin node command, so enabling the plugin alone is not enough; the operator must opt in explicitly: ```json5 { @@ -92,6 +100,8 @@ The plugin calls `CuaDriver.createConfigured`, never bare `create()`. Its author On Windows and Linux this is a hard replacement of the former 0.10 daemon/MCP integration: OpenClaw does not spawn a CUA process or proxy an MCP client. macOS deliberately uses the app-owned embedded daemon described above so the driver remains in `OpenClaw.app`'s TCC responsibility chain. Neither path falls back to another provider for an individual action. +The accepted driver record lives with the `cua-computer` package and supplies both the npm native-file digests and the macOS archive digest. Updating OpenClaw updates that record and the SDK packages together. There is no independent Windows/Linux driver updater or rollback directory because there is no separate driver installation on those hosts; roll back by installing the previous known-good OpenClaw package, then rerun the focused doctor check before restarting the node. + ### Troubleshooting The `cua-computer` fulfiller surfaces typed error codes in the tool result and node logs. Common ones: @@ -99,6 +109,10 @@ The `cua-computer` fulfiller surfaces typed error codes in the tool result and n | Code | Cause | Fix | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `COMPUTER_DRIVER_UNAVAILABLE` | The CUA runtime cannot initialize, the macOS app-owned endpoint is absent, or the desktop permissions/session are unavailable. | On macOS, verify CUA is selected and the bundled driver is ready; on Windows/Linux, run `openclaw node run` inside the interactive desktop session. Reinstall OpenClaw if the pinned runtime is missing. | +| `COMPUTER_DRIVER_PACKAGE_MISSING` | The pinned SDK package, OS/CPU native package, native library, or Node runtime is absent or unreadable. | Reinstall OpenClaw on the node host, rerun `openclaw doctor --lint --only cua-computer/driver-artifacts`, then restart the node. | +| `COMPUTER_DRIVER_VERSION_MISMATCH` | The SDK package or selected native package does not match the accepted 0.19.3 version. | Update or reinstall OpenClaw so both packages come from the same release; rerun the focused doctor check. | +| `COMPUTER_DRIVER_DIGEST_MISMATCH` | A native SDK library or Node runtime is not a regular package file or does not match its pinned SHA-256 digest. | Do not run or replace the file manually. Reinstall OpenClaw, rerun the focused doctor check, then restart the node. | +| `COMPUTER_DRIVER_PLATFORM_UNSUPPORTED` | The node host has no published 0.19.3 native SDK package, such as musl Linux or an unsupported CPU architecture. | Use Windows x64/ARM64 or glibc-based Linux x64/ARM64 for this provider. | | `COMPUTER_REFUSED_` | The driver refused the action with a structured code such as `background_unavailable`, `background_occluded`, or `foreground_unavailable` (KDE/KWin Wayland). | Bring the target window forward, switch to X11, or use a supported compositor. See the compatibility notes above. | | `COMPUTER_STALE_FRAME` | The coordinates referenced a screenshot that is no longer current (context compaction, a display geometry change, or a reference-width change). | Take a fresh `screenshot` before the coordinate action. | | `COMPUTER_STALE_OBSERVATION` | A window or browser reference belongs to an older observation, navigation, execution, or driver generation. | Run `get_window_state` or `get_browser_state` again and retry with the new opaque references. | diff --git a/extensions/cua-computer/api.ts b/extensions/cua-computer/api.ts new file mode 100644 index 000000000000..73adfa331b2a --- /dev/null +++ b/extensions/cua-computer/api.ts @@ -0,0 +1 @@ +export { CUA_DRIVER_ARTIFACT_CHECK_ID, registerCuaDriverDoctorChecks } from "./src/doctor.js"; diff --git a/extensions/cua-computer/index.test.ts b/extensions/cua-computer/index.test.ts index 5dca38cc3ba9..224bed7383e5 100644 --- a/extensions/cua-computer/index.test.ts +++ b/extensions/cua-computer/index.test.ts @@ -9,7 +9,16 @@ import type { OpenClawPluginNodeInvokePolicy, OpenClawPluginNodeInvokePolicyContext, } from "openclaw/plugin-sdk/plugin-entry"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const artifactMocks = vi.hoisted(() => ({ + verify: vi.fn(), +})); + +vi.mock("./src/driver-artifacts.js", () => ({ + verifyInstalledCuaDriverArtifacts: artifactMocks.verify, +})); + import plugin from "./index.js"; function validateManifestConfig(value: unknown) { @@ -24,6 +33,10 @@ function validateManifestConfig(value: unknown) { } describe("cua-computer plugin registration", () => { + beforeEach(() => { + artifactMocks.verify.mockReset().mockReturnValue({ ok: true, applicable: false }); + }); + it("defaults on only for the app-gated macOS provider path", () => { const manifest = JSON.parse( fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"), @@ -74,6 +87,28 @@ describe("cua-computer plugin registration", () => { ]); }); + it("logs the typed artifact diagnostic during plugin startup", () => { + const error = vi.fn(); + artifactMocks.verify.mockReturnValue({ + ok: false, + code: "COMPUTER_DRIVER_PACKAGE_MISSING", + diagnostic: + "COMPUTER_DRIVER_PACKAGE_MISSING: native package absent. Fix: reinstall OpenClaw.", + fixHint: "Reinstall OpenClaw.", + }); + + plugin.register({ + pluginConfig: {}, + logger: { error }, + registerNodeHostCommand: () => {}, + registerNodeInvokePolicy: () => {}, + } as unknown as OpenClawPluginApi); + + expect(error).toHaveBeenCalledWith( + "COMPUTER_DRIVER_PACKAGE_MISSING: native package absent. Fix: reinstall OpenClaw.", + ); + }); + it("forwards an explicitly armed computer action and preserves node refusals", async () => { const policies: OpenClawPluginNodeInvokePolicy[] = []; plugin.register({ diff --git a/extensions/cua-computer/index.ts b/extensions/cua-computer/index.ts index c769583e26bc..f5630f894f88 100644 --- a/extensions/cua-computer/index.ts +++ b/extensions/cua-computer/index.ts @@ -1,7 +1,9 @@ import { registerComputerUseProvider } from "openclaw/plugin-sdk/computer-use"; import { buildPluginConfigSchema, definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { z } from "zod"; +import { registerCuaDriverDoctorChecks } from "./api.js"; import { createCuaComputerProvider } from "./src/commands.js"; +import { verifyInstalledCuaDriverArtifacts } from "./src/driver-artifacts.js"; const CuaComputerConfigSchema = z.strictObject({ // Keep the shipped daemon setting as a named no-op: strict validation accepts @@ -17,12 +19,17 @@ export default definePluginEntry({ description: "Experimental CUA Driver computer control for macOS, Windows, and Linux node hosts.", configSchema, register(api) { + registerCuaDriverDoctorChecks(); const parsed = CuaComputerConfigSchema.safeParse(api.pluginConfig ?? {}); if (!parsed.success) { throw new Error( `Invalid cua-computer plugin config: ${parsed.error.issues[0]?.message ?? "invalid config"}`, ); } + const artifactVerification = verifyInstalledCuaDriverArtifacts(); + if (!artifactVerification.ok) { + api.logger?.error(artifactVerification.diagnostic); + } registerComputerUseProvider(api, createCuaComputerProvider()); // Dangerous plugin command: excluded from default allowlists, and the // Gateway fails closed when this policy registration is missing. diff --git a/extensions/cua-computer/package.json b/extensions/cua-computer/package.json index b4e4a7de410e..861eb2cbed46 100644 --- a/extensions/cua-computer/package.json +++ b/extensions/cua-computer/package.json @@ -11,6 +11,35 @@ "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" }, + "cuaDriverArtifacts": { + "darwin-universal-binary": { + "archiveSha256": "733e28a3782ac8d325f8fce8b5d97486c1054af755b40dfd086151b34c79377e" + }, + "linux-arm64-gnu": { + "files": { + "cua_driver_node_runtime.node": "8347edcfd0a6e842d2e76bd90f465b228f36bf402c0e59da90bfac3fe6004d58", + "libcua_driver_sdk.so": "7263f8980f91585d5a02e0001042b619322bfc5cc56ca9fb626ae56fcccceb2a" + } + }, + "linux-x64-gnu": { + "files": { + "cua_driver_node_runtime.node": "52b70432d2eb167e69632246a38d895c9e7aa61618a31638d393ad6838117293", + "libcua_driver_sdk.so": "31c142f5c67443a1fa933160bfa20d93b9914220fb9a47f2884d38df20ab0671" + } + }, + "win32-arm64-msvc": { + "files": { + "cua_driver_node_runtime.node": "fe025669d1614b1ac9a82d1b6a331acd15b44caef81e5bda6a0b02e1d9a4b71f", + "cua_driver_sdk.dll": "f1f25699dbdcc05169230b8286800b69a10407abb20effd5b767629fe725f21b" + } + }, + "win32-x64-msvc": { + "files": { + "cua_driver_node_runtime.node": "fa9231bfa0c3c9d6deb8ed32d29dd0ef96921b51ea6864c963fd36c99b716b1c", + "cua_driver_sdk.dll": "d7e67baef87fac1a315d86113eeb485fbf8e03e5906797f0bf79d24a990fa38b" + } + } + }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/cua-computer/src/doctor.test.ts b/extensions/cua-computer/src/doctor.test.ts new file mode 100644 index 000000000000..8612bc45aab6 --- /dev/null +++ b/extensions/cua-computer/src/doctor.test.ts @@ -0,0 +1,43 @@ +import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + verify: vi.fn(), +})); + +vi.mock("./driver-artifacts.js", () => ({ + verifyInstalledCuaDriverArtifacts: mocks.verify, +})); + +import { CUA_DRIVER_ARTIFACT_CHECK_ID, registerCuaDriverDoctorChecks } from "./doctor.js"; + +describe("CUA Driver doctor check", () => { + beforeEach(() => { + mocks.verify.mockReset(); + }); + + it("returns the typed artifact failure with the operator repair", async () => { + mocks.verify.mockReturnValue({ + ok: false, + code: "COMPUTER_DRIVER_VERSION_MISMATCH", + diagnostic: "COMPUTER_DRIVER_VERSION_MISMATCH: expected 0.19.3. Fix: reinstall OpenClaw.", + fixHint: "Reinstall OpenClaw.", + }); + let check: HealthCheck | undefined; + registerCuaDriverDoctorChecks({ + registerHealthCheck(value) { + check = value; + }, + }); + + expect(check?.id).toBe(CUA_DRIVER_ARTIFACT_CHECK_ID); + await expect(check?.detect({} as never)).resolves.toEqual([ + expect.objectContaining({ + checkId: CUA_DRIVER_ARTIFACT_CHECK_ID, + severity: "error", + message: expect.stringContaining("COMPUTER_DRIVER_VERSION_MISMATCH"), + fixHint: "Reinstall OpenClaw.", + }), + ]); + }); +}); diff --git a/extensions/cua-computer/src/doctor.ts b/extensions/cua-computer/src/doctor.ts new file mode 100644 index 000000000000..c7abc63aab16 --- /dev/null +++ b/extensions/cua-computer/src/doctor.ts @@ -0,0 +1,53 @@ +import { + getHealthCheck, + registerHealthCheck as registerPluginHealthCheck, + type HealthCheck, +} from "openclaw/plugin-sdk/health"; +import { verifyInstalledCuaDriverArtifacts } from "./driver-artifacts.js"; + +export const CUA_DRIVER_ARTIFACT_CHECK_ID = "cua-computer/driver-artifacts"; + +const cuaDriverArtifactCheck: HealthCheck = { + id: CUA_DRIVER_ARTIFACT_CHECK_ID, + kind: "plugin", + description: "Verify the installed Windows/Linux CUA Driver SDK artifact.", + source: "cua-computer", + async detect() { + const verification = verifyInstalledCuaDriverArtifacts(); + if (verification.ok) { + return []; + } + return [ + { + checkId: CUA_DRIVER_ARTIFACT_CHECK_ID, + severity: "error", + source: "cua-computer", + message: verification.diagnostic, + target: "@trycua/cua-driver", + requirement: "the accepted CUA Driver SDK version and native package digests", + fixHint: verification.fixHint, + }, + ]; + }, +}; + +type CuaDriverDoctorRegistrationHost = { + readonly registerHealthCheck: (check: HealthCheck) => void; +}; + +const registeredHosts = new WeakSet<(check: HealthCheck) => void>(); + +export function registerCuaDriverDoctorChecks(host?: CuaDriverDoctorRegistrationHost): void { + const registerHealthCheck = host?.registerHealthCheck ?? registerPluginHealthCheck; + if (registeredHosts.has(registerHealthCheck)) { + return; + } + if ( + host === undefined && + getHealthCheck(CUA_DRIVER_ARTIFACT_CHECK_ID) === cuaDriverArtifactCheck + ) { + return; + } + registerHealthCheck(cuaDriverArtifactCheck); + registeredHosts.add(registerHealthCheck); +} diff --git a/extensions/cua-computer/src/driver-artifact-verification.ts b/extensions/cua-computer/src/driver-artifact-verification.ts new file mode 100644 index 000000000000..4416caca84b4 --- /dev/null +++ b/extensions/cua-computer/src/driver-artifact-verification.ts @@ -0,0 +1,244 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const DRIVER_PACKAGE = "@trycua/cua-driver"; + +type SupportedArtifactPlatform = + | "linux-arm64-gnu" + | "linux-x64-gnu" + | "win32-arm64-msvc" + | "win32-x64-msvc"; + +type DriverArtifactRecord = { + files: Record; +}; + +type CuaDriverManifest = { + dependencies?: Record; + cuaDriverArtifacts?: Partial>; +}; + +type CuaDriverArtifactDiagnosticCode = + | "COMPUTER_DRIVER_DIGEST_MISMATCH" + | "COMPUTER_DRIVER_MANIFEST_INVALID" + | "COMPUTER_DRIVER_PACKAGE_MISSING" + | "COMPUTER_DRIVER_PLATFORM_UNSUPPORTED" + | "COMPUTER_DRIVER_VERSION_MISMATCH"; + +export type CuaDriverArtifactVerification = + | { ok: true; applicable: false } + | { ok: true; applicable: true; version: string; platformPackage: string } + | { + ok: false; + code: CuaDriverArtifactDiagnosticCode; + diagnostic: string; + fixHint: string; + }; + +type CuaDriverArtifactInspectionOptions = { + platform: NodeJS.Platform; + arch: string; + linuxLibc?: "gnu" | "musl"; + pluginManifestPath: string; + resolvePackageJson: (packageName: string) => string | undefined; +}; + +function failure( + code: CuaDriverArtifactDiagnosticCode, + message: string, + fixHint: string, +): CuaDriverArtifactVerification { + return { ok: false, code, diagnostic: `${code}: ${message} Fix: ${fixHint}`, fixHint }; +} + +function resolveArtifactPlatform( + platform: NodeJS.Platform, + arch: string, + linuxLibc: "gnu" | "musl" | undefined, +): + | { kind: "applicable"; key: SupportedArtifactPlatform } + | { kind: "not-applicable" } + | { kind: "unsupported"; host: string } { + if (platform === "linux") { + if (linuxLibc !== "gnu" || (arch !== "arm64" && arch !== "x64")) { + return { kind: "unsupported", host: `${platform}/${arch}/${linuxLibc ?? "unknown-libc"}` }; + } + return { kind: "applicable", key: `linux-${arch}-gnu` }; + } + if (platform === "win32") { + if (arch !== "arm64" && arch !== "x64") { + return { kind: "unsupported", host: `${platform}/${arch}` }; + } + return { kind: "applicable", key: `win32-${arch}-msvc` }; + } + return { kind: "not-applicable" }; +} + +function readJson(pathname: string): unknown { + return JSON.parse(fs.readFileSync(pathname, "utf8")); +} + +export function readPackageIdentity(pathname: string): { name?: string; version?: string } { + const value = readJson(pathname); + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + const record = value as Record; + return { + name: typeof record.name === "string" ? record.name : undefined, + version: typeof record.version === "string" ? record.version : undefined, + }; +} + +function isSha256(value: unknown): value is string { + return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value); +} + +function loadArtifactRecord( + manifestPath: string, + key: SupportedArtifactPlatform, +): { version: string; artifact: DriverArtifactRecord } | undefined { + const value = readJson(manifestPath) as CuaDriverManifest; + const version = value.dependencies?.[DRIVER_PACKAGE]; + const artifact = value.cuaDriverArtifacts?.[key]; + if ( + typeof version !== "string" || + !/^\d+\.\d+\.\d+$/u.test(version) || + !artifact || + !artifact.files || + Object.keys(artifact.files).length === 0 || + Object.entries(artifact.files).some( + ([filename, digest]) => path.basename(filename) !== filename || !isSha256(digest), + ) + ) { + return undefined; + } + return { version, artifact }; +} + +function hashFile(pathname: string): string { + return createHash("sha256").update(fs.readFileSync(pathname)).digest("hex"); +} + +export function inspectCuaDriverArtifacts( + options: CuaDriverArtifactInspectionOptions, +): CuaDriverArtifactVerification { + const selected = resolveArtifactPlatform(options.platform, options.arch, options.linuxLibc); + if (selected.kind === "not-applicable") { + return { ok: true, applicable: false }; + } + if (selected.kind === "unsupported") { + const fixHint = "Run this node host on Windows x64/ARM64 or glibc-based Linux x64/ARM64."; + return failure( + "COMPUTER_DRIVER_PLATFORM_UNSUPPORTED", + `the pinned CUA Driver SDK has no native package for ${selected.host}.`, + fixHint, + ); + } + + let accepted: ReturnType; + try { + accepted = loadArtifactRecord(options.pluginManifestPath, selected.key); + } catch { + accepted = undefined; + } + if (!accepted) { + const fixHint = "Reinstall OpenClaw from a complete official package."; + return failure( + "COMPUTER_DRIVER_MANIFEST_INVALID", + `the cua-computer artifact record for ${selected.key} is missing or invalid.`, + fixHint, + ); + } + + const platformPackage = `${DRIVER_PACKAGE}-${selected.key}`; + const sdkManifestPath = options.resolvePackageJson(DRIVER_PACKAGE); + const platformManifestPath = options.resolvePackageJson(platformPackage); + if (!sdkManifestPath || !platformManifestPath) { + const missing = sdkManifestPath ? platformPackage : DRIVER_PACKAGE; + const fixHint = `Reinstall OpenClaw on this node host so ${DRIVER_PACKAGE} ${accepted.version} and its native platform package are installed together.`; + return failure( + "COMPUTER_DRIVER_PACKAGE_MISSING", + `${missing} ${accepted.version} is not installed.`, + fixHint, + ); + } + + let sdkIdentity: ReturnType; + let platformIdentity: ReturnType; + try { + sdkIdentity = readPackageIdentity(sdkManifestPath); + platformIdentity = readPackageIdentity(platformManifestPath); + } catch { + const fixHint = + "Reinstall OpenClaw on this node host; do not repair native package files by hand."; + return failure( + "COMPUTER_DRIVER_PACKAGE_MISSING", + "the resolved CUA Driver package metadata cannot be read.", + fixHint, + ); + } + if ( + sdkIdentity.name !== DRIVER_PACKAGE || + platformIdentity.name !== platformPackage || + sdkIdentity.version !== accepted.version || + platformIdentity.version !== accepted.version + ) { + const observed = `${sdkIdentity.name ?? "unknown"}@${sdkIdentity.version ?? "unknown"} + ${platformIdentity.name ?? "unknown"}@${platformIdentity.version ?? "unknown"}`; + const fixHint = `Reinstall or update OpenClaw on this node host so both CUA Driver packages resolve to ${accepted.version}.`; + return failure( + "COMPUTER_DRIVER_VERSION_MISMATCH", + `expected ${DRIVER_PACKAGE} and ${platformPackage} ${accepted.version}, resolved ${observed}.`, + fixHint, + ); + } + + const packageDir = path.dirname(platformManifestPath); + for (const [filename, expectedDigest] of Object.entries(accepted.artifact.files).toSorted( + ([left], [right]) => left.localeCompare(right), + )) { + const pathname = path.join(packageDir, filename); + let stat: fs.Stats; + try { + stat = fs.lstatSync(pathname); + } catch { + const fixHint = `Reinstall OpenClaw on this node host to restore ${platformPackage} ${accepted.version}.`; + return failure( + "COMPUTER_DRIVER_PACKAGE_MISSING", + `${platformPackage} is missing ${filename}.`, + fixHint, + ); + } + if (!stat.isFile() || stat.isSymbolicLink()) { + const fixHint = "Reinstall OpenClaw; the native driver files must be regular package files."; + return failure( + "COMPUTER_DRIVER_DIGEST_MISMATCH", + `${platformPackage}/${filename} is not a regular file.`, + fixHint, + ); + } + let actualDigest: string; + try { + actualDigest = hashFile(pathname); + } catch { + const fixHint = `Reinstall OpenClaw on this node host to restore ${platformPackage} ${accepted.version}.`; + return failure( + "COMPUTER_DRIVER_PACKAGE_MISSING", + `${platformPackage}/${filename} cannot be read.`, + fixHint, + ); + } + if (actualDigest !== expectedDigest) { + const fixHint = + "Reinstall OpenClaw; do not run or replace the mismatched native package files."; + return failure( + "COMPUTER_DRIVER_DIGEST_MISMATCH", + `${platformPackage}/${filename} does not match the accepted ${accepted.version} digest.`, + fixHint, + ); + } + } + + return { ok: true, applicable: true, version: accepted.version, platformPackage }; +} diff --git a/extensions/cua-computer/src/driver-artifacts.test.ts b/extensions/cua-computer/src/driver-artifacts.test.ts new file mode 100644 index 000000000000..0c9e1cf3616b --- /dev/null +++ b/extensions/cua-computer/src/driver-artifacts.test.ts @@ -0,0 +1,144 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { inspectCuaDriverArtifacts } from "./driver-artifact-verification.js"; + +const temporaryDirectories: string[] = []; + +function writeJson(pathname: string, value: unknown): void { + fs.writeFileSync(pathname, `${JSON.stringify(value)}\n`, "utf8"); +} + +function createArtifactFixture( + options: { + platformKey?: "linux-x64-gnu" | "win32-x64-msvc"; + sdkVersion?: string; + platformVersion?: string; + omitPlatformPackage?: boolean; + expectedDigest?: string; + } = {}, +) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cua-artifacts-")); + temporaryDirectories.push(root); + const platformKey = options.platformKey ?? "linux-x64-gnu"; + const acceptedVersion = "0.19.3"; + const nativeFile = platformKey.startsWith("linux") + ? "libcua_driver_sdk.so" + : "cua_driver_sdk.dll"; + const nativeContents = "accepted native artifact"; + const expectedDigest = + options.expectedDigest ?? createHash("sha256").update(nativeContents).digest("hex"); + const pluginManifestPath = path.join(root, "plugin-package.json"); + const sdkManifestPath = path.join(root, "sdk-package.json"); + const platformPackageName = `@trycua/cua-driver-${platformKey}`; + const platformDir = path.join(root, "platform"); + const platformManifestPath = path.join(platformDir, "package.json"); + + fs.mkdirSync(platformDir); + writeJson(pluginManifestPath, { + dependencies: { "@trycua/cua-driver": acceptedVersion }, + cuaDriverArtifacts: { [platformKey]: { files: { [nativeFile]: expectedDigest } } }, + }); + writeJson(sdkManifestPath, { + name: "@trycua/cua-driver", + version: options.sdkVersion ?? acceptedVersion, + }); + writeJson(platformManifestPath, { + name: platformPackageName, + version: options.platformVersion ?? acceptedVersion, + }); + fs.writeFileSync(path.join(platformDir, nativeFile), nativeContents); + + const packages = new Map([["@trycua/cua-driver", sdkManifestPath]]); + if (!options.omitPlatformPackage) { + packages.set(platformPackageName, platformManifestPath); + } + return { + pluginManifestPath, + resolvePackageJson: (packageName: string) => packages.get(packageName), + }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("CUA Driver artifact verification", () => { + it("accepts the pinned SDK and native file digest", () => { + const fixture = createArtifactFixture(); + + expect( + inspectCuaDriverArtifacts({ + platform: "linux", + arch: "x64", + linuxLibc: "gnu", + ...fixture, + }), + ).toEqual({ + ok: true, + applicable: true, + version: "0.19.3", + platformPackage: "@trycua/cua-driver-linux-x64-gnu", + }); + }); + + it("reports an actionable typed diagnostic when the native package is absent", () => { + const fixture = createArtifactFixture({ omitPlatformPackage: true }); + + const result = inspectCuaDriverArtifacts({ + platform: "linux", + arch: "x64", + linuxLibc: "gnu", + ...fixture, + }); + + expect(result).toMatchObject({ ok: false, code: "COMPUTER_DRIVER_PACKAGE_MISSING" }); + expect(result.ok ? "" : result.diagnostic).toContain("Reinstall OpenClaw on this node host"); + }); + + it("refuses SDK and platform package version skew", () => { + const fixture = createArtifactFixture({ platformVersion: "0.19.2" }); + + const result = inspectCuaDriverArtifacts({ + platform: "linux", + arch: "x64", + linuxLibc: "gnu", + ...fixture, + }); + + expect(result).toMatchObject({ ok: false, code: "COMPUTER_DRIVER_VERSION_MISMATCH" }); + expect(result.ok ? "" : result.diagnostic).toContain("resolved @trycua/cua-driver@0.19.3"); + }); + + it("refuses a native file that does not match the accepted digest", () => { + const fixture = createArtifactFixture({ expectedDigest: "0".repeat(64) }); + + const result = inspectCuaDriverArtifacts({ + platform: "linux", + arch: "x64", + linuxLibc: "gnu", + ...fixture, + }); + + expect(result).toMatchObject({ ok: false, code: "COMPUTER_DRIVER_DIGEST_MISMATCH" }); + expect(result.ok ? "" : result.diagnostic).toContain("do not run or replace"); + }); + + it("rejects Linux hosts without a published glibc package", () => { + const fixture = createArtifactFixture(); + + const result = inspectCuaDriverArtifacts({ + platform: "linux", + arch: "x64", + linuxLibc: "musl", + ...fixture, + }); + + expect(result).toMatchObject({ ok: false, code: "COMPUTER_DRIVER_PLATFORM_UNSUPPORTED" }); + expect(result.ok ? "" : result.diagnostic).toContain("glibc-based Linux"); + }); +}); diff --git a/extensions/cua-computer/src/driver-artifacts.ts b/extensions/cua-computer/src/driver-artifacts.ts new file mode 100644 index 000000000000..4b27a2f4b300 --- /dev/null +++ b/extensions/cua-computer/src/driver-artifacts.ts @@ -0,0 +1,57 @@ +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + inspectCuaDriverArtifacts, + readPackageIdentity, + type CuaDriverArtifactVerification, +} from "./driver-artifact-verification.js"; + +const PLUGIN_MANIFEST_PATH = fileURLToPath(new URL("../package.json", import.meta.url)); +const requireFromPlugin = createRequire(import.meta.url); + +function resolvePackageJson(packageName: string): string | undefined { + try { + return requireFromPlugin.resolve(`${packageName}/package.json`); + } catch {} + let entry: string; + try { + entry = requireFromPlugin.resolve(packageName); + } catch { + return undefined; + } + let current = path.dirname(entry); + while (true) { + const candidate = path.join(current, "package.json"); + try { + if (readPackageIdentity(candidate).name === packageName) { + return candidate; + } + } catch {} + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +} + +function detectLinuxLibc(): "gnu" | "musl" { + const report = process.report?.getReport() as + | { header?: { glibcVersionRuntime?: unknown } } + | undefined; + return typeof report?.header?.glibcVersionRuntime === "string" ? "gnu" : "musl"; +} + +let installedVerification: CuaDriverArtifactVerification | undefined; + +export function verifyInstalledCuaDriverArtifacts(): CuaDriverArtifactVerification { + installedVerification ??= inspectCuaDriverArtifacts({ + platform: process.platform, + arch: process.arch, + ...(process.platform === "linux" ? { linuxLibc: detectLinuxLibc() } : {}), + pluginManifestPath: PLUGIN_MANIFEST_PATH, + resolvePackageJson, + }); + return installedVerification; +} diff --git a/extensions/cua-computer/src/driver-client.ts b/extensions/cua-computer/src/driver-client.ts index 5112e51cbaf7..84db8a91a10c 100644 --- a/extensions/cua-computer/src/driver-client.ts +++ b/extensions/cua-computer/src/driver-client.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { verifyInstalledCuaDriverArtifacts } from "./driver-artifacts.js"; type DriverClickButton = import("@trycua/cua-driver").ClickButton; type DriverCaptureScope = import("@trycua/cua-driver").CaptureScope; @@ -310,10 +311,17 @@ class DirectCuaDriverSession implements CuaDriverSession { } async function loadCuaDriverSdk(): Promise { + const artifactVerification = verifyInstalledCuaDriverArtifacts(); + if (!artifactVerification.ok) { + throw new Error(artifactVerification.diagnostic); + } return (await import("@trycua/cua-driver")) as CuaDriverSdk; } function unavailableError(failure: unknown): Error { + if (failure instanceof Error && /^COMPUTER_DRIVER_[A-Z_]+:/u.test(failure.message)) { + return failure; + } const detail = failure instanceof Error ? failure.message : String(failure); return new Error(`COMPUTER_DRIVER_UNAVAILABLE: failed to load CUA Driver SDK: ${detail}`, { cause: failure, diff --git a/scripts/stage-cua-driver-macos.sh b/scripts/stage-cua-driver-macos.sh index 8151d301022f..8f1ee04dbbee 100755 --- a/scripts/stage-cua-driver-macos.sh +++ b/scripts/stage-cua-driver-macos.sh @@ -2,10 +2,11 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -VERSION="0.19.3" +ARTIFACT_MANIFEST="$ROOT_DIR/extensions/cua-computer/package.json" +VERSION="$(node -e 'const manifest = require(process.argv[1]); process.stdout.write(manifest.dependencies["@trycua/cua-driver"]);' "$ARTIFACT_MANIFEST")" TAG="cua-driver-rs-v${VERSION}" ASSET="cua-driver-rs-${VERSION}-darwin-universal-binary.tar.gz" -EXPECTED_SHA256="733e28a3782ac8d325f8fce8b5d97486c1054af755b40dfd086151b34c79377e" +EXPECTED_SHA256="$(node -e 'const manifest = require(process.argv[1]); process.stdout.write(manifest.cuaDriverArtifacts["darwin-universal-binary"].archiveSha256);' "$ARTIFACT_MANIFEST")" DOWNLOAD_URL="https://github.com/trycua/cua/releases/download/${TAG}/${ASSET}" CACHE_DIR="$ROOT_DIR/apps/macos/.build/cua-driver/${TAG}" ARCHIVE="$CACHE_DIR/$ASSET" diff --git a/src/flows/bundled-health-checks.test.ts b/src/flows/bundled-health-checks.test.ts index 06faf9666fb4..1c7701b6fe90 100644 --- a/src/flows/bundled-health-checks.test.ts +++ b/src/flows/bundled-health-checks.test.ts @@ -6,10 +6,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { registerBundledHealthChecks } from "./bundled-health-checks.js"; const mocks = vi.hoisted(() => ({ + registerCuaDriverDoctorChecks: vi.fn(), registerPolicyDoctorChecks: vi.fn(), - loadBundledPluginPublicArtifactModuleSync: vi.fn(() => ({ - registerPolicyDoctorChecks: mocks.registerPolicyDoctorChecks, - })), + loadBundledPluginPublicArtifactModuleSync: vi.fn(({ dirName }: { dirName: string }) => + dirName === "cua-computer" + ? { registerCuaDriverDoctorChecks: mocks.registerCuaDriverDoctorChecks } + : { registerPolicyDoctorChecks: mocks.registerPolicyDoctorChecks }, + ), })); vi.mock("../plugins/public-surface-loader.js", () => ({ @@ -50,6 +53,21 @@ describe("registerBundledHealthChecks", () => { }); }); + it("loads CUA Driver artifact health when the plugin is enabled", () => { + registerBundledHealthChecks({ + cfg: { plugins: { entries: { "cua-computer": { enabled: true } } } }, + cwd: workspaceDir, + }); + + expect(mocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith({ + dirName: "cua-computer", + artifactBasename: "api.js", + }); + expect(mocks.registerCuaDriverDoctorChecks).toHaveBeenCalledWith({ + registerHealthCheck: expect.any(Function), + }); + }); + it("does not use policy.jsonc existence as extension activation", () => { writeFileSync(join(workspaceDir, "policy.jsonc"), "{}\n", "utf-8"); diff --git a/src/flows/bundled-health-checks.ts b/src/flows/bundled-health-checks.ts index 80acc772eeee..ff2e02e3f22a 100644 --- a/src/flows/bundled-health-checks.ts +++ b/src/flows/bundled-health-checks.ts @@ -8,18 +8,37 @@ import { registerHealthCheck } from "./health-check-registry.js"; // Bridges bundled plugin doctor checks into the core health registry. type BundledHealthApi = { + registerCuaDriverDoctorChecks?: (host: { + registerHealthCheck: typeof registerHealthCheck; + }) => void; registerPolicyDoctorChecks?: (host: { registerHealthCheck: typeof registerHealthCheck }) => void; }; /** Registers bundled health checks that are explicitly enabled by config and owner policy. */ export function registerBundledHealthChecks(params: { cfg: OpenClawConfig; cwd?: string }): void { - if (!shouldRegisterPolicyHealth(params)) { - return; + if (shouldRegisterPolicyHealth(params)) { + loadBundledPluginPublicArtifactModuleSync({ + dirName: "policy", + artifactBasename: "api.js", + }).registerPolicyDoctorChecks?.({ registerHealthCheck }); } - loadBundledPluginPublicArtifactModuleSync({ - dirName: "policy", - artifactBasename: "api.js", - }).registerPolicyDoctorChecks?.({ registerHealthCheck }); + if (shouldRegisterPluginHealth(params.cfg, "cua-computer")) { + loadBundledPluginPublicArtifactModuleSync({ + dirName: "cua-computer", + artifactBasename: "api.js", + }).registerCuaDriverDoctorChecks?.({ registerHealthCheck }); + } +} + +function shouldRegisterPluginHealth(cfg: OpenClawConfig, pluginId: string): boolean { + const entry = cfg.plugins?.entries?.[pluginId]; + if (entry?.enabled !== true) { + return false; + } + return passesManifestOwnerBasePolicy({ + plugin: { id: pluginId }, + normalizedConfig: normalizePluginsConfig(cfg.plugins), + }); } function shouldRegisterPolicyHealth(params: { cfg: OpenClawConfig; cwd?: string }): boolean { diff --git a/test/scripts/package-mac-app.test.ts b/test/scripts/package-mac-app.test.ts index 5551167ee3e5..7a3808d19826 100644 --- a/test/scripts/package-mac-app.test.ts +++ b/test/scripts/package-mac-app.test.ts @@ -1408,10 +1408,22 @@ describe("package-mac-app plist stamping", () => { const packageScript = readFileSync(scriptPath, "utf8"); const stageScript = readFileSync("scripts/stage-cua-driver-macos.sh", "utf8"); const codesignScript = readFileSync("scripts/codesign-mac-app.sh", "utf8"); + const cuaManifest = JSON.parse( + readFileSync("extensions/cua-computer/package.json", "utf8"), + ) as { + dependencies: Record; + cuaDriverArtifacts: Record; + }; expect(stageScript).toContain('TAG="cua-driver-rs-v${VERSION}"'); expect(stageScript).toContain( - 'EXPECTED_SHA256="733e28a3782ac8d325f8fce8b5d97486c1054af755b40dfd086151b34c79377e"', + 'ARTIFACT_MANIFEST="$ROOT_DIR/extensions/cua-computer/package.json"', + ); + expect(stageScript).toContain('manifest.dependencies["@trycua/cua-driver"]'); + expect(stageScript).toContain('manifest.cuaDriverArtifacts["darwin-universal-binary"]'); + expect(cuaManifest.dependencies["@trycua/cua-driver"]).toBe("0.19.3"); + expect(cuaManifest.cuaDriverArtifacts["darwin-universal-binary"]?.archiveSha256).toBe( + "733e28a3782ac8d325f8fce8b5d97486c1054af755b40dfd086151b34c79377e", ); expect(packageScript).toContain( '"$ROOT_DIR/scripts/stage-cua-driver-macos.sh" "$APP_ROOT/Contents/Resources/cua-driver"',