diff --git a/extensions/canvas/src/host/a2ui.ts b/extensions/canvas/src/host/a2ui.ts index dfb72021a3cd..9ecd8ffd4abb 100644 --- a/extensions/canvas/src/host/a2ui.ts +++ b/extensions/canvas/src/host/a2ui.ts @@ -10,13 +10,7 @@ import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/string-coerce import { A2UI_PATH, injectCanvasRuntime, isA2uiPath } from "./a2ui-shared.js"; import { resolveFileWithinRoot } from "./file-resolver.js"; -export { - A2UI_PATH, - CANVAS_HOST_PATH, - CANVAS_WS_PATH, - injectCanvasRuntime, - isA2uiPath, -} from "./a2ui-shared.js"; +export { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "./a2ui-shared.js"; let cachedA2uiRootReal: string | null | undefined; let resolvingA2uiRoot: Promise | null = null; diff --git a/extensions/canvas/src/host/server.test.ts b/extensions/canvas/src/host/server.test.ts index 1335d64f22bb..cd23809d88ea 100644 --- a/extensions/canvas/src/host/server.test.ts +++ b/extensions/canvas/src/host/server.test.ts @@ -15,6 +15,8 @@ type MockWatcher = { __emit: (event: string, ...args: unknown[]) => void; }; +const CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES = 64 * 1024; + type CanvasWatchFactory = NonNullable< Parameters[0]["watchFactory"] >; @@ -197,7 +199,6 @@ describe("canvas host", () => { }; let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler; let startCanvasHost: typeof import("./server.js").startCanvasHost; - let canvasLiveReloadMaxInboundMessageBytes = 0; let WebSocketServerClass: typeof import("ws").WebSocketServer; let watcherState: ReturnType; let fixtureRoot = ""; @@ -242,8 +243,6 @@ describe("canvas host", () => { vi.resetModules(); const serverModule = await import("./server.js"); ({ createCanvasHostHandler, startCanvasHost } = serverModule); - canvasLiveReloadMaxInboundMessageBytes = - serverModule.CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES; const wsModule = await vi.importActual("ws"); WebSocketServerClass = wsModule.WebSocketServer; fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-fixtures-")); @@ -507,7 +506,7 @@ describe("canvas host", () => { try { expect(constructorOptions[0]).toMatchObject({ noServer: true, - maxPayload: canvasLiveReloadMaxInboundMessageBytes, + maxPayload: CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES, }); const socketOn = vi.fn(); connectionHandler?.({ on: socketOn } as unknown as TrackingWebSocket); diff --git a/extensions/canvas/src/host/server.ts b/extensions/canvas/src/host/server.ts index b3f2335e9a4c..729c4af192f6 100644 --- a/extensions/canvas/src/host/server.ts +++ b/extensions/canvas/src/host/server.ts @@ -28,7 +28,7 @@ import { } from "./a2ui-shared.js"; import { normalizeUrlPath, resolveFileWithinRoot } from "./file-resolver.js"; -export const CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES = 64 * 1024; +const CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES = 64 * 1024; /** Options for Canvas host creation. */ type CanvasHostOpts = { diff --git a/extensions/google/image-generation-provider.test.ts b/extensions/google/image-generation-provider.test.ts index 6585099afadb..b20d168356f8 100644 --- a/extensions/google/image-generation-provider.test.ts +++ b/extensions/google/image-generation-provider.test.ts @@ -5,7 +5,6 @@ import * as providerHttp from "openclaw/plugin-sdk/provider-http"; import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildGoogleImageGenerationProvider } from "./image-generation-provider.js"; -import { testing as geminiWebSearchTesting } from "./src/gemini-web-search-provider.js"; let ssrfMock: { mockRestore: () => void } | undefined; @@ -622,20 +621,4 @@ describe("Google image-generation provider", () => { }), ).toBe(false); }); - - it("prefers scoped configured Gemini API keys over environment fallbacks", () => { - expect( - geminiWebSearchTesting.resolveGeminiApiKey({ - apiKey: "gemini-secret", - }), - ).toBe("gemini-secret"); - }); - - it("falls back to the default Gemini model when unset or blank", () => { - expect(geminiWebSearchTesting.resolveGeminiModel()).toBe("gemini-2.5-flash"); - expect(geminiWebSearchTesting.resolveGeminiModel({ model: " " })).toBe("gemini-2.5-flash"); - expect(geminiWebSearchTesting.resolveGeminiModel({ model: "gemini-2.5-pro" })).toBe( - "gemini-2.5-pro", - ); - }); }); diff --git a/extensions/google/src/gemini-web-search-provider.shared.ts b/extensions/google/src/gemini-web-search-provider.shared.ts index 62ac2c3b7299..faad24c13047 100644 --- a/extensions/google/src/gemini-web-search-provider.shared.ts +++ b/extensions/google/src/gemini-web-search-provider.shared.ts @@ -20,17 +20,6 @@ export function resolveGeminiConfig(searchConfig?: Record): Gem return isRecord(gemini) ? gemini : {}; } -export function resolveGeminiApiKey( - gemini?: GeminiConfig, - env: Record = process.env, -): string | undefined { - return ( - trimToUndefined(gemini?.apiKey) ?? - trimToUndefined(env.GEMINI_API_KEY) ?? - trimToUndefined(gemini?.providerApiKey) - ); -} - export function resolveGeminiModel(gemini?: GeminiConfig): string { return trimToUndefined(gemini?.model) ?? DEFAULT_GEMINI_WEB_SEARCH_MODEL; } diff --git a/extensions/google/src/gemini-web-search-provider.ts b/extensions/google/src/gemini-web-search-provider.ts index 25dc05550aa2..191802dd5556 100644 --- a/extensions/google/src/gemini-web-search-provider.ts +++ b/extensions/google/src/gemini-web-search-provider.ts @@ -9,11 +9,6 @@ import { type WebSearchProviderToolDefinition, } from "openclaw/plugin-sdk/provider-web-search-config-contract"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { - resolveGeminiApiKey, - resolveGeminiBaseUrl, - resolveGeminiModel, -} from "./gemini-web-search-provider.shared.js"; const GEMINI_CREDENTIAL_PATH = "plugins.entries.google.config.webSearch.apiKey"; const GOOGLE_PROVIDER_CREDENTIAL_PATH = "models.providers.google.apiKey"; @@ -145,10 +140,3 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin { ), }; } - -export const testing = { - resolveGeminiApiKey, - resolveGeminiBaseUrl, - resolveGeminiModel, - withGoogleModelProviderFallbacks, -} as const; diff --git a/extensions/google/web-search-provider.test.ts b/extensions/google/web-search-provider.test.ts index 72fb09d81c6f..fa5dd86b02a5 100644 --- a/extensions/google/web-search-provider.test.ts +++ b/extensions/google/web-search-provider.test.ts @@ -1,8 +1,8 @@ // Google tests cover web search provider plugin behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { withEnv, withEnvAsync, withFetchPreconnect } from "openclaw/plugin-sdk/test-env"; +import { withEnvAsync, withFetchPreconnect } from "openclaw/plugin-sdk/test-env"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { testing, createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js"; +import { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js"; type TestModelProviderConfig = NonNullable< NonNullable["providers"] @@ -104,28 +104,6 @@ describe("google web search provider", () => { }); }); - it("falls back to GEMINI_API_KEY from the environment", () => { - withEnv({ GEMINI_API_KEY: "AIza-env-test" }, () => { - expect(testing.resolveGeminiApiKey()).toBe("AIza-env-test"); - }); - }); - - it("prefers configured api keys over env fallbacks", () => { - withEnv({ GEMINI_API_KEY: "AIza-env-test" }, () => { - expect(testing.resolveGeminiApiKey({ apiKey: "AIza-configured-test" })).toBe( - "AIza-configured-test", - ); - }); - }); - - it("uses provider api keys only after env fallbacks", () => { - withEnv({ GEMINI_API_KEY: "AIza-env-test" }, () => { - expect(testing.resolveGeminiApiKey({ providerApiKey: "AIza-provider-test" })).toBe( - "AIza-env-test", - ); - }); - }); - it("stores configured credentials at the canonical plugin config path", () => { const provider = createGeminiWebSearchProvider(); const config = {} as OpenClawConfig; @@ -136,39 +114,6 @@ describe("google web search provider", () => { expect(provider.getConfiguredCredentialValue?.(config)).toBe("AIza-plugin-test"); }); - it("keeps model-provider fallback config runtime-only when Gemini config was injected", () => { - const searchConfig = Object.defineProperty({ provider: "gemini" }, "gemini", { - value: { apiKey: "AIza-plugin-test" }, - enumerable: false, - configurable: true, - writable: true, - }); - - const merged = testing.withGoogleModelProviderFallbacks(searchConfig, { - models: { - providers: { - google: createGoogleModelProviderConfig({ - apiKey: "AIza-provider-test", - baseUrl: "https://generativelanguage.googleapis.com/proxy/v1beta/", - }), - }, - }, - }); - - expect(merged?.gemini).toEqual({ - apiKey: "AIza-plugin-test", - providerApiKey: "AIza-provider-test", - providerBaseUrl: "https://generativelanguage.googleapis.com/proxy/v1beta/", - }); - expect(Object.keys(merged ?? {})).toEqual(["provider"]); - expect(Object.getOwnPropertyDescriptor(merged, "gemini")?.enumerable).toBe(false); - }); - - it("defaults the Gemini web search model and trims explicit overrides", () => { - expect(testing.resolveGeminiModel()).toBe("gemini-2.5-flash"); - expect(testing.resolveGeminiModel({ model: " gemini-2.5-pro " })).toBe("gemini-2.5-pro"); - }); - it("routes Gemini web search through plugin webSearch.baseUrl", async () => { const mockFetch = installGeminiFetch(); const provider = createGeminiWebSearchProvider(); @@ -702,10 +647,4 @@ describe("google web search provider", () => { }); expect(mockFetch).not.toHaveBeenCalled(); }); - - it("normalizes Gemini shorthand base URLs", () => { - expect( - testing.resolveGeminiBaseUrl({ baseUrl: "https://generativelanguage.googleapis.com" }), - ).toBe("https://generativelanguage.googleapis.com/v1beta"); - }); }); diff --git a/extensions/qa-channel/src/inbound.test.ts b/extensions/qa-channel/src/inbound.test.ts index f428ddb35577..046af46a9b61 100644 --- a/extensions/qa-channel/src/inbound.test.ts +++ b/extensions/qa-channel/src/inbound.test.ts @@ -3,7 +3,7 @@ import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helper import { beforeEach, describe, expect, it, vi } from "vitest"; import { setQaChannelRuntime } from "../api.js"; import { deleteQaBusMessage, editQaBusMessage, sendQaBusMessage } from "./bus-client.js"; -import { handleQaInbound, isHttpMediaUrl } from "./inbound.js"; +import { handleQaInbound } from "./inbound.js"; vi.mock("./bus-client.js", async (importOriginal) => { const actual = await importOriginal(); @@ -66,16 +66,6 @@ function firstRunAssembledParams(runtime: ReturnType { - it("accepts only http and https urls", () => { - expect(isHttpMediaUrl("https://example.com/image.png")).toBe(true); - expect(isHttpMediaUrl("http://example.com/image.png")).toBe(true); - expect(isHttpMediaUrl("file:///etc/passwd")).toBe(false); - expect(isHttpMediaUrl("/etc/passwd")).toBe(false); - expect(isHttpMediaUrl("data:text/plain;base64,SGVsbG8=")).toBe(false); - }); -}); - describe("handleQaInbound", () => { beforeEach(() => { vi.clearAllMocks(); @@ -363,6 +353,43 @@ describe("handleQaInbound", () => { expect(ctxPayload.MediaPaths).toBeUndefined(); }); + it("rejects non-http attachment URLs without dropping the message", async () => { + const runtime = createPluginRuntimeMock(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + setQaChannelRuntime(runtime); + + try { + await handleQaInbound( + createQaInboundParams({ + message: { + attachments: [ + { + id: "attachment-1", + kind: "image", + mimeType: "image/png", + url: "file:///etc/passwd", + }, + { + id: "attachment-2", + kind: "file", + mimeType: "text/plain", + url: "data:text/plain;base64,SGVsbG8=", + }, + ], + }, + }), + ); + + expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1); + const ctxPayload = firstRunAssembledParams(runtime).ctxPayload; + expect(ctxPayload.MediaPath).toBeUndefined(); + expect(ctxPayload.MediaPaths).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(2); + } finally { + warn.mockRestore(); + } + }); + it("uses allowFrom as the group sender fallback for allowlist policy", async () => { const runtime = createPluginRuntimeMock(); setQaChannelRuntime(runtime); diff --git a/extensions/qa-channel/src/inbound.ts b/extensions/qa-channel/src/inbound.ts index 4d4a0c593e8d..3df0a9f2de22 100644 --- a/extensions/qa-channel/src/inbound.ts +++ b/extensions/qa-channel/src/inbound.ts @@ -23,7 +23,7 @@ import { import { getQaChannelRuntime } from "./runtime.js"; import type { CoreConfig, ResolvedQaChannelAccount } from "./types.js"; -export function isHttpMediaUrl(value: string): boolean { +function isHttpMediaUrl(value: string): boolean { try { const parsed = new URL(value); return parsed.protocol === "http:" || parsed.protocol === "https:"; diff --git a/extensions/workspaces/src/cli.test.ts b/extensions/workspaces/src/cli.test.ts index e42a36bf8d22..bda6709d3903 100644 --- a/extensions/workspaces/src/cli.test.ts +++ b/extensions/workspaces/src/cli.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { Command } from "commander"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { registerWorkspaceCli, parseWorkspaceBindingShorthand } from "./cli.js"; +import { registerWorkspaceCli } from "./cli.js"; import { registerWorkspaceGatewayMethods } from "./gateway.js"; import { WorkspaceStore } from "./store.js"; @@ -95,25 +95,6 @@ describe("workspace CLI", () => { gatewayRuntime.callGatewayFromCli.mockReset(); }); - it("parses binding shorthand sources and rejects malformed values", () => { - expect(parseWorkspaceBindingShorthand("value=file:q3.json#/revenue")).toEqual([ - "value", - { source: "file", path: "q3.json", pointer: "/revenue" }, - ]); - expect(parseWorkspaceBindingShorthand("rows=rpc:sessions.list")).toEqual([ - "rows", - { source: "rpc", method: "sessions.list" }, - ]); - expect(parseWorkspaceBindingShorthand('value=static:{"ok":true}')).toEqual([ - "value", - { source: "static", value: { ok: true } }, - ]); - - expect(() => parseWorkspaceBindingShorthand("file:q3.json")).toThrow("binding must be"); - expect(() => parseWorkspaceBindingShorthand("value=static:{bad")).toThrow("invalid static"); - expect(() => parseWorkspaceBindingShorthand("value=command:date")).toThrow("binding source"); - }); - it("round-trips tabs and widgets through the L1 gateway methods", async () => { await withTempStateDir(async (stateDir) => { const store = new WorkspaceStore({ stateDir }); @@ -140,6 +121,10 @@ describe("workspace CLI", () => { "Q3 Revenue", "--binding", "value=file:q3.json#/revenue", + "--binding", + "rows=rpc:sessions.list", + "--binding", + 'summary=static:{"ok":true}', "--props", '{"format":"usd"}', ], @@ -157,7 +142,11 @@ describe("workspace CLI", () => { { title: "Q3 Revenue", grid: { x: 0, y: 0, w: 4, h: 2 }, - bindings: { value: { source: "file", path: "q3.json", pointer: "/revenue" } }, + bindings: { + value: { source: "file", path: "q3.json", pointer: "/revenue" }, + rows: { source: "rpc", method: "sessions.list" }, + summary: { source: "static", value: { ok: true } }, + }, props: { format: "usd" }, }, ], @@ -173,6 +162,25 @@ describe("workspace CLI", () => { changedTabSlug: "finance", actor: "user", }); + + for (const binding of ["file:q3.json", "value=static:{bad", "value=command:date"]) { + await expect( + program.parseAsync( + [ + "workspaces", + "widgets", + "add", + "--tab", + "finance", + "--kind", + "builtin:stat-card", + "--binding", + binding, + ], + { from: "user" }, + ), + ).rejects.toThrow(); + } }); }); diff --git a/extensions/workspaces/src/cli.ts b/extensions/workspaces/src/cli.ts index e4a2a3d3052a..1b62c68f8840 100644 --- a/extensions/workspaces/src/cli.ts +++ b/extensions/workspaces/src/cli.ts @@ -67,7 +67,7 @@ function parseWorkspaceGrid(value: string): WorkspaceGrid { return { x, y, w, h }; } -export function parseWorkspaceBindingShorthand(value: string): [string, WorkspaceBinding] { +function parseWorkspaceBindingShorthand(value: string): [string, WorkspaceBinding] { const eqIndex = value.indexOf("="); if (eqIndex <= 0) { throw new Error("binding must be id=file:, id=rpc:, or id=static:"); diff --git a/extensions/workspaces/src/manifest.test.ts b/extensions/workspaces/src/manifest.test.ts index 8eb3bf7b0085..08271b724f6e 100644 --- a/extensions/workspaces/src/manifest.test.ts +++ b/extensions/workspaces/src/manifest.test.ts @@ -3,27 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { - loadWidgetManifest, - resolveWidgetDir, - snapshotApprovedWidget, - validateWidgetManifest, -} from "./manifest.js"; - -async function withTempStateDir(run: (stateDir: string) => Promise): Promise { - const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-manifest-")); - try { - return await run(stateDir); - } finally { - await fs.rm(stateDir, { recursive: true, force: true }); - } -} - -async function writeManifest(stateDir: string, name: string, manifest: unknown): Promise { - const dir = path.join(stateDir, "workspaces", "widgets", name); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(path.join(dir, "widget.json"), JSON.stringify(manifest)); -} +import { resolveWidgetDir, snapshotApprovedWidget, validateWidgetManifest } from "./manifest.js"; const VALID_MANIFEST = { schemaVersion: 1, @@ -142,39 +122,7 @@ describe("resolveWidgetDir", () => { }); }); -describe("loadWidgetManifest", () => { - it("loads and validates a manifest from disk", async () => { - await withTempStateDir(async (stateDir) => { - await writeManifest(stateDir, "revenue-chart", VALID_MANIFEST); - const manifest = await loadWidgetManifest("revenue-chart", { stateDir }); - expect(manifest?.name).toBe("revenue-chart"); - }); - }); - - it("returns null when the manifest is absent", async () => { - await withTempStateDir(async (stateDir) => { - expect(await loadWidgetManifest("missing", { stateDir })).toBeNull(); - }); - }); - - it("throws when the manifest name does not match its directory", async () => { - await withTempStateDir(async (stateDir) => { - await writeManifest(stateDir, "revenue-chart", { ...VALID_MANIFEST, name: "spoofed" }); - await expect(loadWidgetManifest("revenue-chart", { stateDir })).rejects.toThrow( - /does not match its directory/, - ); - }); - }); - - it("throws on invalid JSON", async () => { - await withTempStateDir(async (stateDir) => { - const dir = path.join(stateDir, "workspaces", "widgets", "broken"); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(path.join(dir, "widget.json"), "{ not json"); - await expect(loadWidgetManifest("broken", { stateDir })).rejects.toThrow(/not valid JSON/); - }); - }); - +describe("snapshotApprovedWidget", () => { it("parses the manifest from the same bytes it hashes", async () => { await withWidget(async ({ stateDir, widgetDir }) => { const snapshot = await snapshotApprovedWidget("demo", { stateDir }); diff --git a/extensions/workspaces/src/manifest.ts b/extensions/workspaces/src/manifest.ts index 9a3099eda483..8c79f4292f06 100644 --- a/extensions/workspaces/src/manifest.ts +++ b/extensions/workspaces/src/manifest.ts @@ -367,32 +367,3 @@ export function resolveWidgetDir(name: string, stateDir = resolveStateDir()): st } return widgetDir; } - -/** Loads and validates the `widget.json` for a named custom widget, or null if absent. */ -export async function loadWidgetManifest( - name: string, - options: { stateDir?: string } = {}, -): Promise { - const widgetDir = resolveWidgetDir(name, options.stateDir); - const manifestPath = path.join(widgetDir, "widget.json"); - let raw: string; - try { - const stat = await fs.stat(manifestPath); - if (!stat.isFile() || stat.size > MANIFEST_MAX_BYTES) { - return null; - } - raw = await fs.readFile(manifestPath, "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null; - } - throw error; - } - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (error) { - throw new Error("widget.json is not valid JSON", { cause: error }); - } - return validateWidgetManifest(parsed, name); -} diff --git a/extensions/workspaces/src/scaffold.ts b/extensions/workspaces/src/scaffold.ts index 58fcf0d560ca..986dcdc38f05 100644 --- a/extensions/workspaces/src/scaffold.ts +++ b/extensions/workspaces/src/scaffold.ts @@ -140,9 +140,8 @@ export async function scaffoldWorkspaceWidget( throw new Error("widget name is invalid"); } const title = scaffoldTitle(name, options.title); - // Validate what we are about to write: `loadWidgetManifest` enforces the same - // schema at mount time, and a scaffold that cannot load is worse than a clear - // error at creation time. + // Validate what we are about to write: the approval snapshot enforces the same + // schema, and a scaffold that cannot be approved is worse than a clear error. validateWidgetManifest(widgetManifest(name, title), name); await fs.mkdir(widgetsRoot, { recursive: true, mode: 0o700 }); try { diff --git a/extensions/workspaces/src/serve.test.ts b/extensions/workspaces/src/serve.test.ts index 8ff6ca91738c..f6e865ad3453 100644 --- a/extensions/workspaces/src/serve.test.ts +++ b/extensions/workspaces/src/serve.test.ts @@ -6,12 +6,7 @@ import path from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { snapshotApprovedWidget } from "./manifest.js"; -import { - parseWidgetRequestPath, - serveWidgetAsset, - WIDGET_CSP, - WIDGETS_ROUTE_PREFIX, -} from "./serve.js"; +import { serveWidgetAsset, WIDGETS_ROUTE_PREFIX } from "./serve.js"; import { WorkspaceStore } from "./store.js"; const execFileAsync = promisify(execFile); @@ -24,6 +19,10 @@ type CapturedResponse = { }; const PARSE_FRAME_TOKEN = "1111111111111111111111111111111111111111111"; +const EXPECTED_WIDGET_CSP = + "sandbox allow-scripts; default-src 'none'; script-src 'self' 'unsafe-inline'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; font-src 'self' data:; connect-src 'none'; frame-ancestors 'self'"; let activeFrameToken: string | null = null; /** Minimal ServerResponse stub capturing status, headers, and body. */ @@ -98,36 +97,6 @@ function urlFor(name: string, rest: string): string { return `${WIDGETS_ROUTE_PREFIX}/${activeFrameToken ?? PARSE_FRAME_TOKEN}/${name}/${rest}`; } -describe("parseWidgetRequestPath", () => { - it("returns null for a pathname not under the widgets prefix", () => { - expect(parseWidgetRequestPath("/plugins/other/x/y")).toBeNull(); - }); - - it("returns null when no logical path is present (name only)", () => { - expect( - parseWidgetRequestPath(`${WIDGETS_ROUTE_PREFIX}/${PARSE_FRAME_TOKEN}/revenue-chart`), - ).toBeNull(); - }); - - it("rejects a name failing the charset check", () => { - expect( - parseWidgetRequestPath(`${WIDGETS_ROUTE_PREFIX}/${PARSE_FRAME_TOKEN}/../etc/passwd`), - ).toBeNull(); - }); - - it("rejects an encoded traversal segment in the logical path", () => { - expect(parseWidgetRequestPath(urlFor("revenue-chart", "%2e%2e/secret"))).toBeNull(); - }); - - it("parses a valid name and logical path", () => { - expect(parseWidgetRequestPath(urlFor("revenue-chart", "assets/app.js"))).toEqual({ - frameToken: PARSE_FRAME_TOKEN, - name: "revenue-chart", - logicalPath: "assets/app.js", - }); - }); -}); - describe("serveWidgetAsset security jail", () => { it("serves an approved widget's index.html with strict headers", async () => { await withApprovedWidget(async ({ store, stateDir }) => { @@ -140,7 +109,7 @@ describe("serveWidgetAsset security jail", () => { expect(handled).toBe(true); expect(captured.statusCode).toBe(200); expect(captured.headers["content-type"]).toBe("text/html; charset=utf-8"); - expect(captured.headers["content-security-policy"]).toBe(WIDGET_CSP); + expect(captured.headers["content-security-policy"]).toBe(EXPECTED_WIDGET_CSP); // The response itself enforces the same opaque-origin sandbox as the // iframe, so opening this URL directly cannot regain same-origin access. expect(captured.headers["content-security-policy"]).toContain("sandbox allow-scripts"); @@ -225,7 +194,7 @@ describe("serveWidgetAsset security jail", () => { }); expect(captured.statusCode).toBe(200); expect(captured.headers["content-type"]).toBe("text/javascript; charset=utf-8"); - expect(captured.headers["content-security-policy"]).toBe(WIDGET_CSP); + expect(captured.headers["content-security-policy"]).toBe(EXPECTED_WIDGET_CSP); expect(captured.headers["x-content-type-options"]).toBe("nosniff"); }); }); @@ -264,7 +233,7 @@ describe("serveWidgetAsset security jail", () => { expect(captured.statusCode).toBe(404); // A 404 is still an attacker-influenced response from the widget origin, // so it MUST carry the same strict CSP as a 200 (invariant I1/I4). - expect(captured.headers["content-security-policy"]).toBe(WIDGET_CSP); + expect(captured.headers["content-security-policy"]).toBe(EXPECTED_WIDGET_CSP); expect(captured.headers["referrer-policy"]).toBe("no-referrer"); }); }); diff --git a/extensions/workspaces/src/serve.ts b/extensions/workspaces/src/serve.ts index 2c6c2bbdb60a..d2dca4166189 100644 --- a/extensions/workspaces/src/serve.ts +++ b/extensions/workspaces/src/serve.ts @@ -28,7 +28,7 @@ export const WIDGETS_ROUTE_PREFIX = "/plugins/workspaces/widgets"; // only by the Control UI. Custom code receives only static workspace values: a // sandboxed child can navigate itself, so privileged RPC/file data stays in the // trusted built-in renderers. -export const WIDGET_CSP = +const WIDGET_CSP = "sandbox allow-scripts; default-src 'none'; script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; font-src 'self' data:; connect-src 'none'; frame-ancestors 'self'"; @@ -96,7 +96,7 @@ function isWidgetRoutePath(pathname: string): boolean { * Returns null when the pathname is not under the prefix or is malformed. Each * segment is URL-decoded; a decode failure yields null (→ 404). */ -export function parseWidgetRequestPath( +function parseWidgetRequestPath( pathname: string, ): { frameToken: string; name: string; logicalPath: string } | null { const prefix = `${WIDGETS_ROUTE_PREFIX}/`; diff --git a/extensions/xai/src/responses-tool-shared.test.ts b/extensions/xai/src/responses-tool-shared.test.ts index 807326111c73..882ad8636de2 100644 --- a/extensions/xai/src/responses-tool-shared.test.ts +++ b/extensions/xai/src/responses-tool-shared.test.ts @@ -1,11 +1,16 @@ // Xai tests cover responses tool shared plugin behavior. import { describe, expect, it } from "vitest"; -import { testing } from "./responses-tool-shared.js"; +import { + buildXaiResponsesToolBody, + extractXaiWebSearchContent, + requireXaiResponseTextAndCitations, + requireXaiResponseTextCitationsAndInline, +} from "./responses-tool-shared.js"; describe("xai responses tool helpers", () => { it("builds the shared xAI Responses tool body", () => { expect( - testing.buildXaiResponsesToolBody({ + buildXaiResponsesToolBody({ model: "grok-4.3", inputText: "search for openclaw", tools: [{ type: "x_search" }], @@ -24,7 +29,7 @@ describe("xai responses tool helpers", () => { it("keeps custom model reasoning untouched while disabling response storage", () => { expect( - testing.buildXaiResponsesToolBody({ + buildXaiResponsesToolBody({ model: "grok-build-0.1", inputText: "run code", tools: [{ type: "code_interpreter" }], @@ -39,20 +44,23 @@ describe("xai responses tool helpers", () => { it("falls back to annotation citations when the API omits top-level citations", () => { expect( - testing.resolveXaiResponseTextAndCitations({ - output: [ - { - type: "message", - content: [ - { - type: "output_text", - text: "Found it", - annotations: [{ type: "url_citation", url: "https://example.com/a" }], - }, - ], - }, - ], - }), + requireXaiResponseTextAndCitations( + { + output: [ + { + type: "message", + content: [ + { + type: "output_text", + text: "Found it", + annotations: [{ type: "url_citation", url: "https://example.com/a" }], + }, + ], + }, + ], + }, + "xAI tool failed", + ), ).toEqual({ content: "Found it", citations: ["https://example.com/a"], @@ -61,7 +69,7 @@ describe("xai responses tool helpers", () => { it("ignores malformed output, content, and annotation entries", () => { expect( - testing.extractXaiWebSearchContent({ + extractXaiWebSearchContent({ output: [ null, { @@ -90,10 +98,13 @@ describe("xai responses tool helpers", () => { it("prefers explicit top-level citations when present", () => { expect( - testing.resolveXaiResponseTextAndCitations({ - output_text: "Done", - citations: ["https://example.com/b"], - }), + requireXaiResponseTextAndCitations( + { + output_text: "Done", + citations: ["https://example.com/b"], + }, + "xAI tool failed", + ), ).toEqual({ content: "Done", citations: ["https://example.com/b"], @@ -106,12 +117,12 @@ describe("xai responses tool helpers", () => { citations: ["https://example.com/b"], inline_citations: [{ start_index: 0, end_index: 4, url: "https://example.com/b" }], }; - expect(testing.resolveXaiResponseTextCitationsAndInline(data, true)).toEqual({ + expect(requireXaiResponseTextCitationsAndInline(data, "xAI tool failed", true)).toEqual({ content: "Done", citations: ["https://example.com/b"], inlineCitations: [{ start_index: 0, end_index: 4, url: "https://example.com/b" }], }); - expect(testing.resolveXaiResponseTextCitationsAndInline(data, false)).toEqual({ + expect(requireXaiResponseTextCitationsAndInline(data, "xAI tool failed", false)).toEqual({ content: "Done", citations: ["https://example.com/b"], inlineCitations: undefined, @@ -119,7 +130,7 @@ describe("xai responses tool helpers", () => { }); it("rejects successful Responses tool payloads without answer text", () => { - expect(() => testing.requireXaiResponseTextAndCitations({}, "xAI tool failed")).toThrow( + expect(() => requireXaiResponseTextAndCitations({}, "xAI tool failed")).toThrow( "xAI tool failed: malformed JSON response", ); }); diff --git a/extensions/xai/src/responses-tool-shared.ts b/extensions/xai/src/responses-tool-shared.ts index fb6ca79d31d7..2c565411a407 100644 --- a/extensions/xai/src/responses-tool-shared.ts +++ b/extensions/xai/src/responses-tool-shared.ts @@ -80,20 +80,6 @@ export function extractXaiWebSearchContent(data: XaiWebSearchResponse): { }; } -function resolveXaiResponseTextAndCitations(data: XaiWebSearchResponse): { - content: string; - citations: string[]; -} { - const { text, annotationCitations } = extractXaiWebSearchContent(data); - return { - content: text ?? "No response", - citations: - Array.isArray(data.citations) && data.citations.length > 0 - ? data.citations - : annotationCitations, - }; -} - export function requireXaiResponseTextAndCitations( data: XaiWebSearchResponse, label: string, @@ -114,25 +100,6 @@ export function requireXaiResponseTextAndCitations( }; } -function resolveXaiResponseTextCitationsAndInline( - data: XaiWebSearchResponse, - inlineCitationsEnabled: boolean, -): { - content: string; - citations: string[]; - inlineCitations?: XaiWebSearchResponse["inline_citations"]; -} { - const { content, citations } = resolveXaiResponseTextAndCitations(data); - return { - content, - citations, - inlineCitations: - inlineCitationsEnabled && Array.isArray(data.inline_citations) - ? data.inline_citations - : undefined, - }; -} - export function requireXaiResponseTextCitationsAndInline( data: XaiWebSearchResponse, label: string, @@ -152,15 +119,3 @@ export function requireXaiResponseTextCitationsAndInline( : undefined, }; } - -export const testing = { - buildXaiResponsesToolBody, - extractXaiWebSearchContent, - requireXaiResponseTextCitationsAndInline, - requireXaiResponseTextAndCitations, - resolveXaiResponseTextCitationsAndInline, - resolveXaiResponseTextAndCitations, - resolveXaiResponsesEndpoint, - XAI_RESPONSES_BASE_URL, - XAI_RESPONSES_ENDPOINT, -} as const; diff --git a/extensions/xai/src/web-search-shared.ts b/extensions/xai/src/web-search-shared.ts index af34158b9195..8541cd6aff82 100644 --- a/extensions/xai/src/web-search-shared.ts +++ b/extensions/xai/src/web-search-shared.ts @@ -81,7 +81,7 @@ function isAbortError(error: unknown): boolean { ); } -export function wrapXaiWebSearchError(error: unknown, timeoutSeconds: number): never { +function wrapXaiWebSearchError(error: unknown, timeoutSeconds: number): never { if (isAbortError(error)) { throw new Error( `xAI web search timed out after ${timeoutSeconds}s. Increase tools.web.search.timeoutSeconds if queries are complex.`, diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index b2d753c8ccda..a9fb8e9c9e71 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { buildXaiCatalogModels, resolveXaiCatalogEntry } from "./model-definitions.js"; import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js"; import { resolveFallbackXaiAuth } from "./src/tool-auth-shared.js"; -import { wrapXaiWebSearchError } from "./src/web-search-shared.js"; +import { requestXaiWebSearch } from "./src/web-search-shared.js"; import { testing } from "./test-api.js"; import { createXaiWebSearchProvider as createXaiWebSearchContractProvider } from "./web-search-contract-api.js"; import { createXaiWebSearchProvider } from "./web-search.js"; @@ -947,13 +947,23 @@ describe("xai web search config resolution", () => { expect(externalContent?.wrapped).toBe(true); }); - it("converts internal xAI timeout aborts into structured tool errors", () => { + it("converts internal xAI timeout aborts into structured tool errors", async () => { const abort = new DOMException("This operation was aborted", "AbortError"); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(abort)); + const request = () => + requestXaiWebSearch({ + query: "OpenClaw", + model: "grok-4.3", + apiKey: "xai-test-key", + endpoint: "https://api.x.ai/v1/responses", + timeoutSeconds: 60, + inlineCitations: false, + }); - expect(() => wrapXaiWebSearchError(abort, 60)).toThrow("xAI web search timed out after 60s"); + await expect(request()).rejects.toThrow("xAI web search timed out after 60s"); try { - wrapXaiWebSearchError(abort, 60); + await request(); } catch (error) { expect(error).toBeInstanceOf(Error); expect((error as Error).name).toBe("Error"); diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs index 33870af70cac..b889186881e9 100644 --- a/scripts/deadcode-exports.baseline.mjs +++ b/scripts/deadcode-exports.baseline.mjs @@ -3,9 +3,6 @@ // Do not add entries to avoid fixing new findings. export const KNIP_UNUSED_EXPORT_BASELINE = [ "extensions/canvas/src/host/a2ui.ts: createA2uiHttpRequestHandler", - "extensions/canvas/src/host/a2ui.ts: injectCanvasRuntime", - "extensions/canvas/src/host/a2ui.ts: isA2uiPath", - "extensions/canvas/src/host/server.ts: CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES", "extensions/codex/src/session-catalog.ts: CODEX_TERMINAL_RESUME_COMMAND", "extensions/codex/src/session-upstream-activity.ts: checkCodexUpstreamActivity (upstream)", "extensions/codex/src/session-upstream-activity.ts: classifyCodexUpstreamTurns", @@ -29,7 +26,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "extensions/file-transfer/src/shared/node-invoke-policy.ts: testing", "extensions/file-transfer/src/tools/dir-fetch-tool.ts: testing", "extensions/google-meet/src/transports/chrome.ts: testing", - "extensions/google/src/gemini-web-search-provider.ts: testing", "extensions/googlechat/src/approval-card-actions.ts: clearGoogleChatApprovalCardBindingsForTest", "extensions/googlechat/src/auth.ts: testing", "extensions/googlechat/src/google-auth.runtime.ts: __testing", @@ -66,7 +62,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "extensions/ollama/src/web-search-provider.ts: testing", "extensions/openshell/src/backend.ts: ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT", "extensions/openshell/src/backend.ts: PINNED_REMOTE_PATH_MUTATION_SCRIPT", - "extensions/qa-channel/src/inbound.ts: isHttpMediaUrl", "extensions/qa-lab/src/gateway-process-boundary.ts: __testing", "extensions/qa-lab/src/qa-agent-workspace.ts: __testing", "extensions/qa-lab/src/runtime-parity.ts: __testing", @@ -110,12 +105,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "extensions/voice-call/src/runtime-state.ts: clearVoiceCallStateRuntime", "extensions/whatsapp/src/auto-reply/monitor/group-gating.ts: resetGroupDropWarningsForTests", "extensions/workspaces/src/broadcast.ts: resetWorkspaceBroadcastForTest", - "extensions/workspaces/src/cli.ts: parseWorkspaceBindingShorthand", - "extensions/workspaces/src/manifest.ts: loadWidgetManifest", - "extensions/workspaces/src/serve.ts: parseWidgetRequestPath", - "extensions/workspaces/src/serve.ts: WIDGET_CSP", - "extensions/xai/src/responses-tool-shared.ts: testing", - "extensions/xai/src/web-search-shared.ts: wrapXaiWebSearchError", "extensions/zalo/src/monitor.ts: testing", "extensions/zalo/src/outbound-media.ts: clearHostedZaloMediaForTest", "extensions/zalouser/src/monitor.ts: __testing",