fix: harden generated surface pruning

This commit is contained in:
Peter Steinberger
2026-05-07 06:41:39 +01:00
parent 23920f6160
commit bece8dcbb8
18 changed files with 372 additions and 26 deletions

View File

@@ -1,12 +1,17 @@
profile: openclaw-check profile: openclaw-check
provider: aws provider: aws
class: beast class: standard
capacity: capacity:
market: spot market: spot
strategy: most-available strategy: most-available
fallback: on-demand-after-120s fallback: on-demand-after-120s
hints: true
regions: regions:
- eu-west-1 - eu-west-1
- eu-west-2
- eu-central-1
- us-east-1
- us-west-2
actions: actions:
workflow: .github/workflows/crabbox-hydrate.yml workflow: .github/workflows/crabbox-hydrate.yml
job: hydrate job: hydrate

View File

@@ -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) ## 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: - Behavior or issue addressed:
- Real environment tested: - Real environment tested:

View File

@@ -717,8 +717,8 @@ export const registerTelegramHandlers = ({
const groupAllowContext = const groupAllowContext =
params.groupAllowContext ?? params.groupAllowContext ??
(await resolveTelegramGroupAllowFromContext({ (await resolveTelegramGroupAllowFromContext({
chatId: params.chatId,
cfg, cfg,
chatId: params.chatId,
accountId, accountId,
senderId: params.senderId, senderId: params.senderId,
isGroup: params.isGroup, isGroup: params.isGroup,

View File

@@ -493,8 +493,8 @@ async function resolveTelegramCommandAuth(params: {
const senderId = msg.from?.id ? String(msg.from.id) : ""; const senderId = msg.from?.id ? String(msg.from.id) : "";
const senderUsername = msg.from?.username ?? ""; const senderUsername = msg.from?.username ?? "";
const groupAllowContext = await resolveTelegramGroupAllowFromContext({ const groupAllowContext = await resolveTelegramGroupAllowFromContext({
chatId,
cfg, cfg,
chatId,
accountId, accountId,
senderId, senderId,
isGroup, isGroup,

View File

@@ -1,6 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect, it } from "vitest"; 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"; import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
describe("resolveTelegramStreamMode", () => { 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", () => { describe("resolveTelegramDraftStreamingChunking", () => {
it("uses smaller defaults than block streaming", () => { it("uses smaller defaults than block streaming", () => {
const chunking = resolveTelegramDraftStreamingChunking(undefined, "default"); const chunking = resolveTelegramDraftStreamingChunking(undefined, "default");

View File

@@ -170,8 +170,8 @@ export function withResolvedTelegramForumFlag<T extends { chat: object }>(
} }
export async function resolveTelegramGroupAllowFromContext(params: { export async function resolveTelegramGroupAllowFromContext(params: {
chatId: string | number;
cfg?: OpenClawConfig; cfg?: OpenClawConfig;
chatId: string | number;
accountId?: string; accountId?: string;
senderId?: string; senderId?: string;
isGroup?: boolean; isGroup?: boolean;

View File

@@ -30,7 +30,9 @@ import { runRuntimePostBuild } from "./runtime-postbuild.mjs";
export { isBuildRelevantRunNodePath, isRestartRelevantRunNodePath, runNodeWatchedPaths }; export { isBuildRelevantRunNodePath, isRestartRelevantRunNodePath, runNodeWatchedPaths };
const buildScript = "scripts/tsdown-build.mjs"; const buildScript = "scripts/tsdown-build.mjs";
const bundledPluginAssetsScript = "scripts/bundled-plugin-assets.mjs";
const compilerArgs = [buildScript, "--no-clean"]; const compilerArgs = [buildScript, "--no-clean"];
const bundledPluginAssetBuildArgs = [bundledPluginAssetsScript, "--phase", "build"];
const runtimePostBuildWatchedPaths = [ const runtimePostBuildWatchedPaths = [
"scripts/copy-bundled-plugin-metadata.mjs", "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)}).`, `Building TypeScript (dist is stale: ${lockedBuildRequirement.reason} - ${formatBuildReason(lockedBuildRequirement.reason)}).`,
deps, deps,
); );
logRunner("Building bundled plugin assets.", deps);
const buildCmd = deps.execPath; 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 buildArgs = compilerArgs;
const build = deps.spawn(buildCmd, buildArgs, { const build = deps.spawn(buildCmd, buildArgs, {
cwd: deps.cwd, cwd: deps.cwd,

View File

@@ -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", () => { test("indexes plugin capability surfaces with shortest ttl per surface", () => {
expect( expect(
indexPluginNodeCapabilitySurfaces([ indexPluginNodeCapabilitySurfaces([
@@ -164,6 +185,32 @@ describe("plugin node capability helpers", () => {
).toBe(false); ).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", () => { test("rejects expired capabilities", () => {
const client = makeClient({ const client = makeClient({
pluginNodeCapabilities: { pluginNodeCapabilities: {

View File

@@ -9,6 +9,7 @@ export const DEFAULT_PLUGIN_NODE_CAPABILITY_TTL_MS = 10 * 60_000;
export type PluginNodeCapabilitySurface = { export type PluginNodeCapabilitySurface = {
surface: string; surface: string;
ttlMs?: number; ttlMs?: number;
scopeKey?: string;
}; };
export type PluginNodeCapabilityClient = { export type PluginNodeCapabilityClient = {
@@ -56,6 +57,15 @@ function normalizeSurface(raw: string | undefined) {
return trimmed ? trimmed : 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) { export function resolvePluginNodeCapabilityTtlMs(surface: PluginNodeCapabilitySurface) {
return surface.ttlMs && surface.ttlMs > 0 ? surface.ttlMs : DEFAULT_PLUGIN_NODE_CAPABILITY_TTL_MS; return surface.ttlMs && surface.ttlMs > 0 ? surface.ttlMs : DEFAULT_PLUGIN_NODE_CAPABILITY_TTL_MS;
} }
@@ -175,11 +185,12 @@ export function setClientPluginNodeCapability(params: {
expiresAtMs: number; expiresAtMs: number;
}) { }) {
const surface = normalizeSurface(params.surface.surface); const surface = normalizeSurface(params.surface.surface);
if (!surface) { const storageKey = resolvePluginNodeCapabilityStorageKey(params.surface);
if (!surface || !storageKey) {
return; return;
} }
params.client.pluginNodeCapabilities ??= {}; params.client.pluginNodeCapabilities ??= {};
params.client.pluginNodeCapabilities[surface] = { params.client.pluginNodeCapabilities[storageKey] = {
capability: params.capability, capability: params.capability,
expiresAtMs: params.expiresAtMs, expiresAtMs: params.expiresAtMs,
}; };
@@ -236,13 +247,14 @@ export function hasAuthorizedPluginNodeCapability(params: {
nowMs?: number; nowMs?: number;
}) { }) {
const surface = normalizeSurface(params.surface.surface); const surface = normalizeSurface(params.surface.surface);
if (!surface) { const storageKey = resolvePluginNodeCapabilityStorageKey(params.surface);
if (!surface || !storageKey) {
return false; return false;
} }
const nowMs = params.nowMs ?? Date.now(); const nowMs = params.nowMs ?? Date.now();
const ttlMs = resolvePluginNodeCapabilityTtlMs(params.surface); const ttlMs = resolvePluginNodeCapabilityTtlMs(params.surface);
for (const client of params.clients) { for (const client of params.clients) {
const entry = client.pluginNodeCapabilities?.[surface]; const entry = client.pluginNodeCapabilities?.[storageKey];
if (!entry || entry.expiresAtMs <= nowMs) { if (!entry || entry.expiresAtMs <= nowMs) {
continue; continue;
} }

View File

@@ -805,6 +805,7 @@ export function attachGatewayUpgradeHandler(opts: {
httpServer: HttpServer; httpServer: HttpServer;
wss: WebSocketServer; wss: WebSocketServer;
handlePluginUpgrade?: PluginHttpUpgradeHandler; handlePluginUpgrade?: PluginHttpUpgradeHandler;
shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean;
resolvePluginNodeCapabilityRoute?: ResolvePluginNodeCapabilityRoute; resolvePluginNodeCapabilityRoute?: ResolvePluginNodeCapabilityRoute;
clients: Set<GatewayWsClient>; clients: Set<GatewayWsClient>;
preauthConnectionBudget: PreauthConnectionBudget; preauthConnectionBudget: PreauthConnectionBudget;
@@ -819,6 +820,7 @@ export function attachGatewayUpgradeHandler(opts: {
httpServer, httpServer,
wss, wss,
handlePluginUpgrade, handlePluginUpgrade,
shouldEnforcePluginGatewayAuth,
resolvePluginNodeCapabilityRoute, resolvePluginNodeCapabilityRoute,
clients, clients,
preauthConnectionBudget, preauthConnectionBudget,
@@ -865,9 +867,44 @@ export function attachGatewayUpgradeHandler(opts: {
} }
} }
if (handlePluginUpgrade) { 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 ( if (
await handlePluginUpgrade(req, socket, head, pathContext, { await handlePluginUpgrade(req, socket, head, pathContext, {
gatewayAuthSatisfied: false, gatewayAuthSatisfied: pluginGatewayAuthSatisfied,
gatewayRequestAuth: pluginGatewayRequestAuth,
gatewayRequestOperatorScopes: pluginGatewayRequestOperatorScopes,
}) })
) { ) {
return; return;

View File

@@ -254,6 +254,7 @@ export async function createGatewayRuntimeState(params: {
httpServer, httpServer,
wss, wss,
handlePluginUpgrade, handlePluginUpgrade,
shouldEnforcePluginGatewayAuth,
resolvePluginNodeCapabilityRoute, resolvePluginNodeCapabilityRoute,
clients, clients,
preauthConnectionBudget, preauthConnectionBudget,

View File

@@ -1,4 +1,5 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import type { Duplex } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { registerPluginHttpRoute } from "../../plugins/http-registry.js"; import { registerPluginHttpRoute } from "../../plugins/http-registry.js";
import { createEmptyPluginRegistry } from "../../plugins/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 { makeMockHttpResponse } from "../test-http-response.js";
import { createTestRegistry } from "./__tests__/test-utils.js"; import { createTestRegistry } from "./__tests__/test-utils.js";
import { import {
createGatewayPluginUpgradeHandler,
createGatewayPluginRequestHandler, createGatewayPluginRequestHandler,
isRegisteredPluginHttpRoutePath, isRegisteredPluginHttpRoutePath,
shouldEnforceGatewayAuthForPluginPath, shouldEnforceGatewayAuthForPluginPath,
@@ -28,6 +30,11 @@ function createRoute(params: {
auth?: "gateway" | "plugin"; auth?: "gateway" | "plugin";
match?: "exact" | "prefix"; match?: "exact" | "prefix";
handler?: (req: IncomingMessage, res: ServerResponse) => boolean | void | Promise<boolean | void>; handler?: (req: IncomingMessage, res: ServerResponse) => boolean | void | Promise<boolean | void>;
handleUpgrade?: (
req: IncomingMessage,
socket: Duplex,
head: Buffer,
) => boolean | void | Promise<boolean | void>;
}) { }) {
return { return {
pluginId: params.pluginId ?? "route", pluginId: params.pluginId ?? "route",
@@ -35,10 +42,25 @@ function createRoute(params: {
auth: params.auth ?? "plugin", auth: params.auth ?? "plugin",
match: params.match ?? "exact", match: params.match ?? "exact",
handler: params.handler ?? (() => {}), handler: params.handler ?? (() => {}),
handleUpgrade: params.handleUpgrade,
source: params.pluginId ?? "route", 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 { function buildRepeatedEncodedSlash(depth: number): string {
let encodedSlash = "%2f"; let encodedSlash = "%2f";
for (let i = 1; i < depth; i++) { 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", () => { describe("plugin HTTP route auth checks", () => {
const deeplyEncodedChannelPath = const deeplyEncodedChannelPath =
"/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile"; "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile";

View File

@@ -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 = { export type PluginRouteDispatchContext = {
gatewayAuthSatisfied?: boolean; gatewayAuthSatisfied?: boolean;
gatewayRequestAuth?: AuthorizedGatewayHttpRequest; gatewayRequestAuth?: AuthorizedGatewayHttpRequest;
@@ -189,7 +194,8 @@ export function createGatewayPluginUpgradeHandler(params: {
const requiresGatewayAuth = matchedPluginRoutesRequireGatewayAuth(matchedRoutes); const requiresGatewayAuth = matchedPluginRoutesRequireGatewayAuth(matchedRoutes);
if (requiresGatewayAuth && dispatchContext?.gatewayAuthSatisfied !== true) { if (requiresGatewayAuth && dispatchContext?.gatewayAuthSatisfied !== true) {
log.warn(`plugin http upgrade blocked without gateway auth (${pathContext.canonicalPath})`); log.warn(`plugin http upgrade blocked without gateway auth (${pathContext.canonicalPath})`);
return false; writeUpgradeUnauthorized(socket);
return true;
} }
const gatewayRequestAuth = dispatchContext?.gatewayRequestAuth; const gatewayRequestAuth = dispatchContext?.gatewayRequestAuth;
const gatewayRequestOperatorScopes = dispatchContext?.gatewayRequestOperatorScopes; const gatewayRequestOperatorScopes = dispatchContext?.gatewayRequestOperatorScopes;
@@ -203,7 +209,8 @@ export function createGatewayPluginUpgradeHandler(params: {
log.warn( log.warn(
`plugin http upgrade blocked without caller auth context (${pathContext.canonicalPath})`, `plugin http upgrade blocked without caller auth context (${pathContext.canonicalPath})`,
); );
return false; writeUpgradeUnauthorized(socket);
return true;
} }
continue; continue;
} }
@@ -211,7 +218,8 @@ export function createGatewayPluginUpgradeHandler(params: {
log.warn( log.warn(
`plugin http upgrade blocked without caller scope context (${pathContext.canonicalPath})`, `plugin http upgrade blocked without caller scope context (${pathContext.canonicalPath})`,
); );
return false; writeUpgradeUnauthorized(socket);
return true;
} }
} }

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { PluginRegistry } from "../../../plugins/registry.js"; 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", () => { describe("plugin node capability route metadata", () => {
it("lists one capability per surface with the shortest ttl", () => { it("lists one capability per surface with the shortest ttl", () => {
@@ -13,8 +17,27 @@ describe("plugin node capability route metadata", () => {
} as unknown as PluginRegistry; } as unknown as PluginRegistry;
expect(listPluginNodeCapabilities(registry)).toEqual([ expect(listPluginNodeCapabilities(registry)).toEqual([
{ surface: "canvas", ttlMs: 100 }, { surface: "canvas", ttlMs: 100, scopeKey: "two:canvas" },
{ surface: "files", ttlMs: 200 }, { 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" });
});
}); });

View File

@@ -16,11 +16,28 @@ function hasNodeCapabilityRoute(route: PluginHttpRouteEntry): route is PluginNod
return Boolean(route.nodeCapability?.surface?.trim()); 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( export function findMatchingPluginNodeCapabilityRoutes(
registry: PluginRegistry, registry: PluginRegistry,
context: PluginRoutePathContext, context: PluginRoutePathContext,
): PluginNodeCapabilityRoute[] { ): PluginNodeCapabilityRoute[] {
return findMatchingPluginHttpRoutes(registry, context).filter(hasNodeCapabilityRoute); return findMatchingPluginHttpRoutes(registry, context)
.filter(hasNodeCapabilityRoute)
.map((route) => ({
...route,
nodeCapability: resolvePluginNodeCapabilityRouteSurface(route),
}));
} }
export function findMatchingPluginNodeCapabilityRoute( export function findMatchingPluginNodeCapabilityRoute(
@@ -41,7 +58,7 @@ export function listPluginNodeCapabilities(
for (const route of registry.httpRoutes ?? []) { for (const route of registry.httpRoutes ?? []) {
const surface = route.nodeCapability?.surface?.trim(); const surface = route.nodeCapability?.surface?.trim();
if (surface) { if (surface) {
const next = { ...route.nodeCapability, surface }; const next = resolvePluginNodeCapabilityRouteSurface(route as PluginNodeCapabilityRoute);
const existing = surfaces.get(surface); const existing = surfaces.get(surface);
if (!existing || resolveTtlMs(next) < resolveTtlMs(existing)) { if (!existing || resolveTtlMs(next) < resolveTtlMs(existing)) {
surfaces.set(surface, next); surfaces.set(surface, next);

View File

@@ -107,6 +107,10 @@ function expectedBuildSpawn() {
return [process.execPath, "scripts/tsdown-build.mjs", "--no-clean"]; return [process.execPath, "scripts/tsdown-build.mjs", "--no-clean"];
} }
function expectedBundledPluginAssetBuildSpawn() {
return [process.execPath, "scripts/bundled-plugin-assets.mjs", "--phase", "build"];
}
function statusCommandSpawn() { function statusCommandSpawn() {
return [process.execPath, "openclaw.mjs", "status"]; return [process.execPath, "openclaw.mjs", "status"];
} }
@@ -341,6 +345,7 @@ describe("run-node script", () => {
); );
await expect(fs.readFile(indexPath, "utf-8")).resolves.toContain("sentinel"); await expect(fs.readFile(indexPath, "utf-8")).resolves.toContain("sentinel");
expect(nodeCalls).toEqual([ expect(nodeCalls).toEqual([
[process.execPath, "scripts/bundled-plugin-assets.mjs", "--phase", "build"],
[process.execPath, "scripts/tsdown-build.mjs", "--no-clean"], [process.execPath, "scripts/tsdown-build.mjs", "--no-clean"],
[process.execPath, "openclaw.mjs", "--version"], [process.execPath, "openclaw.mjs", "--version"],
]); ]);
@@ -379,7 +384,11 @@ describe("run-node script", () => {
}); });
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([expectedBuildSpawn(), statusCommandSpawn()]); expect(spawnCalls).toEqual([
expectedBundledPluginAssetBuildSpawn(),
expectedBuildSpawn(),
statusCommandSpawn(),
]);
await expect( await expect(
fs.readFile(resolvePath(tmp, "dist/plugin-sdk/root-alias.cjs"), "utf-8"), 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(exitCode).toBe(0);
expect(spawnCalls).toEqual([ expect(spawnCalls).toEqual([
expectedBundledPluginAssetBuildSpawn(),
expectedBuildSpawn(), expectedBuildSpawn(),
[ [
process.execPath, process.execPath,
@@ -1223,7 +1233,11 @@ describe("run-node script", () => {
const exitCode = await runStatusCommand({ tmp, spawn, spawnSync }); const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
expect(exitCode).toBe(0); 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 }); const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
expect(exitCode).toBe(0); 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 }); const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([expectedBuildSpawn(), statusCommandSpawn()]); expect(spawnCalls).toEqual([
expectedBundledPluginAssetBuildSpawn(),
expectedBuildSpawn(),
statusCommandSpawn(),
]);
}); });
}); });

View File

@@ -177,6 +177,36 @@ describe("loadWebMedia", () => {
expect(result.buffer.length).toBeGreaterThan(0); 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 () => { it("includes resize failure details when image optimization cannot produce a JPEG", async () => {
await expect(optimizeImageToJpeg(Buffer.from("not an image"), 8)).rejects.toThrow( await expect(optimizeImageToJpeg(Buffer.from("not an image"), 8)).rejects.toThrow(
/Failed to optimize image: .+/, /Failed to optimize image: .+/,

View File

@@ -75,9 +75,17 @@ async function resolveMediaStoreUriToPath(mediaUrl: string): Promise<string | nu
async function resolveHostedPluginMediaUrl(mediaUrl: string): Promise<string | null> { async function resolveHostedPluginMediaUrl(mediaUrl: string): Promise<string | null> {
const registry = getActivePluginRegistry(); const registry = getActivePluginRegistry();
for (const entry of registry?.hostedMediaResolvers ?? []) { for (const entry of registry?.hostedMediaResolvers ?? []) {
const resolved = await entry.resolver(mediaUrl); try {
if (typeof resolved === "string" && resolved.trim()) { const resolved = await entry.resolver(mediaUrl);
return resolved; if (typeof resolved === "string" && resolved.trim()) {
return resolved;
}
} catch (err) {
if (shouldLogVerbose()) {
logVerbose(
`Hosted media resolver failed (${entry.pluginId ?? "unknown"}): ${formatErrorMessage(err)}`,
);
}
} }
} }
return null; return null;