refactor(extensions): trim small plugin exports (#107784)

* refactor(workspaces): privatize internal plugin surfaces

* refactor(canvas): trim internal host exports

* refactor(qa-channel): privatize media URL validation

* refactor(google): remove web search test facade

* refactor(xai): remove response helper test facades

* chore(deadcode): shrink extension export baseline
This commit is contained in:
Peter Steinberger
2026-07-14 14:38:08 -07:00
committed by GitHub
parent 11eb3b50a3
commit 7b9a72fc4f
21 changed files with 141 additions and 362 deletions

View File

@@ -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<string | null> | null = null;

View File

@@ -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<typeof import("./server.js").createCanvasHostHandler>[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<typeof createMockWatcherState>;
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<typeof import("ws")>("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);

View File

@@ -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 = {

View File

@@ -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",
);
});
});

View File

@@ -20,17 +20,6 @@ export function resolveGeminiConfig(searchConfig?: Record<string, unknown>): Gem
return isRecord(gemini) ? gemini : {};
}
export function resolveGeminiApiKey(
gemini?: GeminiConfig,
env: Record<string, string | undefined> = 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;
}

View File

@@ -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;

View File

@@ -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<OpenClawConfig["models"]>["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");
});
});

View File

@@ -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<typeof import("./bus-client.js")>();
@@ -66,16 +66,6 @@ function firstRunAssembledParams(runtime: ReturnType<typeof createPluginRuntimeM
return call[0];
}
describe("isHttpMediaUrl", () => {
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);

View File

@@ -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:";

View File

@@ -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();
}
});
});

View File

@@ -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:<path>, id=rpc:<method>, or id=static:<json>");

View File

@@ -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<T>(run: (stateDir: string) => Promise<T>): Promise<T> {
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<void> {
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 });

View File

@@ -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<WidgetManifest | null> {
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);
}

View File

@@ -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 {

View File

@@ -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");
});
});

View File

@@ -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}/`;

View File

@@ -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",
);
});

View File

@@ -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;

View File

@@ -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.`,

View File

@@ -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");

View File

@@ -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",