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

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)
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:

View File

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

View File

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

View File

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

View File

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

View File

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

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", () => {
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: {

View File

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

View File

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

View File

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

View File

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

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 = {
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;
}
}

View File

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

View File

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

View File

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

View File

@@ -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: .+/,

View File

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