import { beforeEach, describe, expect, it, vi } from "vitest"; const gatewayMocks = vi.hoisted(() => ({ callGatewayTool: vi.fn(), readGatewayCallOptions: vi.fn(() => ({})), })); const nodeUtilsMocks = vi.hoisted(() => ({ resolveNodeId: vi.fn(async () => "node-1"), listNodes: vi.fn(async () => [] as Array<{ nodeId: string; commands?: string[] }>), resolveNodeIdFromList: vi.fn(() => "node-1"), })); const screenMocks = vi.hoisted(() => ({ parseScreenRecordPayload: vi.fn(() => ({ base64: "ZmFrZQ==", format: "mp4", durationMs: 300_000, fps: 10, screenIndex: 0, hasAudio: true, })), screenRecordTempPath: vi.fn(() => "/tmp/screen-record.mp4"), writeScreenRecordToFile: vi.fn(async () => ({ path: "/tmp/screen-record.mp4" })), })); vi.mock("./gateway.js", () => ({ callGatewayTool: gatewayMocks.callGatewayTool, readGatewayCallOptions: gatewayMocks.readGatewayCallOptions, })); vi.mock("./nodes-utils.js", () => ({ resolveNodeId: nodeUtilsMocks.resolveNodeId, listNodes: nodeUtilsMocks.listNodes, resolveNodeIdFromList: nodeUtilsMocks.resolveNodeIdFromList, })); vi.mock("../../cli/nodes-screen.js", () => ({ parseScreenRecordPayload: screenMocks.parseScreenRecordPayload, screenRecordTempPath: screenMocks.screenRecordTempPath, writeScreenRecordToFile: screenMocks.writeScreenRecordToFile, })); import { createNodesTool } from "./nodes-tool.js"; describe("createNodesTool screen_record duration guardrails", () => { beforeEach(() => { gatewayMocks.callGatewayTool.mockReset(); gatewayMocks.readGatewayCallOptions.mockReset(); gatewayMocks.readGatewayCallOptions.mockReturnValue({}); nodeUtilsMocks.resolveNodeId.mockClear(); screenMocks.parseScreenRecordPayload.mockClear(); screenMocks.writeScreenRecordToFile.mockClear(); }); it("marks nodes as owner-only", () => { const tool = createNodesTool(); expect(tool.ownerOnly).toBe(true); }); it("caps durationMs schema at 300000", () => { const tool = createNodesTool(); const schema = tool.parameters as { properties?: { durationMs?: { maximum?: number; }; }; }; expect(schema.properties?.durationMs?.maximum).toBe(300_000); }); it("clamps screen_record durationMs argument to 300000 before gateway invoke", async () => { gatewayMocks.callGatewayTool.mockResolvedValue({ payload: { ok: true } }); const tool = createNodesTool(); await tool.execute("call-1", { action: "screen_record", node: "macbook", durationMs: 900_000, }); expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( "node.invoke", {}, expect.objectContaining({ params: expect.objectContaining({ durationMs: 300_000, }), }), ); }); it("omits rawCommand when preparing wrapped argv execution", async () => { nodeUtilsMocks.listNodes.mockResolvedValue([ { nodeId: "node-1", commands: ["system.run"], }, ]); gatewayMocks.callGatewayTool.mockImplementation(async (_method, _opts, payload) => { if (payload?.command === "system.run.prepare") { return { payload: { plan: { argv: ["bash", "-lc", "echo hi"], cwd: null, commandText: 'bash -lc "echo hi"', commandPreview: "echo hi", agentId: null, sessionKey: null, }, }, }; } if (payload?.command === "system.run") { return { payload: { ok: true } }; } throw new Error(`unexpected command: ${String(payload?.command)}`); }); const tool = createNodesTool(); await tool.execute("call-1", { action: "run", node: "macbook", command: ["bash", "-lc", "echo hi"], }); const prepareCall = gatewayMocks.callGatewayTool.mock.calls.find( (call) => call[2]?.command === "system.run.prepare", )?.[2]; expect(prepareCall).toBeTruthy(); expect(prepareCall?.params).toMatchObject({ command: ["bash", "-lc", "echo hi"], agentId: "main", }); expect(prepareCall?.params).not.toHaveProperty("rawCommand"); }); });