From ed9646516dabc86fb8d0227e6971016d18ca28c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Mar 2026 04:22:51 +0000 Subject: [PATCH] test: collapse utility plugin suites --- extensions/diffs/index.test.ts | 140 -------- extensions/diffs/src/browser.test.ts | 139 ++++++++ extensions/diffs/src/config.test.ts | 233 +++++++++++++ extensions/diffs/src/http.test.ts | 206 ------------ extensions/diffs/src/render.test.ts | 106 ------ extensions/diffs/src/store.test.ts | 203 ++++++++++++ extensions/diffs/src/url.test.ts | 55 ---- extensions/diffs/src/viewer-assets.test.ts | 22 -- extensions/diffs/src/viewer-payload.test.ts | 55 ---- extensions/openshell/src/backend.test.ts | 118 ------- extensions/openshell/src/cli.test.ts | 61 ---- extensions/openshell/src/config.test.ts | 41 --- extensions/openshell/src/fs-bridge.test.ts | 88 ----- ...-bridge.test.ts => openshell-core.test.ts} | 305 +++++++++++++++++- 14 files changed, 875 insertions(+), 897 deletions(-) delete mode 100644 extensions/diffs/index.test.ts delete mode 100644 extensions/diffs/src/http.test.ts delete mode 100644 extensions/diffs/src/render.test.ts delete mode 100644 extensions/diffs/src/url.test.ts delete mode 100644 extensions/diffs/src/viewer-assets.test.ts delete mode 100644 extensions/diffs/src/viewer-payload.test.ts delete mode 100644 extensions/openshell/src/backend.test.ts delete mode 100644 extensions/openshell/src/cli.test.ts delete mode 100644 extensions/openshell/src/config.test.ts delete mode 100644 extensions/openshell/src/fs-bridge.test.ts rename extensions/openshell/src/{remote-fs-bridge.test.ts => openshell-core.test.ts} (50%) diff --git a/extensions/diffs/index.test.ts b/extensions/diffs/index.test.ts deleted file mode 100644 index 4a73905f0c0..00000000000 --- a/extensions/diffs/index.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { IncomingMessage } from "node:http"; -import { describe, expect, it, vi } from "vitest"; -import { createMockServerResponse } from "../../test/helpers/extensions/mock-http-response.js"; -import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; -import type { OpenClawPluginApi, OpenClawPluginToolContext } from "./api.js"; -import plugin from "./index.js"; - -describe("diffs plugin registration", () => { - it("registers the tool, http route, and system-prompt guidance hook", async () => { - const registerTool = vi.fn(); - const registerHttpRoute = vi.fn(); - const on = vi.fn(); - - plugin.register?.( - createTestPluginApi({ - id: "diffs", - name: "Diffs", - description: "Diffs", - source: "test", - config: {}, - runtime: {} as never, - registerTool, - registerHttpRoute, - on, - }), - ); - - expect(registerTool).toHaveBeenCalledTimes(1); - expect(registerHttpRoute).toHaveBeenCalledTimes(1); - expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({ - path: "/plugins/diffs", - auth: "plugin", - match: "prefix", - }); - expect(on).toHaveBeenCalledTimes(1); - expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build"); - const beforePromptBuild = on.mock.calls[0]?.[1]; - const result = await beforePromptBuild?.({}, {}); - expect(result).toMatchObject({ - prependSystemContext: expect.stringContaining("prefer the `diffs` tool"), - }); - expect(result?.prependContext).toBeUndefined(); - }); - - it("applies plugin-config defaults through registered tool and viewer handler", async () => { - type RegisteredTool = { - execute?: (toolCallId: string, params: Record) => Promise; - }; - type RegisteredHttpRouteParams = Parameters[0]; - - let registeredToolFactory: - | ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined) - | undefined; - let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined; - - const api = createTestPluginApi({ - id: "diffs", - name: "Diffs", - description: "Diffs", - source: "test", - config: { - gateway: { - port: 18789, - bind: "loopback", - }, - }, - pluginConfig: { - defaults: { - mode: "view", - theme: "light", - background: false, - layout: "split", - showLineNumbers: false, - diffIndicators: "classic", - lineSpacing: 2, - }, - }, - runtime: {} as never, - registerTool(tool: Parameters[0]) { - registeredToolFactory = typeof tool === "function" ? tool : () => tool; - }, - registerHttpRoute(params: RegisteredHttpRouteParams) { - registeredHttpRouteHandler = params.handler; - }, - }); - - plugin.register?.(api as unknown as OpenClawPluginApi); - - const registeredTool = registeredToolFactory?.({ - agentId: "main", - sessionId: "session-123", - messageChannel: "discord", - agentAccountId: "default", - }) as RegisteredTool | undefined; - const result = await registeredTool?.execute?.("tool-1", { - before: "one\n", - after: "two\n", - }); - const viewerPath = String( - (result as { details?: Record } | undefined)?.details?.viewerPath, - ); - const res = createMockServerResponse(); - const handled = await registeredHttpRouteHandler?.( - localReq({ - method: "GET", - url: viewerPath, - }), - res, - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(String(res.body)).toContain('body data-theme="light"'); - expect(String(res.body)).toContain('"backgroundEnabled":false'); - expect(String(res.body)).toContain('"diffStyle":"split"'); - expect(String(res.body)).toContain('"disableLineNumbers":true'); - expect(String(res.body)).toContain('"diffIndicators":"classic"'); - expect(String(res.body)).toContain("--diffs-line-height: 30px;"); - expect((result as { details?: Record } | undefined)?.details?.context).toEqual( - { - agentId: "main", - sessionId: "session-123", - messageChannel: "discord", - agentAccountId: "default", - }, - ); - }); -}); - -function localReq(input: { - method: string; - url: string; - headers?: IncomingMessage["headers"]; -}): IncomingMessage { - return { - ...input, - headers: input.headers ?? {}, - socket: { remoteAddress: "127.0.0.1" }, - } as unknown as IncomingMessage; -} diff --git a/extensions/diffs/src/browser.test.ts b/extensions/diffs/src/browser.test.ts index 5374e10076a..cec8f07161f 100644 --- a/extensions/diffs/src/browser.test.ts +++ b/extensions/diffs/src/browser.test.ts @@ -1,7 +1,12 @@ import fs from "node:fs/promises"; +import type { IncomingMessage } from "node:http"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js"; +import { createTestPluginApi } from "../../../test/helpers/extensions/plugin-api.js"; import type { OpenClawConfig } from "../api.js"; +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js"; +import plugin from "../index.js"; import { createTempDiffRoot } from "./test-helpers.js"; const { launchMock } = vi.hoisted(() => ({ @@ -187,6 +192,128 @@ describe("PlaywrightDiffScreenshotter", () => { }); }); +describe("diffs plugin registration", () => { + it("registers the tool, http route, and system-prompt guidance hook", async () => { + const registerTool = vi.fn(); + const registerHttpRoute = vi.fn(); + const on = vi.fn(); + + plugin.register?.( + createTestPluginApi({ + id: "diffs", + name: "Diffs", + description: "Diffs", + source: "test", + config: {}, + runtime: {} as never, + registerTool, + registerHttpRoute, + on, + }), + ); + + expect(registerTool).toHaveBeenCalledTimes(1); + expect(registerHttpRoute).toHaveBeenCalledTimes(1); + expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({ + path: "/plugins/diffs", + auth: "plugin", + match: "prefix", + }); + expect(on).toHaveBeenCalledTimes(1); + expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build"); + const beforePromptBuild = on.mock.calls[0]?.[1]; + const result = await beforePromptBuild?.({}, {}); + expect(result).toMatchObject({ + prependSystemContext: expect.stringContaining("prefer the `diffs` tool"), + }); + expect(result?.prependContext).toBeUndefined(); + }); + + it("applies plugin-config defaults through registered tool and viewer handler", async () => { + type RegisteredTool = { + execute?: (toolCallId: string, params: Record) => Promise; + }; + type RegisteredHttpRouteParams = Parameters[0]; + + let registeredToolFactory: + | ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined) + | undefined; + let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined; + + const api = createTestPluginApi({ + id: "diffs", + name: "Diffs", + description: "Diffs", + source: "test", + config: { + gateway: { + port: 18789, + bind: "loopback", + }, + }, + pluginConfig: { + defaults: { + mode: "view", + theme: "light", + background: false, + layout: "split", + showLineNumbers: false, + diffIndicators: "classic", + lineSpacing: 2, + }, + }, + runtime: {} as never, + registerTool(tool: Parameters[0]) { + registeredToolFactory = typeof tool === "function" ? tool : () => tool; + }, + registerHttpRoute(params: RegisteredHttpRouteParams) { + registeredHttpRouteHandler = params.handler; + }, + }); + + plugin.register?.(api as unknown as OpenClawPluginApi); + + const registeredTool = registeredToolFactory?.({ + agentId: "main", + sessionId: "session-123", + messageChannel: "discord", + agentAccountId: "default", + }) as RegisteredTool | undefined; + const result = await registeredTool?.execute?.("tool-1", { + before: "one\n", + after: "two\n", + }); + const viewerPath = String( + (result as { details?: Record } | undefined)?.details?.viewerPath, + ); + const res = createMockServerResponse(); + const handled = await registeredHttpRouteHandler?.( + localReq({ + method: "GET", + url: viewerPath, + }), + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(String(res.body)).toContain('body data-theme="light"'); + expect(String(res.body)).toContain('"backgroundEnabled":false'); + expect(String(res.body)).toContain('"diffStyle":"split"'); + expect(String(res.body)).toContain('"disableLineNumbers":true'); + expect(String(res.body)).toContain('"diffIndicators":"classic"'); + expect(String(res.body)).toContain("--diffs-line-height: 30px;"); + expect((result as { details?: Record } | undefined)?.details?.context).toEqual( + { + agentId: "main", + sessionId: "session-123", + messageChannel: "discord", + agentAccountId: "default", + }, + ); + }); +}); + function createConfig(): OpenClawConfig { return { browser: { @@ -195,6 +322,18 @@ function createConfig(): OpenClawConfig { } as OpenClawConfig; } +function localReq(input: { + method: string; + url: string; + headers?: IncomingMessage["headers"]; +}): IncomingMessage { + return { + ...input, + headers: input.headers ?? {}, + socket: { remoteAddress: "127.0.0.1" }, + } as unknown as IncomingMessage; +} + async function createScreenshotterHarness(options?: { boundingBox?: { x: number; y: number; width: number; height: number }; }) { diff --git a/extensions/diffs/src/config.test.ts b/extensions/diffs/src/config.test.ts index 0c6055199d7..22ce49b69d4 100644 --- a/extensions/diffs/src/config.test.ts +++ b/extensions/diffs/src/config.test.ts @@ -8,6 +8,10 @@ import { resolveDiffsPluginDefaults, resolveDiffsPluginSecurity, } from "./config.js"; +import { renderDiffDocument } from "./render.js"; +import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js"; +import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js"; +import { parseViewerPayloadJson } from "./viewer-payload.js"; const FULL_DEFAULTS = { fontFamily: "JetBrains Mono", @@ -177,3 +181,232 @@ describe("diffs plugin schema surfaces", () => { expect(diffsPluginConfigSchema.jsonSchema).toEqual(manifest.configSchema); }); }); + +describe("diffs viewer URL helpers", () => { + it("defaults to loopback for lan/tailnet bind modes", () => { + expect( + buildViewerUrl({ + config: { gateway: { bind: "lan", port: 18789 } }, + viewerPath: "/plugins/diffs/view/id/token", + }), + ).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token"); + + expect( + buildViewerUrl({ + config: { gateway: { bind: "tailnet", port: 24444 } }, + viewerPath: "/plugins/diffs/view/id/token", + }), + ).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token"); + }); + + it("uses custom bind host when provided", () => { + expect( + buildViewerUrl({ + config: { + gateway: { + bind: "custom", + customBindHost: "gateway.example.com", + port: 443, + tls: { enabled: true }, + }, + }, + viewerPath: "/plugins/diffs/view/id/token", + }), + ).toBe("https://gateway.example.com/plugins/diffs/view/id/token"); + }); + + it("joins viewer path under baseUrl pathname", () => { + expect( + buildViewerUrl({ + config: {}, + baseUrl: "https://example.com/openclaw", + viewerPath: "/plugins/diffs/view/id/token", + }), + ).toBe("https://example.com/openclaw/plugins/diffs/view/id/token"); + }); + + it("rejects base URLs with query/hash", () => { + expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow( + "baseUrl must not include query/hash", + ); + expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow( + "baseUrl must not include query/hash", + ); + }); +}); + +describe("renderDiffDocument", () => { + it("renders before/after input into a complete viewer document", async () => { + const rendered = await renderDiffDocument( + { + kind: "before_after", + before: "const value = 1;\n", + after: "const value = 2;\n", + path: "src/example.ts", + }, + { + presentation: DEFAULT_DIFFS_TOOL_DEFAULTS, + image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }), + expandUnchanged: false, + }, + ); + + expect(rendered.title).toBe("src/example.ts"); + expect(rendered.fileCount).toBe(1); + expect(rendered.html).toContain("data-openclaw-diff-root"); + expect(rendered.html).toContain("src/example.ts"); + expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js"); + expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js"); + expect(rendered.imageHtml).toContain("max-width: 960px;"); + expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;"); + expect(rendered.html).toContain("min-height: 100vh;"); + expect(rendered.html).toContain('"diffIndicators":"bars"'); + expect(rendered.html).toContain('"disableLineNumbers":false'); + expect(rendered.html).toContain("--diffs-line-height: 24px;"); + expect(rendered.html).toContain("--diffs-font-size: 15px;"); + expect(rendered.html).not.toContain("fonts.googleapis.com"); + }); + + it("renders multi-file patch input", async () => { + const patch = [ + "diff --git a/a.ts b/a.ts", + "--- a/a.ts", + "+++ b/a.ts", + "@@ -1 +1 @@", + "-const a = 1;", + "+const a = 2;", + "diff --git a/b.ts b/b.ts", + "--- a/b.ts", + "+++ b/b.ts", + "@@ -1 +1 @@", + "-const b = 1;", + "+const b = 2;", + ].join("\n"); + + const rendered = await renderDiffDocument( + { + kind: "patch", + patch, + title: "Workspace patch", + }, + { + presentation: { + ...DEFAULT_DIFFS_TOOL_DEFAULTS, + layout: "split", + theme: "dark", + }, + image: resolveDiffImageRenderOptions({ + defaults: DEFAULT_DIFFS_TOOL_DEFAULTS, + fileQuality: "hq", + fileMaxWidth: 1180, + }), + expandUnchanged: true, + }, + ); + + expect(rendered.title).toBe("Workspace patch"); + expect(rendered.fileCount).toBe(2); + expect(rendered.html).toContain("Workspace patch"); + expect(rendered.imageHtml).toContain("max-width: 1180px;"); + }); + + it("rejects patches that exceed file-count limits", async () => { + const patch = Array.from({ length: 129 }, (_, i) => { + return [ + `diff --git a/f${i}.ts b/f${i}.ts`, + `--- a/f${i}.ts`, + `+++ b/f${i}.ts`, + "@@ -1 +1 @@", + "-const x = 1;", + "+const x = 2;", + ].join("\n"); + }).join("\n"); + + await expect( + renderDiffDocument( + { + kind: "patch", + patch, + }, + { + presentation: DEFAULT_DIFFS_TOOL_DEFAULTS, + image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }), + expandUnchanged: false, + }, + ), + ).rejects.toThrow("too many files"); + }); +}); + +describe("viewer assets", () => { + it("serves a stable loader that points at the current runtime bundle", async () => { + const loader = await getServedViewerAsset(VIEWER_LOADER_PATH); + + expect(loader?.contentType).toBe("text/javascript; charset=utf-8"); + expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`); + }); + + it("serves the runtime bundle body", async () => { + const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH); + + expect(runtime?.contentType).toBe("text/javascript; charset=utf-8"); + expect(String(runtime?.body)).toContain("openclawDiffsReady"); + }); + + it("returns null for unknown asset paths", async () => { + await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull(); + }); +}); + +describe("parseViewerPayloadJson", () => { + function buildValidPayload(): Record { + return { + prerenderedHTML: "
ok
", + langs: ["text"], + oldFile: { + name: "README.md", + contents: "before", + }, + newFile: { + name: "README.md", + contents: "after", + }, + options: { + theme: { + light: "pierre-light", + dark: "pierre-dark", + }, + diffStyle: "unified", + diffIndicators: "bars", + disableLineNumbers: false, + expandUnchanged: false, + themeType: "dark", + backgroundEnabled: true, + overflow: "wrap", + unsafeCSS: ":host{}", + }, + }; + } + + it("accepts valid payload JSON", () => { + const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload())); + expect(parsed.options.diffStyle).toBe("unified"); + expect(parsed.options.diffIndicators).toBe("bars"); + }); + + it("rejects payloads with invalid shape", () => { + const broken = buildValidPayload(); + broken.options = { + ...(broken.options as Record), + diffIndicators: "invalid", + }; + + expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow( + "Diff payload has invalid shape.", + ); + }); + + it("rejects invalid JSON", () => { + expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON."); + }); +}); diff --git a/extensions/diffs/src/http.test.ts b/extensions/diffs/src/http.test.ts deleted file mode 100644 index e35d847597b..00000000000 --- a/extensions/diffs/src/http.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { IncomingMessage } from "node:http"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js"; -import { createDiffsHttpHandler } from "./http.js"; -import { DiffArtifactStore } from "./store.js"; -import { createDiffStoreHarness } from "./test-helpers.js"; - -describe("createDiffsHttpHandler", () => { - let store: DiffArtifactStore; - let cleanupRootDir: () => Promise; - - async function handleLocalGet(url: string) { - const handler = createDiffsHttpHandler({ store }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url, - }), - res, - ); - return { handled, res }; - } - - beforeEach(async () => { - ({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-")); - }); - - afterEach(async () => { - await cleanupRootDir(); - }); - - it("serves a stored diff document", async () => { - const artifact = await createViewerArtifact(store); - const { handled, res } = await handleLocalGet(artifact.viewerPath); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(res.body).toBe("viewer"); - expect(res.getHeader("content-security-policy")).toContain("default-src 'none'"); - }); - - it("rejects invalid tokens", async () => { - const artifact = await createViewerArtifact(store); - const { handled, res } = await handleLocalGet( - artifact.viewerPath.replace(artifact.token, "bad-token"), - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(404); - }); - - it("rejects malformed artifact ids before reading from disk", async () => { - const handler = createDiffsHttpHandler({ store }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: "/plugins/diffs/view/not-a-real-id/not-a-real-token", - }), - res, - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(404); - }); - - it("serves the shared viewer asset", async () => { - const handler = createDiffsHttpHandler({ store }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: "/plugins/diffs/assets/viewer.js", - }), - res, - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v="); - }); - - it("serves the shared viewer runtime asset", async () => { - const handler = createDiffsHttpHandler({ store }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: "/plugins/diffs/assets/viewer-runtime.js", - }), - res, - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(String(res.body)).toContain("openclawDiffsReady"); - }); - - it.each([ - { - name: "blocks non-loopback viewer access by default", - request: remoteReq, - allowRemoteViewer: false, - expectedStatusCode: 404, - }, - { - name: "blocks loopback requests that carry proxy forwarding headers by default", - request: localReq, - headers: { "x-forwarded-for": "203.0.113.10" }, - allowRemoteViewer: false, - expectedStatusCode: 404, - }, - { - name: "allows remote access when allowRemoteViewer is enabled", - request: remoteReq, - allowRemoteViewer: true, - expectedStatusCode: 200, - }, - { - name: "allows proxied loopback requests when allowRemoteViewer is enabled", - request: localReq, - headers: { "x-forwarded-for": "203.0.113.10" }, - allowRemoteViewer: true, - expectedStatusCode: 200, - }, - ])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => { - const artifact = await createViewerArtifact(store); - - const handler = createDiffsHttpHandler({ store, allowRemoteViewer }); - const res = createMockServerResponse(); - const handled = await handler( - request({ - method: "GET", - url: artifact.viewerPath, - headers, - }), - res, - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(expectedStatusCode); - if (expectedStatusCode === 200) { - expect(res.body).toBe("viewer"); - } - }); - - it("rate-limits repeated remote misses", async () => { - const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true }); - - for (let i = 0; i < 40; i++) { - const miss = createMockServerResponse(); - await handler( - remoteReq({ - method: "GET", - url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - }), - miss, - ); - expect(miss.statusCode).toBe(404); - } - - const limited = createMockServerResponse(); - await handler( - remoteReq({ - method: "GET", - url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - }), - limited, - ); - expect(limited.statusCode).toBe(429); - }); -}); - -async function createViewerArtifact(store: DiffArtifactStore) { - return await store.createArtifact({ - html: "viewer", - title: "Demo", - inputKind: "before_after", - fileCount: 1, - }); -} - -function localReq(input: { - method: string; - url: string; - headers?: Record; -}): IncomingMessage { - return { - ...input, - headers: input.headers ?? {}, - socket: { remoteAddress: "127.0.0.1" }, - } as unknown as IncomingMessage; -} - -function remoteReq(input: { - method: string; - url: string; - headers?: Record; -}): IncomingMessage { - return { - ...input, - headers: input.headers ?? {}, - socket: { remoteAddress: "203.0.113.10" }, - } as unknown as IncomingMessage; -} diff --git a/extensions/diffs/src/render.test.ts b/extensions/diffs/src/render.test.ts deleted file mode 100644 index 006b239a39f..00000000000 --- a/extensions/diffs/src/render.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js"; -import { renderDiffDocument } from "./render.js"; - -describe("renderDiffDocument", () => { - it("renders before/after input into a complete viewer document", async () => { - const rendered = await renderDiffDocument( - { - kind: "before_after", - before: "const value = 1;\n", - after: "const value = 2;\n", - path: "src/example.ts", - }, - { - presentation: DEFAULT_DIFFS_TOOL_DEFAULTS, - image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }), - expandUnchanged: false, - }, - ); - - expect(rendered.title).toBe("src/example.ts"); - expect(rendered.fileCount).toBe(1); - expect(rendered.html).toContain("data-openclaw-diff-root"); - expect(rendered.html).toContain("src/example.ts"); - expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js"); - expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js"); - expect(rendered.imageHtml).toContain("max-width: 960px;"); - expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;"); - expect(rendered.html).toContain("min-height: 100vh;"); - expect(rendered.html).toContain('"diffIndicators":"bars"'); - expect(rendered.html).toContain('"disableLineNumbers":false'); - expect(rendered.html).toContain("--diffs-line-height: 24px;"); - expect(rendered.html).toContain("--diffs-font-size: 15px;"); - expect(rendered.html).not.toContain("fonts.googleapis.com"); - }); - - it("renders multi-file patch input", async () => { - const patch = [ - "diff --git a/a.ts b/a.ts", - "--- a/a.ts", - "+++ b/a.ts", - "@@ -1 +1 @@", - "-const a = 1;", - "+const a = 2;", - "diff --git a/b.ts b/b.ts", - "--- a/b.ts", - "+++ b/b.ts", - "@@ -1 +1 @@", - "-const b = 1;", - "+const b = 2;", - ].join("\n"); - - const rendered = await renderDiffDocument( - { - kind: "patch", - patch, - title: "Workspace patch", - }, - { - presentation: { - ...DEFAULT_DIFFS_TOOL_DEFAULTS, - layout: "split", - theme: "dark", - }, - image: resolveDiffImageRenderOptions({ - defaults: DEFAULT_DIFFS_TOOL_DEFAULTS, - fileQuality: "hq", - fileMaxWidth: 1180, - }), - expandUnchanged: true, - }, - ); - - expect(rendered.title).toBe("Workspace patch"); - expect(rendered.fileCount).toBe(2); - expect(rendered.html).toContain("Workspace patch"); - expect(rendered.imageHtml).toContain("max-width: 1180px;"); - }); - - it("rejects patches that exceed file-count limits", async () => { - const patch = Array.from({ length: 129 }, (_, i) => { - return [ - `diff --git a/f${i}.ts b/f${i}.ts`, - `--- a/f${i}.ts`, - `+++ b/f${i}.ts`, - "@@ -1 +1 @@", - "-const x = 1;", - "+const x = 2;", - ].join("\n"); - }).join("\n"); - - await expect( - renderDiffDocument( - { - kind: "patch", - patch, - }, - { - presentation: DEFAULT_DIFFS_TOOL_DEFAULTS, - image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }), - expandUnchanged: false, - }, - ), - ).rejects.toThrow("too many files"); - }); -}); diff --git a/extensions/diffs/src/store.test.ts b/extensions/diffs/src/store.test.ts index 02e0e0c8b6b..7e07fcc6e4b 100644 --- a/extensions/diffs/src/store.test.ts +++ b/extensions/diffs/src/store.test.ts @@ -1,6 +1,9 @@ import fs from "node:fs/promises"; +import type { IncomingMessage } from "node:http"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js"; +import { createDiffsHttpHandler } from "./http.js"; import { DiffArtifactStore } from "./store.js"; import { createDiffStoreHarness } from "./test-helpers.js"; @@ -211,3 +214,203 @@ describe("DiffArtifactStore", () => { expect(cleanupSpy).toHaveBeenCalledTimes(2); }); }); + +describe("createDiffsHttpHandler", () => { + let store: DiffArtifactStore; + let cleanupRootDir: () => Promise; + + async function handleLocalGet(url: string) { + const handler = createDiffsHttpHandler({ store }); + const res = createMockServerResponse(); + const handled = await handler( + localReq({ + method: "GET", + url, + }), + res, + ); + return { handled, res }; + } + + beforeEach(async () => { + ({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-")); + }); + + afterEach(async () => { + await cleanupRootDir(); + }); + + it("serves a stored diff document", async () => { + const artifact = await createViewerArtifact(store); + const { handled, res } = await handleLocalGet(artifact.viewerPath); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(res.body).toBe("viewer"); + expect(res.getHeader("content-security-policy")).toContain("default-src 'none'"); + }); + + it("rejects invalid tokens", async () => { + const artifact = await createViewerArtifact(store); + const { handled, res } = await handleLocalGet( + artifact.viewerPath.replace(artifact.token, "bad-token"), + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(404); + }); + + it("rejects malformed artifact ids before reading from disk", async () => { + const handler = createDiffsHttpHandler({ store }); + const res = createMockServerResponse(); + const handled = await handler( + localReq({ + method: "GET", + url: "/plugins/diffs/view/not-a-real-id/not-a-real-token", + }), + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(404); + }); + + it("serves the shared viewer asset", async () => { + const handler = createDiffsHttpHandler({ store }); + const res = createMockServerResponse(); + const handled = await handler( + localReq({ + method: "GET", + url: "/plugins/diffs/assets/viewer.js", + }), + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v="); + }); + + it("serves the shared viewer runtime asset", async () => { + const handler = createDiffsHttpHandler({ store }); + const res = createMockServerResponse(); + const handled = await handler( + localReq({ + method: "GET", + url: "/plugins/diffs/assets/viewer-runtime.js", + }), + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(String(res.body)).toContain("openclawDiffsReady"); + }); + + it.each([ + { + name: "blocks non-loopback viewer access by default", + request: remoteReq, + allowRemoteViewer: false, + expectedStatusCode: 404, + }, + { + name: "blocks loopback requests that carry proxy forwarding headers by default", + request: localReq, + headers: { "x-forwarded-for": "203.0.113.10" }, + allowRemoteViewer: false, + expectedStatusCode: 404, + }, + { + name: "allows remote access when allowRemoteViewer is enabled", + request: remoteReq, + allowRemoteViewer: true, + expectedStatusCode: 200, + }, + { + name: "allows proxied loopback requests when allowRemoteViewer is enabled", + request: localReq, + headers: { "x-forwarded-for": "203.0.113.10" }, + allowRemoteViewer: true, + expectedStatusCode: 200, + }, + ])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => { + const artifact = await createViewerArtifact(store); + + const handler = createDiffsHttpHandler({ store, allowRemoteViewer }); + const res = createMockServerResponse(); + const handled = await handler( + request({ + method: "GET", + url: artifact.viewerPath, + headers, + }), + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(expectedStatusCode); + if (expectedStatusCode === 200) { + expect(res.body).toBe("viewer"); + } + }); + + it("rate-limits repeated remote misses", async () => { + const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true }); + + for (let i = 0; i < 40; i++) { + const miss = createMockServerResponse(); + await handler( + remoteReq({ + method: "GET", + url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }), + miss, + ); + expect(miss.statusCode).toBe(404); + } + + const limited = createMockServerResponse(); + await handler( + remoteReq({ + method: "GET", + url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }), + limited, + ); + expect(limited.statusCode).toBe(429); + }); +}); + +async function createViewerArtifact(store: DiffArtifactStore) { + return await store.createArtifact({ + html: "viewer", + title: "Demo", + inputKind: "before_after", + fileCount: 1, + }); +} + +function localReq(input: { + method: string; + url: string; + headers?: Record; +}): IncomingMessage { + return { + ...input, + headers: input.headers ?? {}, + socket: { remoteAddress: "127.0.0.1" }, + } as unknown as IncomingMessage; +} + +function remoteReq(input: { + method: string; + url: string; + headers?: Record; +}): IncomingMessage { + return { + ...input, + headers: input.headers ?? {}, + socket: { remoteAddress: "203.0.113.10" }, + } as unknown as IncomingMessage; +} diff --git a/extensions/diffs/src/url.test.ts b/extensions/diffs/src/url.test.ts deleted file mode 100644 index 4511faaa270..00000000000 --- a/extensions/diffs/src/url.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js"; - -describe("diffs viewer URL helpers", () => { - it("defaults to loopback for lan/tailnet bind modes", () => { - expect( - buildViewerUrl({ - config: { gateway: { bind: "lan", port: 18789 } }, - viewerPath: "/plugins/diffs/view/id/token", - }), - ).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token"); - - expect( - buildViewerUrl({ - config: { gateway: { bind: "tailnet", port: 24444 } }, - viewerPath: "/plugins/diffs/view/id/token", - }), - ).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token"); - }); - - it("uses custom bind host when provided", () => { - expect( - buildViewerUrl({ - config: { - gateway: { - bind: "custom", - customBindHost: "gateway.example.com", - port: 443, - tls: { enabled: true }, - }, - }, - viewerPath: "/plugins/diffs/view/id/token", - }), - ).toBe("https://gateway.example.com/plugins/diffs/view/id/token"); - }); - - it("joins viewer path under baseUrl pathname", () => { - expect( - buildViewerUrl({ - config: {}, - baseUrl: "https://example.com/openclaw", - viewerPath: "/plugins/diffs/view/id/token", - }), - ).toBe("https://example.com/openclaw/plugins/diffs/view/id/token"); - }); - - it("rejects base URLs with query/hash", () => { - expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow( - "baseUrl must not include query/hash", - ); - expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow( - "baseUrl must not include query/hash", - ); - }); -}); diff --git a/extensions/diffs/src/viewer-assets.test.ts b/extensions/diffs/src/viewer-assets.test.ts deleted file mode 100644 index 5bd6500e7c8..00000000000 --- a/extensions/diffs/src/viewer-assets.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js"; - -describe("viewer assets", () => { - it("serves a stable loader that points at the current runtime bundle", async () => { - const loader = await getServedViewerAsset(VIEWER_LOADER_PATH); - - expect(loader?.contentType).toBe("text/javascript; charset=utf-8"); - expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`); - }); - - it("serves the runtime bundle body", async () => { - const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH); - - expect(runtime?.contentType).toBe("text/javascript; charset=utf-8"); - expect(String(runtime?.body)).toContain("openclawDiffsReady"); - }); - - it("returns null for unknown asset paths", async () => { - await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull(); - }); -}); diff --git a/extensions/diffs/src/viewer-payload.test.ts b/extensions/diffs/src/viewer-payload.test.ts deleted file mode 100644 index 44c3dda425e..00000000000 --- a/extensions/diffs/src/viewer-payload.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseViewerPayloadJson } from "./viewer-payload.js"; - -function buildValidPayload(): Record { - return { - prerenderedHTML: "
ok
", - langs: ["text"], - oldFile: { - name: "README.md", - contents: "before", - }, - newFile: { - name: "README.md", - contents: "after", - }, - options: { - theme: { - light: "pierre-light", - dark: "pierre-dark", - }, - diffStyle: "unified", - diffIndicators: "bars", - disableLineNumbers: false, - expandUnchanged: false, - themeType: "dark", - backgroundEnabled: true, - overflow: "wrap", - unsafeCSS: ":host{}", - }, - }; -} - -describe("parseViewerPayloadJson", () => { - it("accepts valid payload JSON", () => { - const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload())); - expect(parsed.options.diffStyle).toBe("unified"); - expect(parsed.options.diffIndicators).toBe("bars"); - }); - - it("rejects payloads with invalid shape", () => { - const broken = buildValidPayload(); - broken.options = { - ...(broken.options as Record), - diffIndicators: "invalid", - }; - - expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow( - "Diff payload has invalid shape.", - ); - }); - - it("rejects invalid JSON", () => { - expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON."); - }); -}); diff --git a/extensions/openshell/src/backend.test.ts b/extensions/openshell/src/backend.test.ts deleted file mode 100644 index 2685d7effa8..00000000000 --- a/extensions/openshell/src/backend.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; - -const cliMocks = vi.hoisted(() => ({ - runOpenShellCli: vi.fn(), -})); - -vi.mock("./cli.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - runOpenShellCli: cliMocks.runOpenShellCli, - }; -}); - -import { createOpenShellSandboxBackendManager } from "./backend.js"; -import { resolveOpenShellPluginConfig } from "./config.js"; - -describe("openshell backend manager", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("checks runtime status with config override from OpenClaw config", async () => { - cliMocks.runOpenShellCli.mockResolvedValue({ - code: 0, - stdout: "{}", - stderr: "", - }); - - const manager = createOpenShellSandboxBackendManager({ - pluginConfig: resolveOpenShellPluginConfig({ - command: "openshell", - from: "openclaw", - }), - }); - - const result = await manager.describeRuntime({ - entry: { - containerName: "openclaw-session-1234", - backendId: "openshell", - runtimeLabel: "openclaw-session-1234", - sessionKey: "agent:main", - createdAtMs: 1, - lastUsedAtMs: 1, - image: "custom-source", - configLabelKind: "Source", - }, - config: { - plugins: { - entries: { - openshell: { - enabled: true, - config: { - command: "openshell", - from: "custom-source", - }, - }, - }, - }, - }, - }); - - expect(result).toEqual({ - running: true, - actualConfigLabel: "custom-source", - configLabelMatch: true, - }); - expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({ - context: expect.objectContaining({ - sandboxName: "openclaw-session-1234", - config: expect.objectContaining({ - from: "custom-source", - }), - }), - args: ["sandbox", "get", "openclaw-session-1234"], - }); - }); - - it("removes runtimes via openshell sandbox delete", async () => { - cliMocks.runOpenShellCli.mockResolvedValue({ - code: 0, - stdout: "", - stderr: "", - }); - - const manager = createOpenShellSandboxBackendManager({ - pluginConfig: resolveOpenShellPluginConfig({ - command: "/usr/local/bin/openshell", - gateway: "lab", - }), - }); - - await manager.removeRuntime({ - entry: { - containerName: "openclaw-session-5678", - backendId: "openshell", - runtimeLabel: "openclaw-session-5678", - sessionKey: "agent:main", - createdAtMs: 1, - lastUsedAtMs: 1, - image: "openclaw", - configLabelKind: "Source", - }, - config: {}, - }); - - expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({ - context: expect.objectContaining({ - sandboxName: "openclaw-session-5678", - config: expect.objectContaining({ - command: "/usr/local/bin/openshell", - gateway: "lab", - }), - }), - args: ["sandbox", "delete", "openclaw-session-5678"], - }); - }); -}); diff --git a/extensions/openshell/src/cli.test.ts b/extensions/openshell/src/cli.test.ts deleted file mode 100644 index 88dc7764860..00000000000 --- a/extensions/openshell/src/cli.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { - buildExecRemoteCommand, - buildOpenShellBaseArgv, - resolveOpenShellCommand, - setBundledOpenShellCommandResolverForTest, - shellEscape, -} from "./cli.js"; -import { resolveOpenShellPluginConfig } from "./config.js"; - -describe("openshell cli helpers", () => { - afterEach(() => { - setBundledOpenShellCommandResolverForTest(); - }); - - it("builds base argv with gateway overrides", () => { - const config = resolveOpenShellPluginConfig({ - command: "/usr/local/bin/openshell", - gateway: "lab", - gatewayEndpoint: "https://lab.example", - }); - expect(buildOpenShellBaseArgv(config)).toEqual([ - "/usr/local/bin/openshell", - "--gateway", - "lab", - "--gateway-endpoint", - "https://lab.example", - ]); - }); - - it("prefers the bundled openshell command when available", () => { - setBundledOpenShellCommandResolverForTest(() => "/tmp/node_modules/.bin/openshell"); - const config = resolveOpenShellPluginConfig(undefined); - - expect(resolveOpenShellCommand("openshell")).toBe("/tmp/node_modules/.bin/openshell"); - expect(buildOpenShellBaseArgv(config)).toEqual(["/tmp/node_modules/.bin/openshell"]); - }); - - it("falls back to the PATH command when no bundled openshell is present", () => { - setBundledOpenShellCommandResolverForTest(() => null); - - expect(resolveOpenShellCommand("openshell")).toBe("openshell"); - }); - - it("shell escapes single quotes", () => { - expect(shellEscape(`a'b`)).toBe(`'a'"'"'b'`); - }); - - it("wraps exec commands with env and workdir", () => { - const command = buildExecRemoteCommand({ - command: "pwd && printenv TOKEN", - workdir: "/sandbox/project", - env: { - TOKEN: "abc 123", - }, - }); - expect(command).toContain(`'env'`); - expect(command).toContain(`'TOKEN=abc 123'`); - expect(command).toContain(`'cd '"'"'/sandbox/project'"'"' && pwd && printenv TOKEN'`); - }); -}); diff --git a/extensions/openshell/src/config.test.ts b/extensions/openshell/src/config.test.ts deleted file mode 100644 index f46fec1cd46..00000000000 --- a/extensions/openshell/src/config.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveOpenShellPluginConfig } from "./config.js"; - -describe("openshell plugin config", () => { - it("applies defaults", () => { - expect(resolveOpenShellPluginConfig(undefined)).toEqual({ - mode: "mirror", - command: "openshell", - gateway: undefined, - gatewayEndpoint: undefined, - from: "openclaw", - policy: undefined, - providers: [], - gpu: false, - autoProviders: true, - remoteWorkspaceDir: "/sandbox", - remoteAgentWorkspaceDir: "/agent", - timeoutMs: 120_000, - }); - }); - - it("accepts remote mode", () => { - expect(resolveOpenShellPluginConfig({ mode: "remote" }).mode).toBe("remote"); - }); - - it("rejects relative remote paths", () => { - expect(() => - resolveOpenShellPluginConfig({ - remoteWorkspaceDir: "sandbox", - }), - ).toThrow("OpenShell remote path must be absolute"); - }); - - it("rejects unknown mode", () => { - expect(() => - resolveOpenShellPluginConfig({ - mode: "bogus", - }), - ).toThrow("mode must be one of mirror, remote"); - }); -}); diff --git a/extensions/openshell/src/fs-bridge.test.ts b/extensions/openshell/src/fs-bridge.test.ts deleted file mode 100644 index 67a3edc5bcc..00000000000 --- a/extensions/openshell/src/fs-bridge.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js"; -import type { OpenShellSandboxBackend } from "./backend.js"; -import { createOpenShellFsBridge } from "./fs-bridge.js"; - -const tempDirs: string[] = []; - -async function makeTempDir() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-openshell-fs-")); - tempDirs.push(dir); - return dir; -} - -afterEach(async () => { - await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); -}); - -function createBackendMock(): OpenShellSandboxBackend { - return { - id: "openshell", - runtimeId: "openshell-test", - runtimeLabel: "openshell-test", - workdir: "/sandbox", - env: {}, - remoteWorkspaceDir: "/sandbox", - remoteAgentWorkspaceDir: "/agent", - buildExecSpec: vi.fn(), - runShellCommand: vi.fn(), - runRemoteShellScript: vi.fn().mockResolvedValue({ - stdout: Buffer.alloc(0), - stderr: Buffer.alloc(0), - code: 0, - }), - syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined), - } as unknown as OpenShellSandboxBackend; -} - -describe("openshell fs bridge", () => { - it("writes locally and syncs the file to the remote workspace", async () => { - const workspaceDir = await makeTempDir(); - const backend = createBackendMock(); - const sandbox = createSandboxTestContext({ - overrides: { - backendId: "openshell", - workspaceDir, - agentWorkspaceDir: workspaceDir, - containerWorkdir: "/sandbox", - }, - }); - - const bridge = createOpenShellFsBridge({ sandbox, backend }); - await bridge.writeFile({ - filePath: "nested/file.txt", - data: "hello", - mkdir: true, - }); - - expect(await fs.readFile(path.join(workspaceDir, "nested", "file.txt"), "utf8")).toBe("hello"); - expect(backend.syncLocalPathToRemote).toHaveBeenCalledWith( - path.join(workspaceDir, "nested", "file.txt"), - "/sandbox/nested/file.txt", - ); - }); - - it("maps agent mount paths when the sandbox workspace is read-only", async () => { - const workspaceDir = await makeTempDir(); - const agentWorkspaceDir = await makeTempDir(); - await fs.writeFile(path.join(agentWorkspaceDir, "note.txt"), "agent", "utf8"); - const backend = createBackendMock(); - const sandbox = createSandboxTestContext({ - overrides: { - backendId: "openshell", - workspaceDir, - agentWorkspaceDir, - workspaceAccess: "ro", - containerWorkdir: "/sandbox", - }, - }); - - const bridge = createOpenShellFsBridge({ sandbox, backend }); - const resolved = bridge.resolvePath({ filePath: "/agent/note.txt" }); - expect(resolved.hostPath).toBe(path.join(agentWorkspaceDir, "note.txt")); - expect(await bridge.readFile({ filePath: "/agent/note.txt" })).toEqual(Buffer.from("agent")); - }); -}); diff --git a/extensions/openshell/src/remote-fs-bridge.test.ts b/extensions/openshell/src/openshell-core.test.ts similarity index 50% rename from extensions/openshell/src/remote-fs-bridge.test.ts rename to extensions/openshell/src/openshell-core.test.ts index 9e8dc7509ae..9997a65650a 100644 --- a/extensions/openshell/src/remote-fs-bridge.test.ts +++ b/extensions/openshell/src/openshell-core.test.ts @@ -2,10 +2,232 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js"; import type { OpenShellSandboxBackend } from "./backend.js"; -import { createOpenShellRemoteFsBridge } from "./remote-fs-bridge.js"; +import { + buildExecRemoteCommand, + buildOpenShellBaseArgv, + resolveOpenShellCommand, + setBundledOpenShellCommandResolverForTest, + shellEscape, +} from "./cli.js"; +import { resolveOpenShellPluginConfig } from "./config.js"; + +const cliMocks = vi.hoisted(() => ({ + runOpenShellCli: vi.fn(), +})); + +let createOpenShellSandboxBackendManager: typeof import("./backend.js").createOpenShellSandboxBackendManager; + +describe("openshell plugin config", () => { + it("applies defaults", () => { + expect(resolveOpenShellPluginConfig(undefined)).toEqual({ + mode: "mirror", + command: "openshell", + gateway: undefined, + gatewayEndpoint: undefined, + from: "openclaw", + policy: undefined, + providers: [], + gpu: false, + autoProviders: true, + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + timeoutMs: 120_000, + }); + }); + + it("accepts remote mode", () => { + expect(resolveOpenShellPluginConfig({ mode: "remote" }).mode).toBe("remote"); + }); + + it("rejects relative remote paths", () => { + expect(() => + resolveOpenShellPluginConfig({ + remoteWorkspaceDir: "sandbox", + }), + ).toThrow("OpenShell remote path must be absolute"); + }); + + it("rejects unknown mode", () => { + expect(() => + resolveOpenShellPluginConfig({ + mode: "bogus", + }), + ).toThrow("mode must be one of mirror, remote"); + }); +}); + +describe("openshell cli helpers", () => { + afterEach(() => { + setBundledOpenShellCommandResolverForTest(); + }); + + it("builds base argv with gateway overrides", () => { + const config = resolveOpenShellPluginConfig({ + command: "/usr/local/bin/openshell", + gateway: "lab", + gatewayEndpoint: "https://lab.example", + }); + expect(buildOpenShellBaseArgv(config)).toEqual([ + "/usr/local/bin/openshell", + "--gateway", + "lab", + "--gateway-endpoint", + "https://lab.example", + ]); + }); + + it("prefers the bundled openshell command when available", () => { + setBundledOpenShellCommandResolverForTest(() => "/tmp/node_modules/.bin/openshell"); + const config = resolveOpenShellPluginConfig(undefined); + + expect(resolveOpenShellCommand("openshell")).toBe("/tmp/node_modules/.bin/openshell"); + expect(buildOpenShellBaseArgv(config)).toEqual(["/tmp/node_modules/.bin/openshell"]); + }); + + it("falls back to the PATH command when no bundled openshell is present", () => { + setBundledOpenShellCommandResolverForTest(() => null); + + expect(resolveOpenShellCommand("openshell")).toBe("openshell"); + }); + + it("shell escapes single quotes", () => { + expect(shellEscape(`a'b`)).toBe(`'a'"'"'b'`); + }); + + it("wraps exec commands with env and workdir", () => { + const command = buildExecRemoteCommand({ + command: "pwd && printenv TOKEN", + workdir: "/sandbox/project", + env: { + TOKEN: "abc 123", + }, + }); + expect(command).toContain(`'env'`); + expect(command).toContain(`'TOKEN=abc 123'`); + expect(command).toContain(`'cd '"'"'/sandbox/project'"'"' && pwd && printenv TOKEN'`); + }); +}); + +describe("openshell backend manager", () => { + beforeAll(async () => { + vi.resetModules(); + vi.doMock("./cli.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runOpenShellCli: cliMocks.runOpenShellCli, + }; + }); + ({ createOpenShellSandboxBackendManager } = await import("./backend.js")); + }); + + afterAll(() => { + vi.doUnmock("./cli.js"); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("checks runtime status with config override from OpenClaw config", async () => { + cliMocks.runOpenShellCli.mockResolvedValue({ + code: 0, + stdout: "{}", + stderr: "", + }); + + const manager = createOpenShellSandboxBackendManager({ + pluginConfig: resolveOpenShellPluginConfig({ + command: "openshell", + from: "openclaw", + }), + }); + + const result = await manager.describeRuntime({ + entry: { + containerName: "openclaw-session-1234", + backendId: "openshell", + runtimeLabel: "openclaw-session-1234", + sessionKey: "agent:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "custom-source", + configLabelKind: "Source", + }, + config: { + plugins: { + entries: { + openshell: { + enabled: true, + config: { + command: "openshell", + from: "custom-source", + }, + }, + }, + }, + }, + }); + + expect(result).toEqual({ + running: true, + actualConfigLabel: "custom-source", + configLabelMatch: true, + }); + expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({ + context: expect.objectContaining({ + sandboxName: "openclaw-session-1234", + config: expect.objectContaining({ + from: "custom-source", + }), + }), + args: ["sandbox", "get", "openclaw-session-1234"], + }); + }); + + it("removes runtimes via openshell sandbox delete", async () => { + cliMocks.runOpenShellCli.mockResolvedValue({ + code: 0, + stdout: "", + stderr: "", + }); + + const manager = createOpenShellSandboxBackendManager({ + pluginConfig: resolveOpenShellPluginConfig({ + command: "/usr/local/bin/openshell", + gateway: "lab", + }), + }); + + await manager.removeRuntime({ + entry: { + containerName: "openclaw-session-5678", + backendId: "openshell", + runtimeLabel: "openclaw-session-5678", + sessionKey: "agent:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "openclaw", + configLabelKind: "Source", + }, + config: {}, + }); + + expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({ + context: expect.objectContaining({ + sandboxName: "openclaw-session-5678", + config: expect.objectContaining({ + command: "/usr/local/bin/openshell", + gateway: "lab", + }), + }), + args: ["sandbox", "delete", "openclaw-session-5678"], + }); + }); +}); const tempDirs: string[] = []; @@ -19,6 +241,26 @@ afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); +function createMirrorBackendMock(): OpenShellSandboxBackend { + return { + id: "openshell", + runtimeId: "openshell-test", + runtimeLabel: "openshell-test", + workdir: "/sandbox", + env: {}, + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + buildExecSpec: vi.fn(), + runShellCommand: vi.fn(), + runRemoteShellScript: vi.fn().mockResolvedValue({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }), + syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined), + } as unknown as OpenShellSandboxBackend; +} + function translateRemotePath(value: string, roots: { workspace: string; agent: string }) { if (value === "/sandbox" || value.startsWith("/sandbox/")) { return path.join(roots.workspace, value.slice("/sandbox".length)); @@ -55,7 +297,10 @@ async function runLocalShell(params: { }; } -function createBackendMock(roots: { workspace: string; agent: string }): OpenShellSandboxBackend { +function createRemoteBackendMock(roots: { + workspace: string; + agent: string; +}): OpenShellSandboxBackend { return { id: "openshell", runtimeId: "openshell-test", @@ -226,14 +471,63 @@ async function applyMutation(args: string[], stdin?: Buffer) { throw new Error(`unknown mutation operation: ${operation}`); } -describe("openshell remote fs bridge", () => { +describe("openshell fs bridges", () => { + it("writes locally and syncs the file to the remote workspace", async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); + const backend = createMirrorBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const { createOpenShellFsBridge } = await import("./fs-bridge.js"); + const bridge = createOpenShellFsBridge({ sandbox, backend }); + await bridge.writeFile({ + filePath: "nested/file.txt", + data: "hello", + mkdir: true, + }); + + expect(await fs.readFile(path.join(workspaceDir, "nested", "file.txt"), "utf8")).toBe("hello"); + expect(backend.syncLocalPathToRemote).toHaveBeenCalledWith( + path.join(workspaceDir, "nested", "file.txt"), + "/sandbox/nested/file.txt", + ); + }); + + it("maps agent mount paths when the sandbox workspace is read-only", async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); + const agentWorkspaceDir = await makeTempDir("openclaw-openshell-agent-"); + await fs.writeFile(path.join(agentWorkspaceDir, "note.txt"), "agent", "utf8"); + const backend = createMirrorBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir, + workspaceAccess: "ro", + containerWorkdir: "/sandbox", + }, + }); + + const { createOpenShellFsBridge } = await import("./fs-bridge.js"); + const bridge = createOpenShellFsBridge({ sandbox, backend }); + const resolved = bridge.resolvePath({ filePath: "/agent/note.txt" }); + expect(resolved.hostPath).toBe(path.join(agentWorkspaceDir, "note.txt")); + expect(await bridge.readFile({ filePath: "/agent/note.txt" })).toEqual(Buffer.from("agent")); + }); + it("writes, reads, renames, and removes files without local host paths", async () => { const workspaceDir = await makeTempDir("openclaw-openshell-remote-local-"); const remoteWorkspaceDir = await makeTempDir("openclaw-openshell-remote-workspace-"); const remoteAgentDir = await makeTempDir("openclaw-openshell-remote-agent-"); const remoteWorkspaceRealDir = await fs.realpath(remoteWorkspaceDir); const remoteAgentRealDir = await fs.realpath(remoteAgentDir); - const backend = createBackendMock({ + const backend = createRemoteBackendMock({ workspace: remoteWorkspaceRealDir, agent: remoteAgentRealDir, }); @@ -246,6 +540,7 @@ describe("openshell remote fs bridge", () => { }, }); + const { createOpenShellRemoteFsBridge } = await import("./remote-fs-bridge.js"); const bridge = createOpenShellRemoteFsBridge({ sandbox, backend }); await bridge.writeFile({ filePath: "nested/file.txt",