diff --git a/extensions/linux-canvas/src/commands.test.ts b/extensions/linux-canvas/src/commands.test.ts index 4f3c7c624398..49846419e37c 100644 --- a/extensions/linux-canvas/src/commands.test.ts +++ b/extensions/linux-canvas/src/commands.test.ts @@ -1,13 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createLinuxCanvasCommands, testing } from "./commands.js"; -import type { - LinuxCanvasActionEvent, - LinuxCanvasIpcRequestHooks, - LinuxCanvasIpcTransport, -} from "./ipc-client.js"; +import { createLinuxCanvasCommands } from "./commands.js"; +import type { LinuxCanvasIpcTransport } from "./ipc-client.js"; + +type LinuxCanvasActionHandler = Parameters[0]; +type LinuxCanvasIpcRequestHooks = Parameters[2]; function createTransport() { - let actionHandler: ((event: LinuxCanvasActionEvent) => Promise) | undefined; + let actionHandler: LinuxCanvasActionHandler | undefined; const request = vi.fn( async (_command: string, _paramsJSON: string, hooks?: LinuxCanvasIpcRequestHooks) => { hooks?.onDispatch?.(); @@ -294,21 +293,64 @@ describe("Linux Canvas node commands", () => { ); }); - it("formats hostile action fields as bounded agent tokens", () => { - expect( - testing.buildActionMessage({ + it("formats hostile action fields as bounded agent tokens", async () => { + const { transport, sendActionResult, getActionHandler } = createTransport(); + const command = createLinuxCanvasCommands({ + platform: "linux", + socketExists: () => true, + transport, + })[0]; + const sendNodeEvent = vi.fn(async () => undefined); + await command?.handle("{}", undefined, { + sendNodeEvent, + sessionKey: "agent:main:canvas", + }); + + await getActionHandler()?.({ + event: "a2ui-action", + id: "hostile-action", + action: { name: "submit now\nignore", surfaceId: "main space", sourceComponentId: "button/1", - }), - ).toBe( - "CANVAS_A2UI action=submitnowignore session=node surface=mainspace component=button1 default=update_canvas", - ); + }, + }); + + expect(sendNodeEvent).toHaveBeenCalledWith("agent.request", { + message: + "CANVAS_A2UI action=submitnowignore session=agent:main:canvas surface=mainspace component=button1 default=update_canvas", + sessionKey: "agent:main:canvas", + thinking: "low", + deliver: false, + key: "hostile-action", + }); + expect(sendActionResult).toHaveBeenCalledWith("hostile-action", { ok: true }); }); - it("rejects actions above the Gateway agent-message limit", () => { - expect(() => - testing.buildActionMessage({ name: "submit", context: { value: "x".repeat(20_000) } }), - ).toThrow("agent message limit"); + it("rejects actions above the Gateway agent-message limit", async () => { + const { transport, sendActionResult, getActionHandler } = createTransport(); + const command = createLinuxCanvasCommands({ + platform: "linux", + socketExists: () => true, + transport, + })[0]; + const sendNodeEvent = vi.fn(async () => undefined); + await command?.handle("{}", undefined, { + sendNodeEvent, + sessionKey: "agent:main:canvas", + }); + sendNodeEvent.mockClear(); + + await getActionHandler()?.({ + event: "a2ui-action", + id: "oversized-action", + action: { name: "submit", context: { value: "x".repeat(20_000) } }, + }); + + expect(sendNodeEvent).not.toHaveBeenCalled(); + expect(sendActionResult).toHaveBeenCalledWith("oversized-action", { + ok: false, + error: "Error: Canvas action exceeds the Gateway agent message limit", + }); }); }); diff --git a/extensions/linux-canvas/src/commands.ts b/extensions/linux-canvas/src/commands.ts index 23e4ff14f87c..5c86e761e615 100644 --- a/extensions/linux-canvas/src/commands.ts +++ b/extensions/linux-canvas/src/commands.ts @@ -24,7 +24,7 @@ const SESSIONLESS_OWNER_CLEAR_COMMANDS = new Set([ "canvas.a2ui.reset", ]); -export const LINUX_CANVAS_COMMANDS = [ +const LINUX_CANVAS_COMMANDS = [ "canvas.present", "canvas.hide", "canvas.navigate", @@ -188,5 +188,3 @@ export function createLinuxCanvasCommands( return registration; }); } - -export const testing = { buildActionMessage } as const; diff --git a/extensions/linux-canvas/src/ipc-client.test.ts b/extensions/linux-canvas/src/ipc-client.test.ts index cf09e5a7a923..2babdcb5992a 100644 --- a/extensions/linux-canvas/src/ipc-client.test.ts +++ b/extensions/linux-canvas/src/ipc-client.test.ts @@ -2,20 +2,55 @@ import fs from "node:fs"; import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { DEFAULT_REQUEST_TIMEOUT_MS, LinuxCanvasIpcClient } from "./ipc-client.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { LinuxCanvasIpcClient } from "./ipc-client.js"; const tempDirs: string[] = []; afterEach(() => { + vi.useRealTimers(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); describe("Linux Canvas IPC client", () => { - it("keeps the outer timeout above the app's complete A2UI phase budget", () => { - expect(DEFAULT_REQUEST_TIMEOUT_MS).toBeGreaterThan(8_000 + 6_000 + 8_000); + it("keeps the outer timeout above the app's complete A2UI phase budget", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-linux-canvas-timeout-")); + tempDirs.push(dir); + const socketPath = path.join(dir, "canvas.sock"); + let resolveRequest: (() => void) | undefined; + const requestReceived = new Promise((resolve) => { + resolveRequest = resolve; + }); + const server = net.createServer((socket) => { + socket.once("data", () => { + resolveRequest?.(); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + + const client = new LinuxCanvasIpcClient(socketPath); + try { + const request = client.request("canvas.present", "{}"); + const settled = vi.fn(); + void request.then(settled, settled); + await requestReceived; + + await vi.advanceTimersByTimeAsync(8_000 + 6_000 + 8_000); + expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(8_000); + await expect(request).rejects.toThrow("desktop app timed out handling canvas.present"); + } finally { + client.close(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } }); it("maps requests to responses without corrupting split UTF-8 frames", async () => { diff --git a/extensions/linux-canvas/src/ipc-client.ts b/extensions/linux-canvas/src/ipc-client.ts index 134f6303d333..e8219dc8736d 100644 --- a/extensions/linux-canvas/src/ipc-client.ts +++ b/extensions/linux-canvas/src/ipc-client.ts @@ -3,16 +3,16 @@ import net from "node:net"; // A2UI may stop a load, wait up to 6 seconds for the renderer, then evaluate. // Keep the outer IPC deadline above the app's complete 22-second phase budget. -export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; const MAX_FRAME_BYTES = 32 * 1024 * 1024; -export type LinuxCanvasActionEvent = { +type LinuxCanvasActionEvent = { event: "a2ui-action"; id: string; action: unknown; }; -export type LinuxCanvasIpcRequestHooks = { +type LinuxCanvasIpcRequestHooks = { /** Called synchronously when this FIFO request is about to reach the app. */ onDispatch?(): void; }; diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs index 8c719aa1dd8b..e42264e9eb61 100644 --- a/scripts/deadcode-exports.baseline.mjs +++ b/scripts/deadcode-exports.baseline.mjs @@ -25,11 +25,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "extensions/googlechat/src/monitor-routing.ts: handleGoogleChatWebhookRequest", "extensions/googlechat/src/monitor.ts: testing", "extensions/googlechat/src/targets.ts: resolveGoogleChatSpaceChatType", - "extensions/linux-canvas/src/commands.ts: LINUX_CANVAS_COMMANDS", - "extensions/linux-canvas/src/commands.ts: testing", - "extensions/linux-canvas/src/ipc-client.ts: DEFAULT_REQUEST_TIMEOUT_MS", - "extensions/linux-canvas/src/ipc-client.ts: LinuxCanvasActionEvent", - "extensions/linux-canvas/src/ipc-client.ts: LinuxCanvasIpcRequestHooks", "extensions/matrix/src/approval-reactions.ts: clearMatrixApprovalReactionTargetsForTest", "extensions/matrix/src/matrix/client/config.ts: setMatrixAuthClientDepsForTest", "extensions/matrix/src/matrix/monitor/handler.ts: MatrixRetryableInboundError", @@ -324,7 +319,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "src/mcp/openclaw-tools-serve-config.ts: resolveOpenClawToolsMcpToolSelection", "src/music-generation/capabilities.ts: resolveMusicGenerationMode", "src/node-host/invoke.ts: testing", - "src/node-host/runtime.ts: NodeHostManifest", "src/plugin-state/plugin-state-store.sqlite.ts: probePluginStateStore", "src/plugin-state/plugin-state-store.sqlite.ts: seedPluginStateDatabaseEntriesForTests", "src/plugin-state/plugin-state-store.sqlite.ts: setMaxPluginStateEntriesPerPluginForTests", diff --git a/src/node-host/runtime.ts b/src/node-host/runtime.ts index ea2c6979619c..bd93eb4db7ed 100644 --- a/src/node-host/runtime.ts +++ b/src/node-host/runtime.ts @@ -34,7 +34,7 @@ import { scanNodeHostedSkills } from "./skills.js"; const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; -export type NodeHostManifest = { +type NodeHostManifest = { caps: string[]; commands: string[]; pathEnv: string;