mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-27 17:21:12 +00:00
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:
committed by
GitHub
parent
11eb3b50a3
commit
7b9a72fc4f
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>");
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}/`;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user