mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(computer-use): repair artifact verification and post-approval descriptor found by the Linux gate (#124128)
* fix(cua-computer): prove Linux X11 live vertical * test(computer-use): authenticate isolated Linux rig * fix(gateway): refresh computer use after node approval * refactor(cua-computer): resolve the plugin manifest by static import * fix(gateway): break plugin runtime import cycle * fix(computer-use): bind live rig to committed helpers
This commit is contained in:
committed by
GitHub
parent
23f84a851a
commit
abf0ef6513
@@ -72,7 +72,9 @@ Browser targets, pages, page elements, and dialogs are opaque capabilities. Reta
|
|||||||
|
|
||||||
### Maintainer live-proof rig
|
### Maintainer live-proof rig
|
||||||
|
|
||||||
The repository includes a macOS-only development rig that preserves the real vertical path: agent-facing `computer` tool, Gateway `node.invoke`, paired Mac node, and the selected node-local provider. It is deliberately isolated from the operator app and Gateway.
|
The repository includes a development rig that preserves the real vertical path: agent-facing `computer` tool, Gateway `node.invoke`, paired node, and the selected node-local provider. It is deliberately isolated from the operator app and Gateway. The macOS path uses the signed app node; the Linux path uses the opt-in `cua-computer` plugin in a real X11 session.
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
|
||||||
Build a signed app from a clean, committed checkout, choose a fresh profile and non-default loopback port, and prepare the two config views:
|
Build a signed app from a clean, committed checkout, choose a fresh profile and non-default loopback port, and prepare the two config views:
|
||||||
|
|
||||||
@@ -95,6 +97,35 @@ scripts/dev/computer-use-macos-live-rig.sh proof \
|
|||||||
|
|
||||||
The proof runner first requires the sole connected computer node to advertise the requested provider, then executes `screenshot`, `list_windows`, `get_window_state`, background element click and type, and re-observes the window. It saves the structured result and target-window before/after images under the scratch directory and fails unless the provider matches, the target started non-frontmost, the frontmost app and cursor stayed unchanged, target content changed, and the final effect was confirmed or a structured refusal. Restart the isolated app with the other provider and rerun the same proof. Do not use port `18789`, the default profile, or `/Applications/OpenClaw.app` for this rig.
|
The proof runner first requires the sole connected computer node to advertise the requested provider, then executes `screenshot`, `list_windows`, `get_window_state`, background element click and type, and re-observes the window. It saves the structured result and target-window before/after images under the scratch directory and fails unless the provider matches, the target started non-frontmost, the frontmost app and cursor stayed unchanged, target content changed, and the final effect was confirmed or a structured refusal. Restart the isolated app with the other provider and rerun the same proof. Do not use port `18789`, the default profile, or `/Applications/OpenClaw.app` for this rig.
|
||||||
|
|
||||||
|
#### Linux X11 through Crabbox
|
||||||
|
|
||||||
|
Run Linux proof on a Crabbox Linux host, not on a macOS container. A direct AWS Crabbox lease with Xvfb is sufficient because Xvfb is a real X11 server; a local container on macOS is not remote Linux desktop proof. Install the X11 fixture prerequisites on the disposable host, then start an isolated session:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y at-spi2-core dbus-x11 gir1.2-gtk-3.0 jq openbox python3-gi x11-utils xdotool xvfb
|
||||||
|
|
||||||
|
dbus-run-session -- bash
|
||||||
|
export DISPLAY=:99 XDG_SESSION_TYPE=x11 NO_AT_BRIDGE=0
|
||||||
|
Xvfb "$DISPLAY" -screen 0 1280x800x24 -nolisten tcp &
|
||||||
|
openbox >/tmp/openclaw-cu-openbox.log 2>&1 &
|
||||||
|
|
||||||
|
scratch="$(mktemp -d /tmp/openclaw-cu-live.XXXXXX)"
|
||||||
|
scripts/dev/computer-use-macos-live-rig.sh prepare-linux \
|
||||||
|
cu-linux-live-proof 29431 "$scratch"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the emitted `gateway`, `node`, and `fixture` commands in separate panes that inherit the same `DISPLAY` and `DBUS_SESSION_BUS_ADDRESS`. The isolated configs share one scratch-only random Gateway token, and the gateway silently approves loopback node-device pairing. The node command surface remains an explicit approval: run the emitted `nodes` command after the node finishes reconnecting, read `.pending[0].requestId`, and pass it to `scripts/dev/computer-use-macos-live-rig.sh approve "$scratch" <request-id>`. Rerun `nodes` until exactly one connected node advertises `provider.id: "cua-computer"`.
|
||||||
|
|
||||||
|
Execute the same proof runner against the non-frontmost GTK fixture:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/dev/computer-use-macos-live-rig.sh proof \
|
||||||
|
"$scratch" cua "OpenClaw CUA X11 Target" "W3-LINUX CONFIRMED"
|
||||||
|
```
|
||||||
|
|
||||||
|
The result and `window-before.png` / `window-after.png` stay under the scratch directory. A confirmed mutation must preserve the sentinel as the active X11 window and leave the pointer unchanged. An upstream `background_unavailable` or `background_occluded` result is valid refusal evidence only when it remains structured and no foreground retry is attempted. The rig rejects native Wayland even when `DISPLAY` is also present for XWayland; switch to X11 instead of claiming Wayland coverage.
|
||||||
|
|
||||||
### Windows and Linux (experimental, direct SDK)
|
### Windows and Linux (experimental, direct SDK)
|
||||||
|
|
||||||
The bundled `cua-computer` plugin provides an experimental fulfiller for Windows and Linux node hosts. It is disabled by default and uses the pinned CUA Driver SDK 0.19.3 contract directly:
|
The bundled `cua-computer` plugin provides an experimental fulfiller for Windows and Linux node hosts. It is disabled by default and uses the pinned CUA Driver SDK 0.19.3 contract directly:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ type CuaDriverArtifactInspectionOptions = {
|
|||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
arch: string;
|
arch: string;
|
||||||
linuxLibc?: "gnu" | "musl";
|
linuxLibc?: "gnu" | "musl";
|
||||||
pluginManifestPath: string;
|
pluginManifest: unknown;
|
||||||
resolvePackageJson: (packageName: string) => string | undefined;
|
resolvePackageJson: (packageName: string) => string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,10 +96,10 @@ function isSha256(value: unknown): value is string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadArtifactRecord(
|
function loadArtifactRecord(
|
||||||
manifestPath: string,
|
manifestValue: unknown,
|
||||||
key: SupportedArtifactPlatform,
|
key: SupportedArtifactPlatform,
|
||||||
): { version: string; artifact: DriverArtifactRecord } | undefined {
|
): { version: string; artifact: DriverArtifactRecord } | undefined {
|
||||||
const value = readJson(manifestPath) as CuaDriverManifest;
|
const value = manifestValue as CuaDriverManifest;
|
||||||
const version = value.dependencies?.[DRIVER_PACKAGE];
|
const version = value.dependencies?.[DRIVER_PACKAGE];
|
||||||
const artifact = value.cuaDriverArtifacts?.[key];
|
const artifact = value.cuaDriverArtifacts?.[key];
|
||||||
if (
|
if (
|
||||||
@@ -139,7 +139,7 @@ export function inspectCuaDriverArtifacts(
|
|||||||
|
|
||||||
let accepted: ReturnType<typeof loadArtifactRecord>;
|
let accepted: ReturnType<typeof loadArtifactRecord>;
|
||||||
try {
|
try {
|
||||||
accepted = loadArtifactRecord(options.pluginManifestPath, selected.key);
|
accepted = loadArtifactRecord(options.pluginManifest, selected.key);
|
||||||
} catch {
|
} catch {
|
||||||
accepted = undefined;
|
accepted = undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
inspect: vi.fn(() => ({ ok: true, applicable: false }) as const),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./driver-artifact-verification.js", () => ({
|
||||||
|
inspectCuaDriverArtifacts: mocks.inspect,
|
||||||
|
readPackageIdentity: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { verifyInstalledCuaDriverArtifacts } from "./driver-artifacts.js";
|
||||||
|
|
||||||
|
it("supplies the accepted artifact record without depending on the bundled module path", () => {
|
||||||
|
verifyInstalledCuaDriverArtifacts();
|
||||||
|
|
||||||
|
expect(mocks.inspect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
pluginManifest: expect.objectContaining({
|
||||||
|
dependencies: expect.objectContaining({ "@trycua/cua-driver": "0.19.3" }),
|
||||||
|
cuaDriverArtifacts: expect.objectContaining({
|
||||||
|
"win32-arm64-msvc": {
|
||||||
|
files: {
|
||||||
|
"cua_driver_node_runtime.node":
|
||||||
|
"fe025669d1614b1ac9a82d1b6a331acd15b44caef81e5bda6a0b02e1d9a4b71f",
|
||||||
|
"cua_driver_sdk.dll":
|
||||||
|
"f1f25699dbdcc05169230b8286800b69a10407abb20effd5b767629fe725f21b",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -30,17 +30,16 @@ function createArtifactFixture(
|
|||||||
const nativeContents = "accepted native artifact";
|
const nativeContents = "accepted native artifact";
|
||||||
const expectedDigest =
|
const expectedDigest =
|
||||||
options.expectedDigest ?? createHash("sha256").update(nativeContents).digest("hex");
|
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 sdkManifestPath = path.join(root, "sdk-package.json");
|
||||||
const platformPackageName = `@trycua/cua-driver-${platformKey}`;
|
const platformPackageName = `@trycua/cua-driver-${platformKey}`;
|
||||||
const platformDir = path.join(root, "platform");
|
const platformDir = path.join(root, "platform");
|
||||||
const platformManifestPath = path.join(platformDir, "package.json");
|
const platformManifestPath = path.join(platformDir, "package.json");
|
||||||
|
|
||||||
fs.mkdirSync(platformDir);
|
fs.mkdirSync(platformDir);
|
||||||
writeJson(pluginManifestPath, {
|
const pluginManifest = {
|
||||||
dependencies: { "@trycua/cua-driver": acceptedVersion },
|
dependencies: { "@trycua/cua-driver": acceptedVersion },
|
||||||
cuaDriverArtifacts: { [platformKey]: { files: { [nativeFile]: expectedDigest } } },
|
cuaDriverArtifacts: { [platformKey]: { files: { [nativeFile]: expectedDigest } } },
|
||||||
});
|
};
|
||||||
writeJson(sdkManifestPath, {
|
writeJson(sdkManifestPath, {
|
||||||
name: "@trycua/cua-driver",
|
name: "@trycua/cua-driver",
|
||||||
version: options.sdkVersion ?? acceptedVersion,
|
version: options.sdkVersion ?? acceptedVersion,
|
||||||
@@ -56,7 +55,7 @@ function createArtifactFixture(
|
|||||||
packages.set(platformPackageName, platformManifestPath);
|
packages.set(platformPackageName, platformManifestPath);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
pluginManifestPath,
|
pluginManifest,
|
||||||
resolvePackageJson: (packageName: string) => packages.get(packageName),
|
resolvePackageJson: (packageName: string) => packages.get(packageName),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import pluginManifest from "../package.json" with { type: "json" };
|
||||||
import {
|
import {
|
||||||
inspectCuaDriverArtifacts,
|
inspectCuaDriverArtifacts,
|
||||||
readPackageIdentity,
|
readPackageIdentity,
|
||||||
type CuaDriverArtifactVerification,
|
type CuaDriverArtifactVerification,
|
||||||
} from "./driver-artifact-verification.js";
|
} from "./driver-artifact-verification.js";
|
||||||
|
|
||||||
const PLUGIN_MANIFEST_PATH = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
||||||
const requireFromPlugin = createRequire(import.meta.url);
|
const requireFromPlugin = createRequire(import.meta.url);
|
||||||
|
|
||||||
function resolvePackageJson(packageName: string): string | undefined {
|
function resolvePackageJson(packageName: string): string | undefined {
|
||||||
@@ -50,7 +49,7 @@ export function verifyInstalledCuaDriverArtifacts(): CuaDriverArtifactVerificati
|
|||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
arch: process.arch,
|
arch: process.arch,
|
||||||
...(process.platform === "linux" ? { linuxLibc: detectLinuxLibc() } : {}),
|
...(process.platform === "linux" ? { linuxLibc: detectLinuxLibc() } : {}),
|
||||||
pluginManifestPath: PLUGIN_MANIFEST_PATH,
|
pluginManifest,
|
||||||
resolvePackageJson,
|
resolvePackageJson,
|
||||||
});
|
});
|
||||||
return installedVerification;
|
return installedVerification;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "3.0")
|
||||||
|
from gi.repository import Gtk # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="OpenClaw Linux X11 computer-use fixture")
|
||||||
|
parser.add_argument("--title", required=True)
|
||||||
|
parser.add_argument("--text", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
window = Gtk.Window(title=args.title)
|
||||||
|
window.set_default_size(520, 180)
|
||||||
|
window.connect("destroy", Gtk.main_quit)
|
||||||
|
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||||
|
box.set_border_width(24)
|
||||||
|
label = Gtk.Label(label="Background-edit target")
|
||||||
|
label.set_xalign(0)
|
||||||
|
entry = Gtk.Entry()
|
||||||
|
entry.set_name("Editor")
|
||||||
|
entry.set_text(args.text)
|
||||||
|
entry.set_activates_default(False)
|
||||||
|
|
||||||
|
box.pack_start(label, False, False, 0)
|
||||||
|
box.pack_start(entry, False, False, 0)
|
||||||
|
window.add(box)
|
||||||
|
window.show_all()
|
||||||
|
Gtk.main()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -20,7 +20,7 @@ const { values } = parseArgs({
|
|||||||
|
|
||||||
if (values.help) {
|
if (values.help) {
|
||||||
console.log(
|
console.log(
|
||||||
"Usage: computer-use-macos-live-proof.ts --provider <peekaboo|cua> --window-title <title> --text <text> --artifacts <dir> [--element-label <label>]",
|
"Usage: computer-use-macos-live-proof.ts --provider <peekaboo|cua> --window-title <title> --text <text> --artifacts <dir> [--element-label <label>]\nRuns on macOS or an X11 Linux session; native Wayland is intentionally refused.",
|
||||||
);
|
);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
@@ -187,11 +187,29 @@ async function saveImage(name: string, result: ToolResult): Promise<string> {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function frontmostApp(): Promise<string> {
|
type FrontmostState = { kind: "application" | "window"; name: string };
|
||||||
const script =
|
|
||||||
'tell application "System Events" to get name of first application process whose frontmost is true';
|
async function frontmostState(): Promise<FrontmostState> {
|
||||||
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", script]);
|
if (process.platform === "darwin") {
|
||||||
return stdout.trim();
|
const script =
|
||||||
|
'tell application "System Events" to get name of first application process whose frontmost is true';
|
||||||
|
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", script]);
|
||||||
|
return { kind: "application", name: stdout.trim() };
|
||||||
|
}
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
if (process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY) {
|
||||||
|
throw new Error("Linux live proof requires X11; native Wayland is out of scope");
|
||||||
|
}
|
||||||
|
const { stdout: windowId } = await execFileAsync("xdotool", ["getactivewindow"]);
|
||||||
|
const { stdout: title } = await execFileAsync("xdotool", ["getwindowname", windowId.trim()]);
|
||||||
|
return { kind: "window", name: title.trim() };
|
||||||
|
}
|
||||||
|
throw new Error(`frontmost-window proof is unsupported on ${process.platform}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTargetFrontmost(frontmost: FrontmostState, target: JsonRecord): boolean {
|
||||||
|
const targetIdentity = frontmost.kind === "application" ? target.appName : target.title;
|
||||||
|
return typeof targetIdentity === "string" && frontmost.name === targetIdentity;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cursor(result: ToolResult): { x: unknown; y: unknown } {
|
function cursor(result: ToolResult): { x: unknown; y: unknown } {
|
||||||
@@ -257,10 +275,10 @@ const targetFields = {
|
|||||||
observationId,
|
observationId,
|
||||||
deliveryMode: "background",
|
deliveryMode: "background",
|
||||||
};
|
};
|
||||||
const frontmostBefore = await frontmostApp();
|
const frontmostBefore = await frontmostState();
|
||||||
if (frontmostBefore === target.appName) {
|
if (isTargetFrontmost(frontmostBefore, target)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`target app ${stringValue(target.appName) || "<unknown>"} is frontmost; foreground another app and retry`,
|
`target ${frontmostBefore.kind} ${frontmostBefore.name || "<unknown>"} is frontmost; foreground another window and retry`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const cursorBeforeResult = await call("get_cursor_position");
|
const cursorBeforeResult = await call("get_cursor_position");
|
||||||
@@ -282,7 +300,7 @@ if (typed.kind === "error" || wireResult(typed.result).effect !== "confirmed") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cursorAfterResult = await call("get_cursor_position");
|
const cursorAfterResult = await call("get_cursor_position");
|
||||||
const frontmostAfter = await frontmostApp();
|
const frontmostAfter = await frontmostState();
|
||||||
const after = await call("get_window_state", { windowRef: target.windowRef });
|
const after = await call("get_window_state", { windowRef: target.windowRef });
|
||||||
const afterImage = await saveImage("window-after", after);
|
const afterImage = await saveImage("window-after", after);
|
||||||
const afterElement = selectElement(after);
|
const afterElement = selectElement(after);
|
||||||
@@ -291,7 +309,8 @@ const cursorAfter = cursor(cursorAfterResult);
|
|||||||
const finalOutcome = confirmation ?? typed;
|
const finalOutcome = confirmation ?? typed;
|
||||||
|
|
||||||
const evidence = {
|
const evidence = {
|
||||||
route: "agent computer tool -> Gateway node.invoke -> paired Mac node -> selected provider",
|
route: "agent computer tool -> Gateway node.invoke -> paired node -> selected provider",
|
||||||
|
platform: process.platform,
|
||||||
provider: { expected: expectedProviderId, advertised: advertisedProvider },
|
provider: { expected: expectedProviderId, advertised: advertisedProvider },
|
||||||
screenshot: resultText(screenshot),
|
screenshot: resultText(screenshot),
|
||||||
target: {
|
target: {
|
||||||
@@ -317,8 +336,9 @@ const evidence = {
|
|||||||
},
|
},
|
||||||
artifacts: { beforeImage, afterImage },
|
artifacts: { beforeImage, afterImage },
|
||||||
assertions: {
|
assertions: {
|
||||||
targetWasNotFrontmost: frontmostBefore !== target.appName,
|
targetWasNotFrontmost: !isTargetFrontmost(frontmostBefore, target),
|
||||||
frontmostUnchanged: frontmostBefore === frontmostAfter,
|
frontmostUnchanged:
|
||||||
|
frontmostBefore.kind === frontmostAfter.kind && frontmostBefore.name === frontmostAfter.name,
|
||||||
cursorUnchanged: cursorBefore.x === cursorAfter.x && cursorBefore.y === cursorAfter.y,
|
cursorUnchanged: cursorBefore.x === cursorAfter.x && cursorBefore.y === cursorAfter.y,
|
||||||
targetContentChanged: beforeElement.value !== afterElement.value,
|
targetContentChanged: beforeElement.value !== afterElement.value,
|
||||||
confirmedEffectOrStructuredRefusal: structuredOutcome(finalOutcome),
|
confirmedEffectOrStructuredRefusal: structuredOutcome(finalOutcome),
|
||||||
|
|||||||
@@ -8,14 +8,20 @@ usage() {
|
|||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
Usage:
|
Usage:
|
||||||
scripts/dev/computer-use-macos-live-rig.sh prepare <profile> <port> <app> <scratch> [peekaboo|cua]
|
scripts/dev/computer-use-macos-live-rig.sh prepare <profile> <port> <app> <scratch> [peekaboo|cua]
|
||||||
|
scripts/dev/computer-use-macos-live-rig.sh prepare-linux <profile> <port> <scratch>
|
||||||
scripts/dev/computer-use-macos-live-rig.sh gateway <scratch>
|
scripts/dev/computer-use-macos-live-rig.sh gateway <scratch>
|
||||||
scripts/dev/computer-use-macos-live-rig.sh app <scratch> [peekaboo|cua]
|
scripts/dev/computer-use-macos-live-rig.sh app <scratch> [peekaboo|cua]
|
||||||
|
scripts/dev/computer-use-macos-live-rig.sh node <scratch>
|
||||||
|
scripts/dev/computer-use-macos-live-rig.sh fixture <scratch> <target-title> <sentinel-title> <before-text>
|
||||||
scripts/dev/computer-use-macos-live-rig.sh nodes <scratch>
|
scripts/dev/computer-use-macos-live-rig.sh nodes <scratch>
|
||||||
|
scripts/dev/computer-use-macos-live-rig.sh approve <scratch> <request-id>
|
||||||
scripts/dev/computer-use-macos-live-rig.sh proof <scratch> <peekaboo|cua> <window-title> <text> [element-label]
|
scripts/dev/computer-use-macos-live-rig.sh proof <scratch> <peekaboo|cua> <window-title> <text> [element-label]
|
||||||
|
|
||||||
The rig is maintainer-only and loopback-only. Run gateway and app in separate
|
The rig is maintainer-only and loopback-only. On macOS, run gateway and app in
|
||||||
terminals, approve the dedicated CLI device after its first `nodes` request,
|
separate terminals. On Linux X11, run gateway, node, and fixture in separate
|
||||||
then run `proof`. Never use the operator profile or port 18789.
|
terminals that share DISPLAY and DBUS_SESSION_BUS_ADDRESS. Approve the isolated
|
||||||
|
node command surface after its first request, then run proof. Never use the
|
||||||
|
operator profile or port 18789. Native Wayland is intentionally unsupported.
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,11 +39,27 @@ validate_provider() {
|
|||||||
|
|
||||||
require_unoccupied_port() {
|
require_unoccupied_port() {
|
||||||
local port="$1"
|
local port="$1"
|
||||||
if /usr/sbin/lsof -nP -iTCP:"$port" -sTCP:LISTEN -t >/dev/null 2>&1; then
|
if ! node - "$port" >/dev/null 2>&1 <<'NODE'; then
|
||||||
|
const net = require("node:net");
|
||||||
|
const port = Number(process.argv[2]);
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once("error", () => process.exit(1));
|
||||||
|
server.listen({ host: "127.0.0.1", port, exclusive: true }, () => server.close());
|
||||||
|
NODE
|
||||||
fail "port $port already has a listener; choose a fresh proof port"
|
fail "port $port already has a listener; choose a fresh proof port"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require_linux_x11() {
|
||||||
|
[[ "$(uname -s)" == "Linux" ]] || fail "Linux proof must run on a Linux host"
|
||||||
|
[[ "${XDG_SESSION_TYPE:-}" != "wayland" && -z "${WAYLAND_DISPLAY:-}" ]] ||
|
||||||
|
fail "native Wayland is out of scope; switch to an X11 session"
|
||||||
|
[[ -n "${DISPLAY:-}" ]] || fail "DISPLAY is required for Linux X11 proof"
|
||||||
|
command -v xdpyinfo >/dev/null || fail "xdpyinfo is required for Linux X11 proof"
|
||||||
|
command -v xdotool >/dev/null || fail "xdotool is required for Linux X11 proof"
|
||||||
|
xdpyinfo >/dev/null 2>&1 || fail "DISPLAY does not resolve to a live X11 server"
|
||||||
|
}
|
||||||
|
|
||||||
load_rig() {
|
load_rig() {
|
||||||
local scratch="$1"
|
local scratch="$1"
|
||||||
[[ "$scratch" = /* ]] || fail "scratch path must be absolute"
|
[[ "$scratch" = /* ]] || fail "scratch path must be absolute"
|
||||||
@@ -49,14 +71,26 @@ load_rig() {
|
|||||||
done < <(node - "$rig_path" <<'NODE'
|
done < <(node - "$rig_path" <<'NODE'
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = process.argv[2];
|
const path = process.argv[2];
|
||||||
const keys = ["root", "profile", "port", "app", "appState", "gatewayConfig", "agentState"];
|
const macKeys = ["root", "profile", "port", "app", "appState", "gatewayConfig", "agentState"];
|
||||||
|
const linuxKeys = [
|
||||||
|
"platform", "root", "profile", "port", "gatewayConfig", "gatewayState",
|
||||||
|
"agentState", "nodeConfig", "nodeState", "display",
|
||||||
|
];
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(fs.readFileSync(path, "utf8"));
|
const value = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected object");
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected object");
|
||||||
const actualKeys = Object.keys(value).sort();
|
const actualKeys = Object.keys(value).sort();
|
||||||
if (actualKeys.join("\0") !== [...keys].sort().join("\0")) throw new Error("unexpected fields");
|
const isMac = actualKeys.join("\0") === [...macKeys].sort().join("\0");
|
||||||
|
const isLinux = actualKeys.join("\0") === [...linuxKeys].sort().join("\0");
|
||||||
|
if (!isMac && !isLinux) throw new Error("unexpected fields");
|
||||||
if (!Number.isInteger(value.port)) throw new Error("port must be an integer");
|
if (!Number.isInteger(value.port)) throw new Error("port must be an integer");
|
||||||
const fields = [value.root, value.profile, String(value.port), value.app, value.appState, value.gatewayConfig, value.agentState];
|
if (isLinux && value.platform !== "linux") throw new Error("invalid platform");
|
||||||
|
const fields = isMac
|
||||||
|
? ["macos", value.root, value.profile, String(value.port), value.app, value.appState,
|
||||||
|
value.gatewayConfig, value.agentState, "", "", "", value.appState]
|
||||||
|
: [value.platform, value.root, value.profile, String(value.port), "", "",
|
||||||
|
value.gatewayConfig, value.agentState, value.nodeConfig, value.nodeState,
|
||||||
|
value.display, value.gatewayState];
|
||||||
if (fields.some((field) => typeof field !== "string" || field.includes("\0"))) throw new Error("invalid field");
|
if (fields.some((field) => typeof field !== "string" || field.includes("\0"))) throw new Error("invalid field");
|
||||||
process.stdout.write(`${fields.join("\0")}\0`);
|
process.stdout.write(`${fields.join("\0")}\0`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -65,27 +99,48 @@ try {
|
|||||||
}
|
}
|
||||||
NODE
|
NODE
|
||||||
)
|
)
|
||||||
[[ ${#rig_values[@]} -eq 7 ]] || fail "invalid $rig_path"
|
[[ ${#rig_values[@]} -eq 12 ]] || fail "invalid $rig_path"
|
||||||
OPENCLAW_CU_RIG_ROOT="${rig_values[0]}"
|
OPENCLAW_CU_RIG_PLATFORM="${rig_values[0]}"
|
||||||
OPENCLAW_CU_RIG_PROFILE="${rig_values[1]}"
|
OPENCLAW_CU_RIG_ROOT="${rig_values[1]}"
|
||||||
OPENCLAW_CU_RIG_PORT="${rig_values[2]}"
|
OPENCLAW_CU_RIG_PROFILE="${rig_values[2]}"
|
||||||
OPENCLAW_CU_RIG_APP="${rig_values[3]}"
|
OPENCLAW_CU_RIG_PORT="${rig_values[3]}"
|
||||||
OPENCLAW_CU_RIG_APP_STATE="${rig_values[4]}"
|
OPENCLAW_CU_RIG_APP="${rig_values[4]}"
|
||||||
OPENCLAW_CU_RIG_GATEWAY_CONFIG="${rig_values[5]}"
|
OPENCLAW_CU_RIG_APP_STATE="${rig_values[5]}"
|
||||||
OPENCLAW_CU_RIG_AGENT_STATE="${rig_values[6]}"
|
OPENCLAW_CU_RIG_GATEWAY_CONFIG="${rig_values[6]}"
|
||||||
|
OPENCLAW_CU_RIG_AGENT_STATE="${rig_values[7]}"
|
||||||
|
OPENCLAW_CU_RIG_NODE_CONFIG="${rig_values[8]}"
|
||||||
|
OPENCLAW_CU_RIG_NODE_STATE="${rig_values[9]}"
|
||||||
|
OPENCLAW_CU_RIG_DISPLAY="${rig_values[10]}"
|
||||||
|
OPENCLAW_CU_RIG_GATEWAY_STATE="${rig_values[11]}"
|
||||||
[[ "$OPENCLAW_CU_RIG_ROOT" == "$repo_root" ]] ||
|
[[ "$OPENCLAW_CU_RIG_ROOT" == "$repo_root" ]] ||
|
||||||
fail "rig belongs to a different checkout: $OPENCLAW_CU_RIG_ROOT"
|
fail "rig belongs to a different checkout: $OPENCLAW_CU_RIG_ROOT"
|
||||||
[[ "$OPENCLAW_CU_RIG_PROFILE" =~ ^[A-Za-z0-9][A-Za-z0-9_-]+$ ]] || fail "invalid rig profile"
|
[[ "$OPENCLAW_CU_RIG_PROFILE" =~ ^[A-Za-z0-9][A-Za-z0-9_-]+$ ]] || fail "invalid rig profile"
|
||||||
[[ "$OPENCLAW_CU_RIG_PORT" =~ ^[0-9]+$ ]] || fail "invalid rig port"
|
[[ "$OPENCLAW_CU_RIG_PORT" =~ ^[0-9]+$ ]] || fail "invalid rig port"
|
||||||
((OPENCLAW_CU_RIG_PORT >= 1024 && OPENCLAW_CU_RIG_PORT <= 65535)) || fail "invalid rig port"
|
((OPENCLAW_CU_RIG_PORT >= 1024 && OPENCLAW_CU_RIG_PORT <= 65535)) || fail "invalid rig port"
|
||||||
((OPENCLAW_CU_RIG_PORT != 18789)) || fail "operator port is not valid rig state"
|
((OPENCLAW_CU_RIG_PORT != 18789)) || fail "operator port is not valid rig state"
|
||||||
[[ "$OPENCLAW_CU_RIG_APP" = /* ]] || fail "invalid rig app path"
|
|
||||||
[[ "$OPENCLAW_CU_RIG_APP_STATE" == "$HOME/.openclaw-$OPENCLAW_CU_RIG_PROFILE" ]] ||
|
|
||||||
fail "rig app state does not match its profile"
|
|
||||||
[[ "$OPENCLAW_CU_RIG_GATEWAY_CONFIG" == "$scratch/gateway.json" ]] ||
|
[[ "$OPENCLAW_CU_RIG_GATEWAY_CONFIG" == "$scratch/gateway.json" ]] ||
|
||||||
fail "rig gateway config is outside its scratch directory"
|
fail "rig gateway config is outside its scratch directory"
|
||||||
[[ "$OPENCLAW_CU_RIG_AGENT_STATE" == "$scratch/agent-state" ]] ||
|
[[ "$OPENCLAW_CU_RIG_AGENT_STATE" == "$scratch/agent-state" ]] ||
|
||||||
fail "rig agent state is outside its scratch directory"
|
fail "rig agent state is outside its scratch directory"
|
||||||
|
case "$OPENCLAW_CU_RIG_PLATFORM" in
|
||||||
|
macos)
|
||||||
|
[[ "$OPENCLAW_CU_RIG_APP" = /* ]] || fail "invalid rig app path"
|
||||||
|
[[ "$OPENCLAW_CU_RIG_APP_STATE" == "$HOME/.openclaw-$OPENCLAW_CU_RIG_PROFILE" ]] ||
|
||||||
|
fail "rig app state does not match its profile"
|
||||||
|
[[ "$OPENCLAW_CU_RIG_GATEWAY_STATE" == "$OPENCLAW_CU_RIG_APP_STATE" ]] ||
|
||||||
|
fail "invalid macOS gateway state"
|
||||||
|
;;
|
||||||
|
linux)
|
||||||
|
[[ "$OPENCLAW_CU_RIG_NODE_CONFIG" == "$scratch/node.json" ]] ||
|
||||||
|
fail "rig node config is outside its scratch directory"
|
||||||
|
[[ "$OPENCLAW_CU_RIG_NODE_STATE" == "$scratch/node-state" ]] ||
|
||||||
|
fail "rig node state is outside its scratch directory"
|
||||||
|
[[ "$OPENCLAW_CU_RIG_GATEWAY_STATE" == "$scratch/gateway-state" ]] ||
|
||||||
|
fail "rig gateway state is outside its scratch directory"
|
||||||
|
[[ -n "$OPENCLAW_CU_RIG_DISPLAY" ]] || fail "invalid Linux display"
|
||||||
|
;;
|
||||||
|
*) fail "invalid rig platform" ;;
|
||||||
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
@@ -115,9 +170,9 @@ prepare() {
|
|||||||
codesign --verify --deep --strict "$app_path" >/dev/null 2>&1 ||
|
codesign --verify --deep --strict "$app_path" >/dev/null 2>&1 ||
|
||||||
fail "app is not a valid signed bundle: $app_path"
|
fail "app is not a valid signed bundle: $app_path"
|
||||||
|
|
||||||
git -C "$repo_root" diff --quiet -- src packages extensions scripts/run-node.mjs scripts/run-node.mts ||
|
git -C "$repo_root" diff --quiet -- src packages extensions scripts ||
|
||||||
fail "runtime sources are dirty; commit and rebuild before launching the node worker"
|
fail "runtime sources are dirty; commit and rebuild before launching the node worker"
|
||||||
git -C "$repo_root" diff --cached --quiet -- src packages extensions scripts/run-node.mjs scripts/run-node.mts ||
|
git -C "$repo_root" diff --cached --quiet -- src packages extensions scripts ||
|
||||||
fail "runtime sources are staged but uncommitted; commit and rebuild first"
|
fail "runtime sources are staged but uncommitted; commit and rebuild first"
|
||||||
|
|
||||||
local app_state="$HOME/.openclaw-$profile"
|
local app_state="$HOME/.openclaw-$profile"
|
||||||
@@ -184,20 +239,108 @@ NODE
|
|||||||
echo "nodes: $0 nodes $scratch"
|
echo "nodes: $0 nodes $scratch"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepare_linux() {
|
||||||
|
[[ $# -eq 3 ]] || { usage; exit 2; }
|
||||||
|
local profile="$1"
|
||||||
|
local port="$2"
|
||||||
|
local scratch="$3"
|
||||||
|
|
||||||
|
require_linux_x11
|
||||||
|
[[ "$profile" =~ ^[A-Za-z0-9][A-Za-z0-9_-]+$ ]] ||
|
||||||
|
fail "profile must contain only letters, digits, underscores, and dashes"
|
||||||
|
case "$profile" in
|
||||||
|
default | main | local) fail "choose a fresh, explicitly isolated profile" ;;
|
||||||
|
esac
|
||||||
|
[[ "$port" =~ ^[0-9]+$ ]] || fail "port must be numeric"
|
||||||
|
((port >= 1024 && port <= 65535)) || fail "port must be between 1024 and 65535"
|
||||||
|
((port != 18789)) || fail "port 18789 belongs to the operator gateway"
|
||||||
|
[[ "$scratch" = /* ]] || fail "scratch path must be absolute"
|
||||||
|
require_unoccupied_port "$port"
|
||||||
|
|
||||||
|
git -C "$repo_root" diff --quiet -- src packages extensions scripts ||
|
||||||
|
fail "runtime sources are dirty; commit and rebuild before launching the node worker"
|
||||||
|
git -C "$repo_root" diff --cached --quiet -- src packages extensions scripts ||
|
||||||
|
fail "runtime sources are staged but uncommitted; commit and rebuild first"
|
||||||
|
|
||||||
|
[[ ! -e "$scratch/rig.json" ]] || fail "$scratch already contains a rig"
|
||||||
|
mkdir -p "$scratch/agent-state" "$scratch/gateway-state" "$scratch/node-state"
|
||||||
|
local gateway_config="$scratch/gateway.json"
|
||||||
|
local node_config="$scratch/node.json"
|
||||||
|
local gateway_token
|
||||||
|
gateway_token="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')"
|
||||||
|
|
||||||
|
node - "$port" "$gateway_token" >"$gateway_config" <<'NODE'
|
||||||
|
const port = Number(process.argv[2]);
|
||||||
|
const token = process.argv[3];
|
||||||
|
process.stdout.write(`${JSON.stringify({
|
||||||
|
gateway: {
|
||||||
|
mode: "local",
|
||||||
|
port,
|
||||||
|
auth: { mode: "token", token },
|
||||||
|
nodes: {
|
||||||
|
commands: { allow: ["computer.act"] },
|
||||||
|
pairing: { autoApproveLocal: true, sshVerify: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
node - "$port" "$gateway_token" >"$node_config" <<'NODE'
|
||||||
|
const port = Number(process.argv[2]);
|
||||||
|
const token = process.argv[3];
|
||||||
|
process.stdout.write(`${JSON.stringify({
|
||||||
|
gateway: {
|
||||||
|
mode: "remote",
|
||||||
|
remote: { transport: "direct", url: `ws://127.0.0.1:${port}`, token },
|
||||||
|
},
|
||||||
|
browser: { enabled: false },
|
||||||
|
nodeHost: { browserProxy: { enabled: false } },
|
||||||
|
plugins: { entries: { "cua-computer": { enabled: true } } },
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
node - "$repo_root" "$profile" "$port" "$gateway_config" "$scratch/gateway-state" \
|
||||||
|
"$scratch/agent-state" "$node_config" "$scratch/node-state" "$DISPLAY" >"$scratch/rig.json" <<'NODE'
|
||||||
|
const [root, profile, port, gatewayConfig, gatewayState, agentState, nodeConfig, nodeState, display] = process.argv.slice(2);
|
||||||
|
process.stdout.write(`${JSON.stringify({
|
||||||
|
platform: "linux",
|
||||||
|
root,
|
||||||
|
profile,
|
||||||
|
port: Number(port),
|
||||||
|
gatewayConfig,
|
||||||
|
gatewayState,
|
||||||
|
agentState,
|
||||||
|
nodeConfig,
|
||||||
|
nodeState,
|
||||||
|
display,
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
chmod 600 "$gateway_config" "$node_config" "$scratch/rig.json"
|
||||||
|
|
||||||
|
echo "prepared isolated Linux X11 profile $profile on ws://127.0.0.1:$port ($DISPLAY)"
|
||||||
|
echo "gateway: $0 gateway $scratch"
|
||||||
|
echo "node: $0 node $scratch"
|
||||||
|
echo "fixture: $0 fixture $scratch 'OpenClaw CUA X11 Target' 'OpenClaw X11 Sentinel' 'W3-LINUX BEFORE'"
|
||||||
|
echo "nodes: $0 nodes $scratch"
|
||||||
|
}
|
||||||
|
|
||||||
run_gateway() {
|
run_gateway() {
|
||||||
[[ $# -eq 1 ]] || { usage; exit 2; }
|
[[ $# -eq 1 ]] || { usage; exit 2; }
|
||||||
load_rig "$1"
|
load_rig "$1"
|
||||||
require_unoccupied_port "$OPENCLAW_CU_RIG_PORT"
|
require_unoccupied_port "$OPENCLAW_CU_RIG_PORT"
|
||||||
|
local auth_mode="none"
|
||||||
|
[[ "$OPENCLAW_CU_RIG_PLATFORM" == "linux" ]] && auth_mode="token"
|
||||||
exec env \
|
exec env \
|
||||||
OPENCLAW_CONFIG_PATH="$OPENCLAW_CU_RIG_GATEWAY_CONFIG" \
|
OPENCLAW_CONFIG_PATH="$OPENCLAW_CU_RIG_GATEWAY_CONFIG" \
|
||||||
OPENCLAW_STATE_DIR="$OPENCLAW_CU_RIG_APP_STATE" \
|
OPENCLAW_STATE_DIR="$OPENCLAW_CU_RIG_GATEWAY_STATE" \
|
||||||
node "$repo_root/scripts/run-node.mjs" --profile "$OPENCLAW_CU_RIG_PROFILE" \
|
node "$repo_root/scripts/run-node.mjs" --profile "$OPENCLAW_CU_RIG_PROFILE" \
|
||||||
gateway run --port "$OPENCLAW_CU_RIG_PORT" --auth none --verbose
|
gateway run --port "$OPENCLAW_CU_RIG_PORT" --auth "$auth_mode" --verbose
|
||||||
}
|
}
|
||||||
|
|
||||||
run_app() {
|
run_app() {
|
||||||
[[ $# -ge 1 && $# -le 2 ]] || { usage; exit 2; }
|
[[ $# -ge 1 && $# -le 2 ]] || { usage; exit 2; }
|
||||||
load_rig "$1"
|
load_rig "$1"
|
||||||
|
[[ "$OPENCLAW_CU_RIG_PLATFORM" == "macos" ]] || fail "app is available only for macOS rigs"
|
||||||
local provider="${2:-peekaboo}"
|
local provider="${2:-peekaboo}"
|
||||||
validate_provider "$provider"
|
validate_provider "$provider"
|
||||||
defaults write "ai.openclaw.mac.profile.$OPENCLAW_CU_RIG_PROFILE" \
|
defaults write "ai.openclaw.mac.profile.$OPENCLAW_CU_RIG_PROFILE" \
|
||||||
@@ -206,6 +349,54 @@ run_app() {
|
|||||||
"$OPENCLAW_CU_RIG_APP/Contents/MacOS/OpenClaw"
|
"$OPENCLAW_CU_RIG_APP/Contents/MacOS/OpenClaw"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
run_node() {
|
||||||
|
[[ $# -eq 1 ]] || { usage; exit 2; }
|
||||||
|
load_rig "$1"
|
||||||
|
[[ "$OPENCLAW_CU_RIG_PLATFORM" == "linux" ]] || fail "node is available only for Linux rigs"
|
||||||
|
require_linux_x11
|
||||||
|
[[ "$DISPLAY" == "$OPENCLAW_CU_RIG_DISPLAY" ]] || fail "DISPLAY does not match rig state"
|
||||||
|
exec env \
|
||||||
|
DISPLAY="$OPENCLAW_CU_RIG_DISPLAY" \
|
||||||
|
OPENCLAW_CONFIG_PATH="$OPENCLAW_CU_RIG_NODE_CONFIG" \
|
||||||
|
OPENCLAW_STATE_DIR="$OPENCLAW_CU_RIG_NODE_STATE" \
|
||||||
|
node "$repo_root/scripts/run-node.mjs" --profile "$OPENCLAW_CU_RIG_PROFILE" \
|
||||||
|
node run --host 127.0.0.1 --port "$OPENCLAW_CU_RIG_PORT" \
|
||||||
|
--display-name "OpenClaw CUA X11 Live Proof"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_fixture() {
|
||||||
|
[[ $# -eq 4 ]] || { usage; exit 2; }
|
||||||
|
local scratch="$1"
|
||||||
|
load_rig "$scratch"
|
||||||
|
[[ "$OPENCLAW_CU_RIG_PLATFORM" == "linux" ]] || fail "fixture is available only for Linux rigs"
|
||||||
|
require_linux_x11
|
||||||
|
[[ "$DISPLAY" == "$OPENCLAW_CU_RIG_DISPLAY" ]] || fail "DISPLAY does not match rig state"
|
||||||
|
command -v python3 >/dev/null || fail "python3 is required for the Linux proof fixture"
|
||||||
|
command -v xmessage >/dev/null || fail "xmessage is required for the Linux proof sentinel"
|
||||||
|
|
||||||
|
local target_title="$2"
|
||||||
|
local sentinel_title="$3"
|
||||||
|
local before_text="$4"
|
||||||
|
local target_pid sentinel_pid target_window sentinel_window
|
||||||
|
python3 "$repo_root/scripts/dev/computer-use-linux-x11-fixture.py" \
|
||||||
|
--title "$target_title" --text "$before_text" &
|
||||||
|
target_pid=$!
|
||||||
|
xmessage -title "$sentinel_title" "This window must remain frontmost during proof." &
|
||||||
|
sentinel_pid=$!
|
||||||
|
trap 'kill "$target_pid" "$sentinel_pid" >/dev/null 2>&1 || true' EXIT INT TERM
|
||||||
|
|
||||||
|
for _ in {1..100}; do
|
||||||
|
target_window="$(xdotool search --name "$target_title" 2>/dev/null | head -n 1 || true)"
|
||||||
|
sentinel_window="$(xdotool search --name "$sentinel_title" 2>/dev/null | head -n 1 || true)"
|
||||||
|
[[ -n "$target_window" && -n "$sentinel_window" ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ -n "$target_window" && -n "$sentinel_window" ]] || fail "fixture windows did not appear"
|
||||||
|
xdotool windowactivate --sync "$sentinel_window"
|
||||||
|
echo "fixture ready: target=$target_window sentinel=$sentinel_window"
|
||||||
|
wait "$target_pid"
|
||||||
|
}
|
||||||
|
|
||||||
run_nodes() {
|
run_nodes() {
|
||||||
[[ $# -eq 1 ]] || { usage; exit 2; }
|
[[ $# -eq 1 ]] || { usage; exit 2; }
|
||||||
load_rig "$1"
|
load_rig "$1"
|
||||||
@@ -215,12 +406,26 @@ run_nodes() {
|
|||||||
node "$repo_root/scripts/run-node.mjs" nodes list --json
|
node "$repo_root/scripts/run-node.mjs" nodes list --json
|
||||||
}
|
}
|
||||||
|
|
||||||
|
run_approve() {
|
||||||
|
[[ $# -eq 2 ]] || { usage; exit 2; }
|
||||||
|
load_rig "$1"
|
||||||
|
exec env \
|
||||||
|
OPENCLAW_CONFIG_PATH="$OPENCLAW_CU_RIG_GATEWAY_CONFIG" \
|
||||||
|
OPENCLAW_STATE_DIR="$OPENCLAW_CU_RIG_AGENT_STATE" \
|
||||||
|
node "$repo_root/scripts/run-node.mjs" nodes approve "$2" --json
|
||||||
|
}
|
||||||
|
|
||||||
run_proof() {
|
run_proof() {
|
||||||
[[ $# -ge 4 && $# -le 5 ]] || { usage; exit 2; }
|
[[ $# -ge 4 && $# -le 5 ]] || { usage; exit 2; }
|
||||||
local scratch="$1"
|
local scratch="$1"
|
||||||
load_rig "$scratch"
|
load_rig "$scratch"
|
||||||
local provider="$2"
|
local provider="$2"
|
||||||
validate_provider "$provider"
|
validate_provider "$provider"
|
||||||
|
if [[ "$OPENCLAW_CU_RIG_PLATFORM" == "linux" ]]; then
|
||||||
|
[[ "$provider" == "cua" ]] || fail "Linux rig supports only the CUA provider"
|
||||||
|
require_linux_x11
|
||||||
|
[[ "$DISPLAY" == "$OPENCLAW_CU_RIG_DISPLAY" ]] || fail "DISPLAY does not match rig state"
|
||||||
|
fi
|
||||||
local args=(
|
local args=(
|
||||||
--provider "$provider"
|
--provider "$provider"
|
||||||
--window-title "$3"
|
--window-title "$3"
|
||||||
@@ -241,9 +446,13 @@ command_name="${1:-}"
|
|||||||
shift
|
shift
|
||||||
case "$command_name" in
|
case "$command_name" in
|
||||||
prepare) prepare "$@" ;;
|
prepare) prepare "$@" ;;
|
||||||
|
prepare-linux) prepare_linux "$@" ;;
|
||||||
gateway) run_gateway "$@" ;;
|
gateway) run_gateway "$@" ;;
|
||||||
app) run_app "$@" ;;
|
app) run_app "$@" ;;
|
||||||
|
node) run_node "$@" ;;
|
||||||
|
fixture) run_fixture "$@" ;;
|
||||||
nodes) run_nodes "$@" ;;
|
nodes) run_nodes "$@" ;;
|
||||||
|
approve) run_approve "$@" ;;
|
||||||
proof) run_proof "$@" ;;
|
proof) run_proof "$@" ;;
|
||||||
-h | --help | help) usage ;;
|
-h | --help | help) usage ;;
|
||||||
*) usage; exit 2 ;;
|
*) usage; exit 2 ;;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ComputerUseCapabilityDescriptor } from "../plugins/computer-use-contract.js";
|
||||||
|
|
||||||
|
/** Publish Computer Use metadata only after the command pair is effective for this session. */
|
||||||
|
export function resolveEffectiveComputerUseDescriptor(params: {
|
||||||
|
commands: readonly string[];
|
||||||
|
declared?: ComputerUseCapabilityDescriptor;
|
||||||
|
}): ComputerUseCapabilityDescriptor | undefined {
|
||||||
|
return params.commands.includes("computer.act") && params.commands.includes("screen.snapshot")
|
||||||
|
? params.declared
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
@@ -10,10 +10,8 @@ import type { ConnectParams } from "../../packages/gateway-protocol/src/index.js
|
|||||||
import type { NodePairingRequestInput, PairedDeviceNode } from "../infra/device-pairing-node.js";
|
import type { NodePairingRequestInput, PairedDeviceNode } from "../infra/device-pairing-node.js";
|
||||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
|
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
import {
|
import { resolveEffectiveComputerUseDescriptor } from "./node-computer-use-descriptor.js";
|
||||||
reconcileNodePairingOnConnect,
|
import { reconcileNodePairingOnConnect } from "./node-connect-reconcile.js";
|
||||||
resolveEffectiveComputerUseDescriptor,
|
|
||||||
} from "./node-connect-reconcile.js";
|
|
||||||
|
|
||||||
function makeNodeConnectParams(overrides?: Partial<ConnectParams>): ConnectParams {
|
function makeNodeConnectParams(overrides?: Partial<ConnectParams>): ConnectParams {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -33,16 +33,6 @@ type NodeConnectPairingReconcileResult = {
|
|||||||
shouldClearPendingPairings?: boolean;
|
shouldClearPendingPairings?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Publish Computer Use metadata only after the command pair is effective for this session. */
|
|
||||||
export function resolveEffectiveComputerUseDescriptor(params: {
|
|
||||||
commands: readonly string[];
|
|
||||||
declared?: ComputerUseCapabilityDescriptor;
|
|
||||||
}): ComputerUseCapabilityDescriptor | undefined {
|
|
||||||
return params.commands.includes("computer.act") && params.commands.includes("screen.snapshot")
|
|
||||||
? params.declared
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveApprovedReconnectCommands(params: {
|
function resolveApprovedReconnectCommands(params: {
|
||||||
pairedCommands: readonly string[] | undefined;
|
pairedCommands: readonly string[] | undefined;
|
||||||
allowlist: Set<string>;
|
allowlist: Set<string>;
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ function makeClient(
|
|||||||
caps?: string[];
|
caps?: string[];
|
||||||
commands?: string[];
|
commands?: string[];
|
||||||
computerUse?: unknown;
|
computerUse?: unknown;
|
||||||
|
declaredComputerUse?: unknown;
|
||||||
workerRuns?: WorkerAdmissionHandshake;
|
workerRuns?: WorkerAdmissionHandshake;
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
declaredCaps?: string[];
|
declaredCaps?: string[];
|
||||||
@@ -143,6 +144,7 @@ function makeClient(
|
|||||||
caps: opts.caps ?? [],
|
caps: opts.caps ?? [],
|
||||||
commands: opts.commands ?? [],
|
commands: opts.commands ?? [],
|
||||||
computerUse: opts.computerUse,
|
computerUse: opts.computerUse,
|
||||||
|
declaredComputerUse: opts.declaredComputerUse,
|
||||||
workerRuns: opts.workerRuns,
|
workerRuns: opts.workerRuns,
|
||||||
permissions: opts.permissions,
|
permissions: opts.permissions,
|
||||||
declaredCaps: opts.declaredCaps,
|
declaredCaps: opts.declaredCaps,
|
||||||
@@ -295,18 +297,22 @@ function authorizeSystemRun(registry: NodeRegistry, overrides: Partial<SystemRun
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computerUseDescriptor() {
|
||||||
|
return {
|
||||||
|
contractVersion: 2 as const,
|
||||||
|
provider: { id: "fixture", label: "Fixture", generation: "generation-1" },
|
||||||
|
actions: ["screenshot", "left_click"] as const,
|
||||||
|
targets: ["screen"] as const,
|
||||||
|
deliveryModes: ["foreground"] as const,
|
||||||
|
observations: ["image"] as const,
|
||||||
|
features: { recording: false, agentCursor: false, multiDisplay: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("gateway/node-registry", () => {
|
describe("gateway/node-registry", () => {
|
||||||
it("retains the validated Computer Use declaration on the live session", () => {
|
it("retains the validated Computer Use declaration on the live session", () => {
|
||||||
const registry = createNodeRegistry();
|
const registry = createNodeRegistry();
|
||||||
const computerUse = {
|
const computerUse = computerUseDescriptor();
|
||||||
contractVersion: 2,
|
|
||||||
provider: { id: "fixture", label: "Fixture", generation: "generation-1" },
|
|
||||||
actions: ["screenshot", "left_click"],
|
|
||||||
targets: ["screen"],
|
|
||||||
deliveryModes: ["foreground"],
|
|
||||||
observations: ["image"],
|
|
||||||
features: { recording: false, agentCursor: false, multiDisplay: false },
|
|
||||||
};
|
|
||||||
const client = makeClient("conn-computer", "node-computer", [], {
|
const client = makeClient("conn-computer", "node-computer", [], {
|
||||||
commands: ["screen.snapshot", "computer.act"],
|
commands: ["screen.snapshot", "computer.act"],
|
||||||
computerUse,
|
computerUse,
|
||||||
@@ -2751,11 +2757,13 @@ describe("gateway/node-registry", () => {
|
|||||||
|
|
||||||
it("refreshes effective live surface within the declared surface", () => {
|
it("refreshes effective live surface within the declared surface", () => {
|
||||||
const registry = createTestNodeRegistry();
|
const registry = createTestNodeRegistry();
|
||||||
|
const computerUse = computerUseDescriptor();
|
||||||
const client = makeClient("conn-1", "node-1", [], {
|
const client = makeClient("conn-1", "node-1", [], {
|
||||||
caps: [],
|
caps: [],
|
||||||
commands: [],
|
commands: [],
|
||||||
declaredCaps: ["talk"],
|
declaredCaps: ["talk"],
|
||||||
declaredCommands: ["talk.ptt.start"],
|
declaredCommands: ["talk.ptt.start", "computer.act", "screen.snapshot"],
|
||||||
|
declaredComputerUse: computerUse,
|
||||||
declaredPermissions: { microphone: true, camera: false },
|
declaredPermissions: { microphone: true, camera: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2765,15 +2773,21 @@ describe("gateway/node-registry", () => {
|
|||||||
|
|
||||||
const updated = registry.updateSurface("node-1", {
|
const updated = registry.updateSurface("node-1", {
|
||||||
caps: ["talk", "screen"],
|
caps: ["talk", "screen"],
|
||||||
commands: ["talk.ptt.start", "system.run"],
|
commands: ["talk.ptt.start", "computer.act", "screen.snapshot", "system.run"],
|
||||||
permissions: { microphone: true, camera: true },
|
permissions: { microphone: true, camera: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(updated?.caps).toEqual(["talk"]);
|
expect(updated?.caps).toEqual(["talk"]);
|
||||||
expect(updated?.commands).toEqual(["talk.ptt.start"]);
|
expect(updated?.commands).toEqual(["talk.ptt.start", "computer.act", "screen.snapshot"]);
|
||||||
|
expect(updated?.computerUse).toEqual(computerUse);
|
||||||
expect(updated?.permissions).toEqual({ microphone: true, camera: false });
|
expect(updated?.permissions).toEqual({ microphone: true, camera: false });
|
||||||
expect(client.connect.caps).toEqual(["talk"]);
|
expect(client.connect.caps).toEqual(["talk"]);
|
||||||
expect((client.connect as { commands?: string[] }).commands).toEqual(["talk.ptt.start"]);
|
expect((client.connect as { commands?: string[] }).commands).toEqual([
|
||||||
|
"talk.ptt.start",
|
||||||
|
"computer.act",
|
||||||
|
"screen.snapshot",
|
||||||
|
]);
|
||||||
|
expect(client.connect.computerUse).toEqual(computerUse);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("advances the exact live session with its approved surface generation", () => {
|
it("advances the exact live session with its approved surface generation", () => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
parseComputerUseCapabilityDescriptor,
|
parseComputerUseCapabilityDescriptor,
|
||||||
type ComputerUseCapabilityDescriptor,
|
type ComputerUseCapabilityDescriptor,
|
||||||
} from "../plugins/computer-use-contract.js";
|
} from "../plugins/computer-use-contract.js";
|
||||||
|
import { resolveEffectiveComputerUseDescriptor } from "./node-computer-use-descriptor.js";
|
||||||
import {
|
import {
|
||||||
createRegisteredNodePluginToolDescriptorMap,
|
createRegisteredNodePluginToolDescriptorMap,
|
||||||
normalizeNodePluginToolDescriptors,
|
normalizeNodePluginToolDescriptors,
|
||||||
@@ -73,6 +74,7 @@ export type NodeSession = {
|
|||||||
declaredCommands: string[];
|
declaredCommands: string[];
|
||||||
sessionCommandsCeiling?: string[];
|
sessionCommandsCeiling?: string[];
|
||||||
commands: string[];
|
commands: string[];
|
||||||
|
declaredComputerUse?: ComputerUseCapabilityDescriptor;
|
||||||
computerUse?: ComputerUseCapabilityDescriptor;
|
computerUse?: ComputerUseCapabilityDescriptor;
|
||||||
/** Exact node-local build admitted for worker session hosting. */
|
/** Exact node-local build admitted for worker session hosting. */
|
||||||
workerRuns?: WorkerAdmissionHandshake;
|
workerRuns?: WorkerAdmissionHandshake;
|
||||||
@@ -467,6 +469,12 @@ export class NodeRegistry {
|
|||||||
connect.computerUse === undefined
|
connect.computerUse === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: parseComputerUseCapabilityDescriptor(connect.computerUse);
|
: parseComputerUseCapabilityDescriptor(connect.computerUse);
|
||||||
|
const declaredComputerUseValue = (connect as { declaredComputerUse?: unknown })
|
||||||
|
.declaredComputerUse;
|
||||||
|
const declaredComputerUse =
|
||||||
|
declaredComputerUseValue === undefined
|
||||||
|
? computerUse
|
||||||
|
: parseComputerUseCapabilityDescriptor(declaredComputerUseValue);
|
||||||
// Session ceilings preserve protocol compatibility across later pairing
|
// Session ceilings preserve protocol compatibility across later pairing
|
||||||
// approvals while declared* retains the durable approval surface.
|
// approvals while declared* retains the durable approval surface.
|
||||||
const sessionCapsCeiling = Array.isArray(
|
const sessionCapsCeiling = Array.isArray(
|
||||||
@@ -519,6 +527,7 @@ export class NodeRegistry {
|
|||||||
declaredCommands,
|
declaredCommands,
|
||||||
sessionCommandsCeiling,
|
sessionCommandsCeiling,
|
||||||
commands,
|
commands,
|
||||||
|
...(declaredComputerUse ? { declaredComputerUse } : {}),
|
||||||
...(computerUse ? { computerUse } : {}),
|
...(computerUse ? { computerUse } : {}),
|
||||||
...(workerRuns ? { workerRuns } : {}),
|
...(workerRuns ? { workerRuns } : {}),
|
||||||
declaredNodePluginTools,
|
declaredNodePluginTools,
|
||||||
@@ -1004,6 +1013,12 @@ export class NodeRegistry {
|
|||||||
const nextCommands = surface.commands.filter((command) => sessionCommandsCeiling.has(command));
|
const nextCommands = surface.commands.filter((command) => sessionCommandsCeiling.has(command));
|
||||||
node.commands = nextCommands;
|
node.commands = nextCommands;
|
||||||
(node.client.connect as { commands?: string[] }).commands = nextCommands;
|
(node.client.connect as { commands?: string[] }).commands = nextCommands;
|
||||||
|
const nextComputerUse = resolveEffectiveComputerUseDescriptor({
|
||||||
|
commands: nextCommands,
|
||||||
|
declared: node.declaredComputerUse,
|
||||||
|
});
|
||||||
|
node.computerUse = nextComputerUse;
|
||||||
|
node.client.connect.computerUse = nextComputerUse;
|
||||||
this.replaceEffectiveNodePluginTools(node);
|
this.replaceEffectiveNodePluginTools(node);
|
||||||
|
|
||||||
if ("caps" in surface) {
|
if ("caps" in surface) {
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ import {
|
|||||||
import { getPairedDevice } from "../../../infra/device-pairing.js";
|
import { getPairedDevice } from "../../../infra/device-pairing.js";
|
||||||
import { AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING } from "../../auth-rate-limit.js";
|
import { AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING } from "../../auth-rate-limit.js";
|
||||||
import { ADMIN_SCOPE, PAIRING_SCOPE, WRITE_SCOPE } from "../../method-scopes.js";
|
import { ADMIN_SCOPE, PAIRING_SCOPE, WRITE_SCOPE } from "../../method-scopes.js";
|
||||||
import {
|
import { resolveEffectiveComputerUseDescriptor } from "../../node-computer-use-descriptor.js";
|
||||||
reconcileNodePairingOnConnect,
|
import { reconcileNodePairingOnConnect } from "../../node-connect-reconcile.js";
|
||||||
resolveEffectiveComputerUseDescriptor,
|
|
||||||
} from "../../node-connect-reconcile.js";
|
|
||||||
import { filterLegacyNodeProtocolFeatures } from "../../node-legacy-protocol-filter.js";
|
import { filterLegacyNodeProtocolFeatures } from "../../node-legacy-protocol-filter.js";
|
||||||
import { withSerializedRateLimitAttempt } from "../../rate-limit-attempt-serialization.js";
|
import { withSerializedRateLimitAttempt } from "../../rate-limit-attempt-serialization.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -185,12 +183,14 @@ export async function prepareGatewayNodeConnect(
|
|||||||
const nodeConnectParams = connectParams as ConnectParams & {
|
const nodeConnectParams = connectParams as ConnectParams & {
|
||||||
declaredCaps?: string[];
|
declaredCaps?: string[];
|
||||||
declaredCommands?: string[];
|
declaredCommands?: string[];
|
||||||
|
declaredComputerUse?: unknown;
|
||||||
declaredPermissions?: Record<string, boolean>;
|
declaredPermissions?: Record<string, boolean>;
|
||||||
sessionCapsCeiling?: string[];
|
sessionCapsCeiling?: string[];
|
||||||
sessionCommandsCeiling?: string[];
|
sessionCommandsCeiling?: string[];
|
||||||
};
|
};
|
||||||
nodeConnectParams.declaredCaps = reconciliation.declaredCaps;
|
nodeConnectParams.declaredCaps = reconciliation.declaredCaps;
|
||||||
nodeConnectParams.declaredCommands = reconciliation.declaredCommands;
|
nodeConnectParams.declaredCommands = reconciliation.declaredCommands;
|
||||||
|
nodeConnectParams.declaredComputerUse = reconciliation.declaredComputerUse;
|
||||||
nodeConnectParams.declaredPermissions = reconciliation.declaredPermissions;
|
nodeConnectParams.declaredPermissions = reconciliation.declaredPermissions;
|
||||||
const pluginSurfaces = pluginNodeCapabilities.map((surface) => surface.surface);
|
const pluginSurfaces = pluginNodeCapabilities.map((surface) => surface.surface);
|
||||||
if (usesLegacyNodeProtocol) {
|
if (usesLegacyNodeProtocol) {
|
||||||
|
|||||||
@@ -69,10 +69,8 @@ import {
|
|||||||
} from "./http-common.js";
|
} from "./http-common.js";
|
||||||
import { ADMIN_SCOPE, PAIRING_SCOPE, WRITE_SCOPE } from "./method-scopes.js";
|
import { ADMIN_SCOPE, PAIRING_SCOPE, WRITE_SCOPE } from "./method-scopes.js";
|
||||||
import { isLoopbackAddress, resolveRequestClientIp } from "./net.js";
|
import { isLoopbackAddress, resolveRequestClientIp } from "./net.js";
|
||||||
import {
|
import { resolveEffectiveComputerUseDescriptor } from "./node-computer-use-descriptor.js";
|
||||||
reconcileNodePairingOnConnect,
|
import { reconcileNodePairingOnConnect } from "./node-connect-reconcile.js";
|
||||||
resolveEffectiveComputerUseDescriptor,
|
|
||||||
} from "./node-connect-reconcile.js";
|
|
||||||
import type { NodeReapprovalCoordinator } from "./node-reapproval-coordinator.js";
|
import type { NodeReapprovalCoordinator } from "./node-reapproval-coordinator.js";
|
||||||
import type {
|
import type {
|
||||||
NodeConnectivityResult,
|
NodeConnectivityResult,
|
||||||
@@ -872,10 +870,12 @@ export function createWatchNodeHttpRuntime(options: WatchNodeHttpRuntimeOptions)
|
|||||||
const registeredConnect = connect as ConnectParams & {
|
const registeredConnect = connect as ConnectParams & {
|
||||||
declaredCaps?: string[];
|
declaredCaps?: string[];
|
||||||
declaredCommands?: string[];
|
declaredCommands?: string[];
|
||||||
|
declaredComputerUse?: unknown;
|
||||||
declaredPermissions?: Record<string, boolean>;
|
declaredPermissions?: Record<string, boolean>;
|
||||||
};
|
};
|
||||||
registeredConnect.declaredCaps = reconciliation.declaredCaps;
|
registeredConnect.declaredCaps = reconciliation.declaredCaps;
|
||||||
registeredConnect.declaredCommands = reconciliation.declaredCommands;
|
registeredConnect.declaredCommands = reconciliation.declaredCommands;
|
||||||
|
registeredConnect.declaredComputerUse = reconciliation.declaredComputerUse;
|
||||||
registeredConnect.declaredPermissions = reconciliation.declaredPermissions;
|
registeredConnect.declaredPermissions = reconciliation.declaredPermissions;
|
||||||
registeredConnect.caps = reconciliation.effectiveCaps;
|
registeredConnect.caps = reconciliation.effectiveCaps;
|
||||||
registeredConnect.commands = reconciliation.effectiveCommands;
|
registeredConnect.commands = reconciliation.effectiveCommands;
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { createServer } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const rigScriptSource = path.resolve("scripts/dev/computer-use-macos-live-rig.sh");
|
||||||
|
const fixtureRoots: string[] = [];
|
||||||
|
|
||||||
|
function runGit(root: string, ...args: string[]): void {
|
||||||
|
const result = spawnSync("git", args, { cwd: root, encoding: "utf8" });
|
||||||
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeExecutable(filePath: string, source: string): void {
|
||||||
|
writeFileSync(filePath, source);
|
||||||
|
chmodSync(filePath, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRigRepository(): {
|
||||||
|
root: string;
|
||||||
|
script: string;
|
||||||
|
fakeBin: string;
|
||||||
|
app: string;
|
||||||
|
fixture: string;
|
||||||
|
proof: string;
|
||||||
|
} {
|
||||||
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-computer-use-rig-"));
|
||||||
|
fixtureRoots.push(root);
|
||||||
|
const scriptsDev = path.join(root, "scripts", "dev");
|
||||||
|
const fakeBin = path.join(root, "fake-bin");
|
||||||
|
const app = path.join(root, "OpenClaw.app");
|
||||||
|
const appExecutable = path.join(app, "Contents", "MacOS", "OpenClaw");
|
||||||
|
mkdirSync(scriptsDev, { recursive: true });
|
||||||
|
mkdirSync(fakeBin);
|
||||||
|
mkdirSync(path.dirname(appExecutable), { recursive: true });
|
||||||
|
|
||||||
|
const script = path.join(scriptsDev, "computer-use-macos-live-rig.sh");
|
||||||
|
const fixture = path.join(scriptsDev, "computer-use-linux-x11-fixture.py");
|
||||||
|
const proof = path.join(scriptsDev, "computer-use-macos-live-proof.ts");
|
||||||
|
copyFileSync(rigScriptSource, script);
|
||||||
|
chmodSync(script, 0o755);
|
||||||
|
writeFileSync(fixture, "# committed fixture\n");
|
||||||
|
writeFileSync(proof, "// committed proof\n");
|
||||||
|
writeExecutable(appExecutable, "#!/bin/sh\nexit 0\n");
|
||||||
|
writeExecutable(path.join(fakeBin, "codesign"), "#!/bin/sh\nexit 0\n");
|
||||||
|
writeExecutable(path.join(fakeBin, "uname"), "#!/bin/sh\necho Linux\n");
|
||||||
|
writeExecutable(path.join(fakeBin, "xdotool"), "#!/bin/sh\nexit 0\n");
|
||||||
|
writeExecutable(path.join(fakeBin, "xdpyinfo"), "#!/bin/sh\nexit 0\n");
|
||||||
|
|
||||||
|
runGit(root, "init", "-q");
|
||||||
|
runGit(root, "config", "user.name", "OpenClaw Test");
|
||||||
|
runGit(root, "config", "user.email", "openclaw-test@example.com");
|
||||||
|
runGit(root, "add", "scripts");
|
||||||
|
runGit(root, "commit", "-q", "-m", "fixture");
|
||||||
|
|
||||||
|
return { root, script, fakeBin, app, fixture, proof };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reservePort(): Promise<number> {
|
||||||
|
const server = createServer();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen({ host: "127.0.0.1", port: 0 }, resolve);
|
||||||
|
});
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === "object" && address ? address.port : 0;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRig(params: { root: string; script: string; fakeBin: string; args: string[] }) {
|
||||||
|
return spawnSync("bash", [params.script, ...params.args], {
|
||||||
|
cwd: params.root,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DISPLAY: ":99",
|
||||||
|
WAYLAND_DISPLAY: "",
|
||||||
|
XDG_SESSION_TYPE: "x11",
|
||||||
|
PATH: `${params.fakeBin}:${process.env.PATH ?? ""}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of fixtureRoots.splice(0)) {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computer-use live rig source integrity", () => {
|
||||||
|
it("refuses macOS preparation after the proof runner changes", async () => {
|
||||||
|
const fixture = createRigRepository();
|
||||||
|
writeFileSync(fixture.proof, "// locally modified proof\n");
|
||||||
|
const result = runRig({
|
||||||
|
...fixture,
|
||||||
|
args: [
|
||||||
|
"prepare",
|
||||||
|
"proof-test",
|
||||||
|
String(await reservePort()),
|
||||||
|
fixture.app,
|
||||||
|
path.join(fixture.root, "scratch-mac"),
|
||||||
|
"cua",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe(1);
|
||||||
|
expect(result.stderr).toContain("runtime sources are dirty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses Linux preparation after the X11 fixture changes", async () => {
|
||||||
|
const fixture = createRigRepository();
|
||||||
|
writeFileSync(fixture.fixture, "# locally modified fixture\n");
|
||||||
|
const result = runRig({
|
||||||
|
...fixture,
|
||||||
|
args: [
|
||||||
|
"prepare-linux",
|
||||||
|
"proof-test",
|
||||||
|
String(await reservePort()),
|
||||||
|
path.join(fixture.root, "scratch-linux"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe(1);
|
||||||
|
expect(result.stderr).toContain("runtime sources are dirty");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user