mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 02:10:46 +00:00
fix: harden generated surface pruning
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
profile: openclaw-check
|
||||
provider: aws
|
||||
class: beast
|
||||
class: standard
|
||||
capacity:
|
||||
market: spot
|
||||
strategy: most-available
|
||||
fallback: on-demand-after-120s
|
||||
hints: true
|
||||
regions:
|
||||
- eu-west-1
|
||||
- eu-west-2
|
||||
- eu-central-1
|
||||
- us-east-1
|
||||
- us-west-2
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox-hydrate.yml
|
||||
job: hydrate
|
||||
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -37,7 +37,7 @@ If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta
|
||||
|
||||
## Real behavior proof (required for external PRs)
|
||||
|
||||
External contributors must show after-fix evidence from a real OpenClaw setup. Unit tests, mocks, lint, typechecks, snapshots, and CI are supplemental only. Screenshots are encouraged even for CLI, console, text, or log changes; terminal screenshots and copied live output count.
|
||||
External contributors must show after-fix evidence from a real OpenClaw setup. Unit tests, mocks, lint, typechecks, snapshots, and CI are supplemental only. Screenshots are encouraged even for CLI, console, text, or log changes; terminal screenshots and copied live output count. Be mindful of private information like IP addresses, API keys, phone numbers, non-public endpoints, or other private details when providing evidence.
|
||||
|
||||
- Behavior or issue addressed:
|
||||
- Real environment tested:
|
||||
|
||||
@@ -717,8 +717,8 @@ export const registerTelegramHandlers = ({
|
||||
const groupAllowContext =
|
||||
params.groupAllowContext ??
|
||||
(await resolveTelegramGroupAllowFromContext({
|
||||
chatId: params.chatId,
|
||||
cfg,
|
||||
chatId: params.chatId,
|
||||
accountId,
|
||||
senderId: params.senderId,
|
||||
isGroup: params.isGroup,
|
||||
|
||||
@@ -493,8 +493,8 @@ async function resolveTelegramCommandAuth(params: {
|
||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
|
||||
chatId,
|
||||
cfg,
|
||||
chatId,
|
||||
accountId,
|
||||
senderId,
|
||||
isGroup,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveTelegramStreamMode } from "./bot/helpers.js";
|
||||
import { resolveTelegramGroupAllowFromContext, resolveTelegramStreamMode } from "./bot/helpers.js";
|
||||
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
||||
|
||||
describe("resolveTelegramStreamMode", () => {
|
||||
@@ -25,6 +25,35 @@ describe("resolveTelegramStreamMode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTelegramGroupAllowFromContext", () => {
|
||||
it("expands Telegram access groups before normalizing allowFrom entries", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
accessGroups: {
|
||||
maintainers: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
telegram: ["12345"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const context = await resolveTelegramGroupAllowFromContext({
|
||||
cfg,
|
||||
chatId: -100123,
|
||||
accountId: "default",
|
||||
senderId: "12345",
|
||||
isGroup: true,
|
||||
groupAllowFrom: ["accessGroup:maintainers"],
|
||||
readChannelAllowFromStore: async () => [],
|
||||
resolveTelegramGroupConfig: () => ({}),
|
||||
});
|
||||
|
||||
expect(context.effectiveGroupAllow.entries).toEqual(["12345"]);
|
||||
expect(context.effectiveGroupAllow.invalidEntries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTelegramDraftStreamingChunking", () => {
|
||||
it("uses smaller defaults than block streaming", () => {
|
||||
const chunking = resolveTelegramDraftStreamingChunking(undefined, "default");
|
||||
|
||||
@@ -170,8 +170,8 @@ export function withResolvedTelegramForumFlag<T extends { chat: object }>(
|
||||
}
|
||||
|
||||
export async function resolveTelegramGroupAllowFromContext(params: {
|
||||
chatId: string | number;
|
||||
cfg?: OpenClawConfig;
|
||||
chatId: string | number;
|
||||
accountId?: string;
|
||||
senderId?: string;
|
||||
isGroup?: boolean;
|
||||
|
||||
@@ -30,7 +30,9 @@ import { runRuntimePostBuild } from "./runtime-postbuild.mjs";
|
||||
export { isBuildRelevantRunNodePath, isRestartRelevantRunNodePath, runNodeWatchedPaths };
|
||||
|
||||
const buildScript = "scripts/tsdown-build.mjs";
|
||||
const bundledPluginAssetsScript = "scripts/bundled-plugin-assets.mjs";
|
||||
const compilerArgs = [buildScript, "--no-clean"];
|
||||
const bundledPluginAssetBuildArgs = [bundledPluginAssetsScript, "--phase", "build"];
|
||||
|
||||
const runtimePostBuildWatchedPaths = [
|
||||
"scripts/copy-bundled-plugin-metadata.mjs",
|
||||
@@ -1022,7 +1024,23 @@ export async function runNodeMain(params = {}) {
|
||||
`Building TypeScript (dist is stale: ${lockedBuildRequirement.reason} - ${formatBuildReason(lockedBuildRequirement.reason)}).`,
|
||||
deps,
|
||||
);
|
||||
logRunner("Building bundled plugin assets.", deps);
|
||||
const buildCmd = deps.execPath;
|
||||
const assetBuild = deps.spawn(buildCmd, bundledPluginAssetBuildArgs, {
|
||||
cwd: deps.cwd,
|
||||
env: deps.env,
|
||||
stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit",
|
||||
});
|
||||
pipeSpawnedOutput(assetBuild, deps);
|
||||
const assetBuildRes = await waitForSpawnedProcess(assetBuild, deps);
|
||||
const assetBuildInterruptedExitCode = getInterruptedSpawnExitCode(assetBuildRes);
|
||||
if (assetBuildInterruptedExitCode !== null) {
|
||||
return assetBuildInterruptedExitCode;
|
||||
}
|
||||
if (assetBuildRes.exitCode !== 0 && assetBuildRes.exitCode !== null) {
|
||||
return assetBuildRes.exitCode;
|
||||
}
|
||||
|
||||
const buildArgs = compilerArgs;
|
||||
const build = deps.spawn(buildCmd, buildArgs, {
|
||||
cwd: deps.cwd,
|
||||
|
||||
@@ -91,6 +91,27 @@ describe("plugin node capability helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("stores capabilities per plugin-owned surface scope", () => {
|
||||
const client = makeClient();
|
||||
setClientPluginNodeCapability({
|
||||
client,
|
||||
surface: { surface: "canvas", scopeKey: "canvas-plugin:canvas" },
|
||||
capability: "canvas-token",
|
||||
expiresAtMs: 100,
|
||||
});
|
||||
setClientPluginNodeCapability({
|
||||
client,
|
||||
surface: { surface: "canvas", scopeKey: "other-plugin:canvas" },
|
||||
capability: "other-token",
|
||||
expiresAtMs: 200,
|
||||
});
|
||||
|
||||
expect(client.pluginNodeCapabilities).toEqual({
|
||||
"canvas\u0000canvas-plugin:canvas": { capability: "canvas-token", expiresAtMs: 100 },
|
||||
"canvas\u0000other-plugin:canvas": { capability: "other-token", expiresAtMs: 200 },
|
||||
});
|
||||
});
|
||||
|
||||
test("indexes plugin capability surfaces with shortest ttl per surface", () => {
|
||||
expect(
|
||||
indexPluginNodeCapabilitySurfaces([
|
||||
@@ -164,6 +185,32 @@ describe("plugin node capability helpers", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("does not authorize the same surface token for a different plugin scope", () => {
|
||||
const client = makeClient({
|
||||
pluginNodeCapabilities: {
|
||||
"canvas\u0000canvas-plugin:canvas": { capability: "canvas-token", expiresAtMs: 1_500 },
|
||||
},
|
||||
});
|
||||
const clients = new Set([client]);
|
||||
|
||||
expect(
|
||||
hasAuthorizedPluginNodeCapability({
|
||||
clients,
|
||||
surface: { surface: "canvas", scopeKey: "other-plugin:canvas" },
|
||||
capability: "canvas-token",
|
||||
nowMs: 1_000,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasAuthorizedPluginNodeCapability({
|
||||
clients,
|
||||
surface: { surface: "canvas", scopeKey: "canvas-plugin:canvas", ttlMs: 100 },
|
||||
capability: "canvas-token",
|
||||
nowMs: 1_000,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects expired capabilities", () => {
|
||||
const client = makeClient({
|
||||
pluginNodeCapabilities: {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const DEFAULT_PLUGIN_NODE_CAPABILITY_TTL_MS = 10 * 60_000;
|
||||
export type PluginNodeCapabilitySurface = {
|
||||
surface: string;
|
||||
ttlMs?: number;
|
||||
scopeKey?: string;
|
||||
};
|
||||
|
||||
export type PluginNodeCapabilityClient = {
|
||||
@@ -56,6 +57,15 @@ function normalizeSurface(raw: string | undefined) {
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function resolvePluginNodeCapabilityStorageKey(surface: PluginNodeCapabilitySurface) {
|
||||
const normalizedSurface = normalizeSurface(surface.surface);
|
||||
if (!normalizedSurface) {
|
||||
return undefined;
|
||||
}
|
||||
const scopeKey = surface.scopeKey?.trim();
|
||||
return scopeKey ? `${normalizedSurface}\0${scopeKey}` : normalizedSurface;
|
||||
}
|
||||
|
||||
export function resolvePluginNodeCapabilityTtlMs(surface: PluginNodeCapabilitySurface) {
|
||||
return surface.ttlMs && surface.ttlMs > 0 ? surface.ttlMs : DEFAULT_PLUGIN_NODE_CAPABILITY_TTL_MS;
|
||||
}
|
||||
@@ -175,11 +185,12 @@ export function setClientPluginNodeCapability(params: {
|
||||
expiresAtMs: number;
|
||||
}) {
|
||||
const surface = normalizeSurface(params.surface.surface);
|
||||
if (!surface) {
|
||||
const storageKey = resolvePluginNodeCapabilityStorageKey(params.surface);
|
||||
if (!surface || !storageKey) {
|
||||
return;
|
||||
}
|
||||
params.client.pluginNodeCapabilities ??= {};
|
||||
params.client.pluginNodeCapabilities[surface] = {
|
||||
params.client.pluginNodeCapabilities[storageKey] = {
|
||||
capability: params.capability,
|
||||
expiresAtMs: params.expiresAtMs,
|
||||
};
|
||||
@@ -236,13 +247,14 @@ export function hasAuthorizedPluginNodeCapability(params: {
|
||||
nowMs?: number;
|
||||
}) {
|
||||
const surface = normalizeSurface(params.surface.surface);
|
||||
if (!surface) {
|
||||
const storageKey = resolvePluginNodeCapabilityStorageKey(params.surface);
|
||||
if (!surface || !storageKey) {
|
||||
return false;
|
||||
}
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
const ttlMs = resolvePluginNodeCapabilityTtlMs(params.surface);
|
||||
for (const client of params.clients) {
|
||||
const entry = client.pluginNodeCapabilities?.[surface];
|
||||
const entry = client.pluginNodeCapabilities?.[storageKey];
|
||||
if (!entry || entry.expiresAtMs <= nowMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -805,6 +805,7 @@ export function attachGatewayUpgradeHandler(opts: {
|
||||
httpServer: HttpServer;
|
||||
wss: WebSocketServer;
|
||||
handlePluginUpgrade?: PluginHttpUpgradeHandler;
|
||||
shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean;
|
||||
resolvePluginNodeCapabilityRoute?: ResolvePluginNodeCapabilityRoute;
|
||||
clients: Set<GatewayWsClient>;
|
||||
preauthConnectionBudget: PreauthConnectionBudget;
|
||||
@@ -819,6 +820,7 @@ export function attachGatewayUpgradeHandler(opts: {
|
||||
httpServer,
|
||||
wss,
|
||||
handlePluginUpgrade,
|
||||
shouldEnforcePluginGatewayAuth,
|
||||
resolvePluginNodeCapabilityRoute,
|
||||
clients,
|
||||
preauthConnectionBudget,
|
||||
@@ -865,9 +867,44 @@ export function attachGatewayUpgradeHandler(opts: {
|
||||
}
|
||||
}
|
||||
if (handlePluginUpgrade) {
|
||||
let pluginGatewayAuthSatisfied = false;
|
||||
let pluginGatewayRequestAuth: AuthorizedGatewayHttpRequest | undefined;
|
||||
let pluginGatewayRequestOperatorScopes: string[] | undefined;
|
||||
const enforcePluginGatewayAuth = (
|
||||
shouldEnforcePluginGatewayAuth ?? shouldEnforceDefaultPluginGatewayAuth
|
||||
)(pathContext);
|
||||
if (
|
||||
enforcePluginGatewayAuth &&
|
||||
!(await getCachedPluginGatewayAuthBypassPaths(configSnapshot)).has(url.pathname)
|
||||
) {
|
||||
const { checkGatewayHttpRequestAuth } = await getHttpAuthUtilsModule();
|
||||
const authCheck = await checkGatewayHttpRequestAuth({
|
||||
req,
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
cfg: configSnapshot,
|
||||
});
|
||||
if (!authCheck.ok) {
|
||||
writeUpgradeAuthFailure(socket, authCheck.authResult);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
pluginGatewayAuthSatisfied = true;
|
||||
pluginGatewayRequestAuth = authCheck.requestAuth;
|
||||
const { resolvePluginRouteRuntimeOperatorScopes } =
|
||||
await getPluginRouteRuntimeScopesModule();
|
||||
pluginGatewayRequestOperatorScopes = resolvePluginRouteRuntimeOperatorScopes(
|
||||
req,
|
||||
authCheck.requestAuth,
|
||||
);
|
||||
}
|
||||
if (
|
||||
await handlePluginUpgrade(req, socket, head, pathContext, {
|
||||
gatewayAuthSatisfied: false,
|
||||
gatewayAuthSatisfied: pluginGatewayAuthSatisfied,
|
||||
gatewayRequestAuth: pluginGatewayRequestAuth,
|
||||
gatewayRequestOperatorScopes: pluginGatewayRequestOperatorScopes,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -254,6 +254,7 @@ export async function createGatewayRuntimeState(params: {
|
||||
httpServer,
|
||||
wss,
|
||||
handlePluginUpgrade,
|
||||
shouldEnforcePluginGatewayAuth,
|
||||
resolvePluginNodeCapabilityRoute,
|
||||
clients,
|
||||
preauthConnectionBudget,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerPluginHttpRoute } from "../../plugins/http-registry.js";
|
||||
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
|
||||
@@ -11,6 +12,7 @@ import { getPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gatew
|
||||
import { makeMockHttpResponse } from "../test-http-response.js";
|
||||
import { createTestRegistry } from "./__tests__/test-utils.js";
|
||||
import {
|
||||
createGatewayPluginUpgradeHandler,
|
||||
createGatewayPluginRequestHandler,
|
||||
isRegisteredPluginHttpRoutePath,
|
||||
shouldEnforceGatewayAuthForPluginPath,
|
||||
@@ -28,6 +30,11 @@ function createRoute(params: {
|
||||
auth?: "gateway" | "plugin";
|
||||
match?: "exact" | "prefix";
|
||||
handler?: (req: IncomingMessage, res: ServerResponse) => boolean | void | Promise<boolean | void>;
|
||||
handleUpgrade?: (
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
) => boolean | void | Promise<boolean | void>;
|
||||
}) {
|
||||
return {
|
||||
pluginId: params.pluginId ?? "route",
|
||||
@@ -35,10 +42,25 @@ function createRoute(params: {
|
||||
auth: params.auth ?? "plugin",
|
||||
match: params.match ?? "exact",
|
||||
handler: params.handler ?? (() => {}),
|
||||
handleUpgrade: params.handleUpgrade,
|
||||
source: params.pluginId ?? "route",
|
||||
};
|
||||
}
|
||||
|
||||
function createMockUpgradeSocket() {
|
||||
const socket = {
|
||||
chunks: [] as string[],
|
||||
destroyed: false,
|
||||
write(chunk: string) {
|
||||
socket.chunks.push(chunk);
|
||||
},
|
||||
destroy() {
|
||||
socket.destroyed = true;
|
||||
},
|
||||
} as unknown as Duplex & { chunks: string[]; destroyed: boolean };
|
||||
return socket;
|
||||
}
|
||||
|
||||
function buildRepeatedEncodedSlash(depth: number): string {
|
||||
let encodedSlash = "%2f";
|
||||
for (let i = 1; i < depth; i++) {
|
||||
@@ -393,6 +415,73 @@ describe("createGatewayPluginRequestHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createGatewayPluginUpgradeHandler", () => {
|
||||
afterEach(() => {
|
||||
releasePinnedPluginHttpRouteRegistry();
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
});
|
||||
|
||||
it("claims and rejects matched gateway upgrades when auth was not satisfied", async () => {
|
||||
const routeUpgradeHandler = vi.fn(async () => true);
|
||||
const handler = createGatewayPluginUpgradeHandler({
|
||||
registry: createTestRegistry({
|
||||
httpRoutes: [
|
||||
createRoute({
|
||||
path: "/__openclaw__/canvas/ws",
|
||||
auth: "gateway",
|
||||
handleUpgrade: routeUpgradeHandler,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
log: createPluginLog(),
|
||||
});
|
||||
const socket = createMockUpgradeSocket();
|
||||
|
||||
const handled = await handler(
|
||||
{ url: "/__openclaw__/canvas/ws" } as IncomingMessage,
|
||||
socket,
|
||||
Buffer.alloc(0),
|
||||
undefined,
|
||||
{ gatewayAuthSatisfied: false },
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(routeUpgradeHandler).not.toHaveBeenCalled();
|
||||
expect(socket.destroyed).toBe(true);
|
||||
expect(socket.chunks.join("")).toContain("HTTP/1.1 401 Unauthorized");
|
||||
});
|
||||
|
||||
it("dispatches gateway upgrades after gateway auth succeeds", async () => {
|
||||
const routeUpgradeHandler = vi.fn(async () => true);
|
||||
const handler = createGatewayPluginUpgradeHandler({
|
||||
registry: createTestRegistry({
|
||||
httpRoutes: [
|
||||
createRoute({
|
||||
path: "/__openclaw__/canvas/ws",
|
||||
auth: "gateway",
|
||||
handleUpgrade: routeUpgradeHandler,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
log: createPluginLog(),
|
||||
});
|
||||
const socket = createMockUpgradeSocket();
|
||||
|
||||
const handled = await handler(
|
||||
{ url: "/__openclaw__/canvas/ws" } as IncomingMessage,
|
||||
socket,
|
||||
Buffer.alloc(0),
|
||||
undefined,
|
||||
{ gatewayAuthSatisfied: true, gatewayRequestOperatorScopes: ["operator.read"] },
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(routeUpgradeHandler).toHaveBeenCalledTimes(1);
|
||||
expect(socket.destroyed).toBe(false);
|
||||
expect(socket.chunks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin HTTP route auth checks", () => {
|
||||
const deeplyEncodedChannelPath =
|
||||
"/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile";
|
||||
|
||||
@@ -48,6 +48,11 @@ function createPluginRouteRuntimeClient(
|
||||
};
|
||||
}
|
||||
|
||||
function writeUpgradeUnauthorized(socket: Duplex) {
|
||||
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
export type PluginRouteDispatchContext = {
|
||||
gatewayAuthSatisfied?: boolean;
|
||||
gatewayRequestAuth?: AuthorizedGatewayHttpRequest;
|
||||
@@ -189,7 +194,8 @@ export function createGatewayPluginUpgradeHandler(params: {
|
||||
const requiresGatewayAuth = matchedPluginRoutesRequireGatewayAuth(matchedRoutes);
|
||||
if (requiresGatewayAuth && dispatchContext?.gatewayAuthSatisfied !== true) {
|
||||
log.warn(`plugin http upgrade blocked without gateway auth (${pathContext.canonicalPath})`);
|
||||
return false;
|
||||
writeUpgradeUnauthorized(socket);
|
||||
return true;
|
||||
}
|
||||
const gatewayRequestAuth = dispatchContext?.gatewayRequestAuth;
|
||||
const gatewayRequestOperatorScopes = dispatchContext?.gatewayRequestOperatorScopes;
|
||||
@@ -203,7 +209,8 @@ export function createGatewayPluginUpgradeHandler(params: {
|
||||
log.warn(
|
||||
`plugin http upgrade blocked without caller auth context (${pathContext.canonicalPath})`,
|
||||
);
|
||||
return false;
|
||||
writeUpgradeUnauthorized(socket);
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -211,7 +218,8 @@ export function createGatewayPluginUpgradeHandler(params: {
|
||||
log.warn(
|
||||
`plugin http upgrade blocked without caller scope context (${pathContext.canonicalPath})`,
|
||||
);
|
||||
return false;
|
||||
writeUpgradeUnauthorized(socket);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginRegistry } from "../../../plugins/registry.js";
|
||||
import { listPluginNodeCapabilities } from "./route-capability.js";
|
||||
import { resolvePluginRoutePathContext } from "./path-context.js";
|
||||
import {
|
||||
findMatchingPluginNodeCapabilityRoute,
|
||||
listPluginNodeCapabilities,
|
||||
} from "./route-capability.js";
|
||||
|
||||
describe("plugin node capability route metadata", () => {
|
||||
it("lists one capability per surface with the shortest ttl", () => {
|
||||
@@ -13,8 +17,27 @@ describe("plugin node capability route metadata", () => {
|
||||
} as unknown as PluginRegistry;
|
||||
|
||||
expect(listPluginNodeCapabilities(registry)).toEqual([
|
||||
{ surface: "canvas", ttlMs: 100 },
|
||||
{ surface: "files", ttlMs: 200 },
|
||||
{ surface: "canvas", ttlMs: 100, scopeKey: "two:canvas" },
|
||||
{ surface: "files", ttlMs: 200, scopeKey: "files:files" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds plugin ownership to matched capability route metadata", () => {
|
||||
const registry = {
|
||||
httpRoutes: [
|
||||
{
|
||||
pluginId: "canvas-plugin",
|
||||
path: "/__openclaw__/canvas/ws",
|
||||
nodeCapability: { surface: "canvas" },
|
||||
},
|
||||
],
|
||||
} as unknown as PluginRegistry;
|
||||
|
||||
expect(
|
||||
findMatchingPluginNodeCapabilityRoute(
|
||||
registry,
|
||||
resolvePluginRoutePathContext("/__openclaw__/canvas/ws"),
|
||||
)?.nodeCapability,
|
||||
).toEqual({ surface: "canvas", scopeKey: "canvas-plugin:canvas" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,11 +16,28 @@ function hasNodeCapabilityRoute(route: PluginHttpRouteEntry): route is PluginNod
|
||||
return Boolean(route.nodeCapability?.surface?.trim());
|
||||
}
|
||||
|
||||
function resolvePluginNodeCapabilityRouteSurface(
|
||||
route: PluginNodeCapabilityRoute,
|
||||
): PluginNodeCapabilitySurface {
|
||||
const surface = route.nodeCapability.surface.trim();
|
||||
const owner = route.pluginId?.trim() || route.source?.trim();
|
||||
return {
|
||||
...route.nodeCapability,
|
||||
surface,
|
||||
...(owner ? { scopeKey: `${owner}:${surface}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function findMatchingPluginNodeCapabilityRoutes(
|
||||
registry: PluginRegistry,
|
||||
context: PluginRoutePathContext,
|
||||
): PluginNodeCapabilityRoute[] {
|
||||
return findMatchingPluginHttpRoutes(registry, context).filter(hasNodeCapabilityRoute);
|
||||
return findMatchingPluginHttpRoutes(registry, context)
|
||||
.filter(hasNodeCapabilityRoute)
|
||||
.map((route) => ({
|
||||
...route,
|
||||
nodeCapability: resolvePluginNodeCapabilityRouteSurface(route),
|
||||
}));
|
||||
}
|
||||
|
||||
export function findMatchingPluginNodeCapabilityRoute(
|
||||
@@ -41,7 +58,7 @@ export function listPluginNodeCapabilities(
|
||||
for (const route of registry.httpRoutes ?? []) {
|
||||
const surface = route.nodeCapability?.surface?.trim();
|
||||
if (surface) {
|
||||
const next = { ...route.nodeCapability, surface };
|
||||
const next = resolvePluginNodeCapabilityRouteSurface(route as PluginNodeCapabilityRoute);
|
||||
const existing = surfaces.get(surface);
|
||||
if (!existing || resolveTtlMs(next) < resolveTtlMs(existing)) {
|
||||
surfaces.set(surface, next);
|
||||
|
||||
@@ -107,6 +107,10 @@ function expectedBuildSpawn() {
|
||||
return [process.execPath, "scripts/tsdown-build.mjs", "--no-clean"];
|
||||
}
|
||||
|
||||
function expectedBundledPluginAssetBuildSpawn() {
|
||||
return [process.execPath, "scripts/bundled-plugin-assets.mjs", "--phase", "build"];
|
||||
}
|
||||
|
||||
function statusCommandSpawn() {
|
||||
return [process.execPath, "openclaw.mjs", "status"];
|
||||
}
|
||||
@@ -341,6 +345,7 @@ describe("run-node script", () => {
|
||||
);
|
||||
await expect(fs.readFile(indexPath, "utf-8")).resolves.toContain("sentinel");
|
||||
expect(nodeCalls).toEqual([
|
||||
[process.execPath, "scripts/bundled-plugin-assets.mjs", "--phase", "build"],
|
||||
[process.execPath, "scripts/tsdown-build.mjs", "--no-clean"],
|
||||
[process.execPath, "openclaw.mjs", "--version"],
|
||||
]);
|
||||
@@ -379,7 +384,11 @@ describe("run-node script", () => {
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawnCalls).toEqual([expectedBuildSpawn(), statusCommandSpawn()]);
|
||||
expect(spawnCalls).toEqual([
|
||||
expectedBundledPluginAssetBuildSpawn(),
|
||||
expectedBuildSpawn(),
|
||||
statusCommandSpawn(),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readFile(resolvePath(tmp, "dist/plugin-sdk/root-alias.cjs"), "utf-8"),
|
||||
@@ -736,6 +745,7 @@ describe("run-node script", () => {
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawnCalls).toEqual([
|
||||
expectedBundledPluginAssetBuildSpawn(),
|
||||
expectedBuildSpawn(),
|
||||
[
|
||||
process.execPath,
|
||||
@@ -1223,7 +1233,11 @@ describe("run-node script", () => {
|
||||
const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawnCalls).toEqual([expectedBuildSpawn(), statusCommandSpawn()]);
|
||||
expect(spawnCalls).toEqual([
|
||||
expectedBundledPluginAssetBuildSpawn(),
|
||||
expectedBuildSpawn(),
|
||||
statusCommandSpawn(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1244,7 +1258,11 @@ describe("run-node script", () => {
|
||||
const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawnCalls).toEqual([expectedBuildSpawn(), statusCommandSpawn()]);
|
||||
expect(spawnCalls).toEqual([
|
||||
expectedBundledPluginAssetBuildSpawn(),
|
||||
expectedBuildSpawn(),
|
||||
statusCommandSpawn(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1609,7 +1627,11 @@ describe("run-node script", () => {
|
||||
const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawnCalls).toEqual([expectedBuildSpawn(), statusCommandSpawn()]);
|
||||
expect(spawnCalls).toEqual([
|
||||
expectedBundledPluginAssetBuildSpawn(),
|
||||
expectedBuildSpawn(),
|
||||
statusCommandSpawn(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -177,6 +177,36 @@ describe("loadWebMedia", () => {
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps trying hosted media resolvers after one throws", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.hostedMediaResolvers = [
|
||||
{
|
||||
pluginId: "broken",
|
||||
resolver: () => {
|
||||
throw new Error("resolver failed");
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "canvas",
|
||||
resolver: (mediaUrl) =>
|
||||
mediaUrl === `${CANVAS_HOST_PATH}/documents/cv_test/collection.media/tiny.png`
|
||||
? canvasPngFile
|
||||
: null,
|
||||
source: "test",
|
||||
},
|
||||
];
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const result = await loadWebMedia(
|
||||
`${CANVAS_HOST_PATH}/documents/cv_test/collection.media/tiny.png`,
|
||||
{ maxBytes: 1024 * 1024 },
|
||||
);
|
||||
|
||||
expect(result.kind).toBe("image");
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("includes resize failure details when image optimization cannot produce a JPEG", async () => {
|
||||
await expect(optimizeImageToJpeg(Buffer.from("not an image"), 8)).rejects.toThrow(
|
||||
/Failed to optimize image: .+/,
|
||||
|
||||
@@ -75,9 +75,17 @@ async function resolveMediaStoreUriToPath(mediaUrl: string): Promise<string | nu
|
||||
async function resolveHostedPluginMediaUrl(mediaUrl: string): Promise<string | null> {
|
||||
const registry = getActivePluginRegistry();
|
||||
for (const entry of registry?.hostedMediaResolvers ?? []) {
|
||||
const resolved = await entry.resolver(mediaUrl);
|
||||
if (typeof resolved === "string" && resolved.trim()) {
|
||||
return resolved;
|
||||
try {
|
||||
const resolved = await entry.resolver(mediaUrl);
|
||||
if (typeof resolved === "string" && resolved.trim()) {
|
||||
return resolved;
|
||||
}
|
||||
} catch (err) {
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`Hosted media resolver failed (${entry.pluginId ?? "unknown"}): ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user