diff --git a/src/infra/exec-host.test.ts b/src/infra/exec-host.test.ts new file mode 100644 index 00000000000..08d3d8af3be --- /dev/null +++ b/src/infra/exec-host.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const requestJsonlSocketMock = vi.hoisted(() => vi.fn()); + +vi.mock("./jsonl-socket.js", () => ({ + requestJsonlSocket: (...args: unknown[]) => requestJsonlSocketMock(...args), +})); + +import { requestExecHostViaSocket } from "./exec-host.js"; + +describe("requestExecHostViaSocket", () => { + beforeEach(() => { + requestJsonlSocketMock.mockReset(); + }); + + it("returns null when socket credentials are missing", async () => { + await expect( + requestExecHostViaSocket({ + socketPath: "", + token: "secret", + request: { command: ["echo", "hi"] }, + }), + ).resolves.toBeNull(); + await expect( + requestExecHostViaSocket({ + socketPath: "/tmp/socket", + token: "", + request: { command: ["echo", "hi"] }, + }), + ).resolves.toBeNull(); + expect(requestJsonlSocketMock).not.toHaveBeenCalled(); + }); + + it("builds an exec payload and forwards the default timeout", async () => { + requestJsonlSocketMock.mockResolvedValueOnce({ ok: true, payload: { success: true } }); + + await expect( + requestExecHostViaSocket({ + socketPath: "/tmp/socket", + token: "secret", + request: { + command: ["echo", "hi"], + cwd: "/tmp", + }, + }), + ).resolves.toEqual({ ok: true, payload: { success: true } }); + + const call = requestJsonlSocketMock.mock.calls[0]?.[0] as + | { + socketPath: string; + payload: string; + timeoutMs: number; + accept: (msg: unknown) => unknown; + } + | undefined; + if (!call) { + throw new Error("expected requestJsonlSocket call"); + } + + expect(call.socketPath).toBe("/tmp/socket"); + expect(call.timeoutMs).toBe(20_000); + const payload = JSON.parse(call.payload) as { + type: string; + id: string; + nonce: string; + ts: number; + hmac: string; + requestJson: string; + }; + expect(payload.type).toBe("exec"); + expect(payload.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + expect(payload.nonce).toMatch(/^[0-9a-f]{32}$/); + expect(typeof payload.ts).toBe("number"); + expect(payload.hmac).toMatch(/^[0-9a-f]{64}$/); + expect(JSON.parse(payload.requestJson)).toEqual({ + command: ["echo", "hi"], + cwd: "/tmp", + }); + }); + + it("accepts only exec response messages and maps malformed matches to null", async () => { + requestJsonlSocketMock.mockImplementationOnce(async ({ accept }) => { + expect(accept({ type: "ignore" })).toBeUndefined(); + expect(accept({ type: "exec-res", ok: true, payload: { success: true } })).toEqual({ + ok: true, + payload: { success: true }, + }); + expect(accept({ type: "exec-res", ok: false, error: { code: "DENIED" } })).toEqual({ + ok: false, + error: { code: "DENIED" }, + }); + expect(accept({ type: "exec-res", ok: true })).toBeNull(); + return null; + }); + + await expect( + requestExecHostViaSocket({ + socketPath: "/tmp/socket", + token: "secret", + timeoutMs: 123, + request: { command: ["echo", "hi"] }, + }), + ).resolves.toBeNull(); + + expect( + (requestJsonlSocketMock.mock.calls[0]?.[0] as { timeoutMs?: number } | undefined)?.timeoutMs, + ).toBe(123); + }); +}); diff --git a/src/infra/infra-parsing.test.ts b/src/infra/infra-parsing.test.ts deleted file mode 100644 index b87dca7bfa2..00000000000 --- a/src/infra/infra-parsing.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseSshTarget } from "./ssh-tunnel.js"; - -describe("infra parsing", () => { - describe("parseSshTarget", () => { - it("parses user@host:port targets", () => { - expect(parseSshTarget("me@example.com:2222")).toEqual({ - user: "me", - host: "example.com", - port: 2222, - }); - }); - - it("parses host-only targets with default port", () => { - expect(parseSshTarget("example.com")).toEqual({ - user: undefined, - host: "example.com", - port: 22, - }); - }); - - it("rejects hostnames that start with '-'", () => { - expect(parseSshTarget("-V")).toBeNull(); - expect(parseSshTarget("me@-badhost")).toBeNull(); - expect(parseSshTarget("-oProxyCommand=echo")).toBeNull(); - }); - }); -}); diff --git a/src/infra/jsonl-socket.test.ts b/src/infra/jsonl-socket.test.ts new file mode 100644 index 00000000000..af8bf0fdaed --- /dev/null +++ b/src/infra/jsonl-socket.test.ts @@ -0,0 +1,69 @@ +import net from "node:net"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempDir } from "../test-helpers/temp-dir.js"; +import { requestJsonlSocket } from "./jsonl-socket.js"; + +describe.runIf(process.platform !== "win32")("requestJsonlSocket", () => { + it("ignores malformed and non-accepted lines until one is accepted", async () => { + await withTempDir({ prefix: "openclaw-jsonl-socket-" }, async (dir) => { + const socketPath = path.join(dir, "socket.sock"); + const server = net.createServer((socket) => { + socket.on("data", () => { + socket.write("{bad json}\n"); + socket.write('{"type":"ignore"}\n'); + socket.write('{"type":"done","value":42}\n'); + }); + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + + try { + await expect( + requestJsonlSocket({ + socketPath, + payload: '{"hello":"world"}', + timeoutMs: 500, + accept: (msg) => { + const value = msg as { type?: string; value?: number }; + return value.type === "done" ? (value.value ?? null) : undefined; + }, + }), + ).resolves.toBe(42); + } finally { + server.close(); + } + }); + }); + + it("returns null on timeout and on socket errors", async () => { + await withTempDir({ prefix: "openclaw-jsonl-socket-" }, async (dir) => { + const socketPath = path.join(dir, "socket.sock"); + const server = net.createServer(() => { + // Intentionally never reply. + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + + try { + await expect( + requestJsonlSocket({ + socketPath, + payload: "{}", + timeoutMs: 50, + accept: () => undefined, + }), + ).resolves.toBeNull(); + } finally { + server.close(); + } + + await expect( + requestJsonlSocket({ + socketPath, + payload: "{}", + timeoutMs: 50, + accept: () => undefined, + }), + ).resolves.toBeNull(); + }); + }); +}); diff --git a/src/infra/ssh-tunnel.test.ts b/src/infra/ssh-tunnel.test.ts new file mode 100644 index 00000000000..da450d1c029 --- /dev/null +++ b/src/infra/ssh-tunnel.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { parseSshTarget } from "./ssh-tunnel.js"; + +describe("parseSshTarget", () => { + it("parses user@host:port targets", () => { + expect(parseSshTarget("me@example.com:2222")).toEqual({ + user: "me", + host: "example.com", + port: 2222, + }); + }); + + it("strips an ssh prefix and keeps the default port when missing", () => { + expect(parseSshTarget(" ssh alice@example.com ")).toEqual({ + user: "alice", + host: "example.com", + port: 22, + }); + }); + + it("rejects invalid hosts and ports", () => { + expect(parseSshTarget("")).toBeNull(); + expect(parseSshTarget("me@example.com:0")).toBeNull(); + expect(parseSshTarget("me@example.com:not-a-port")).toBeNull(); + expect(parseSshTarget("-V")).toBeNull(); + expect(parseSshTarget("me@-badhost")).toBeNull(); + expect(parseSshTarget("-oProxyCommand=echo")).toBeNull(); + }); +});