diff --git a/.crabbox.yaml b/.crabbox.yaml index ab0046d8ce9..d745f9d837a 100644 --- a/.crabbox.yaml +++ b/.crabbox.yaml @@ -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 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d659077dc34..e5f0a311bf4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -37,7 +37,7 @@ If this PR fixes a plugin beta-release blocker, title it `fix(): 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: diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index b88b75aa2b8..da27dcf932f 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -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, diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 3b554e40787..42d7a0c42c2 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -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, diff --git a/extensions/telegram/src/bot.helpers.test.ts b/extensions/telegram/src/bot.helpers.test.ts index 0823438e3a5..b8a953879d8 100644 --- a/extensions/telegram/src/bot.helpers.test.ts +++ b/extensions/telegram/src/bot.helpers.test.ts @@ -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"); diff --git a/extensions/telegram/src/bot/helpers.ts b/extensions/telegram/src/bot/helpers.ts index 6c655c7a86e..53afcc9d86c 100644 --- a/extensions/telegram/src/bot/helpers.ts +++ b/extensions/telegram/src/bot/helpers.ts @@ -170,8 +170,8 @@ export function withResolvedTelegramForumFlag( } export async function resolveTelegramGroupAllowFromContext(params: { - chatId: string | number; cfg?: OpenClawConfig; + chatId: string | number; accountId?: string; senderId?: string; isGroup?: boolean; diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 9b2e8744b1f..36c7cd2c057 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -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, diff --git a/src/gateway/plugin-node-capability.test.ts b/src/gateway/plugin-node-capability.test.ts index f4e65712f64..5520a9e27bf 100644 --- a/src/gateway/plugin-node-capability.test.ts +++ b/src/gateway/plugin-node-capability.test.ts @@ -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: { diff --git a/src/gateway/plugin-node-capability.ts b/src/gateway/plugin-node-capability.ts index 21e8d442e75..b41c3500744 100644 --- a/src/gateway/plugin-node-capability.ts +++ b/src/gateway/plugin-node-capability.ts @@ -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; } diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index c5de585d77f..1420d046684 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -805,6 +805,7 @@ export function attachGatewayUpgradeHandler(opts: { httpServer: HttpServer; wss: WebSocketServer; handlePluginUpgrade?: PluginHttpUpgradeHandler; + shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean; resolvePluginNodeCapabilityRoute?: ResolvePluginNodeCapabilityRoute; clients: Set; 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; diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 48bfa57682b..4e7f6795361 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -254,6 +254,7 @@ export async function createGatewayRuntimeState(params: { httpServer, wss, handlePluginUpgrade, + shouldEnforcePluginGatewayAuth, resolvePluginNodeCapabilityRoute, clients, preauthConnectionBudget, diff --git a/src/gateway/server/plugins-http.test.ts b/src/gateway/server/plugins-http.test.ts index 6902f71b172..24dcf76ff98 100644 --- a/src/gateway/server/plugins-http.test.ts +++ b/src/gateway/server/plugins-http.test.ts @@ -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; + handleUpgrade?: ( + req: IncomingMessage, + socket: Duplex, + head: Buffer, + ) => boolean | void | Promise; }) { 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"; diff --git a/src/gateway/server/plugins-http.ts b/src/gateway/server/plugins-http.ts index f69db59fd24..95807b7c189 100644 --- a/src/gateway/server/plugins-http.ts +++ b/src/gateway/server/plugins-http.ts @@ -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; } } diff --git a/src/gateway/server/plugins-http/route-capability.test.ts b/src/gateway/server/plugins-http/route-capability.test.ts index 747a333a883..ba0637ee4fb 100644 --- a/src/gateway/server/plugins-http/route-capability.test.ts +++ b/src/gateway/server/plugins-http/route-capability.test.ts @@ -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" }); + }); }); diff --git a/src/gateway/server/plugins-http/route-capability.ts b/src/gateway/server/plugins-http/route-capability.ts index b6503669b55..5d0345a9d02 100644 --- a/src/gateway/server/plugins-http/route-capability.ts +++ b/src/gateway/server/plugins-http/route-capability.ts @@ -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); diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 20088b8225d..ff9dbc95ff0 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -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(), + ]); }); }); diff --git a/src/media/web-media.test.ts b/src/media/web-media.test.ts index 9ff086452ca..aac486eaed0 100644 --- a/src/media/web-media.test.ts +++ b/src/media/web-media.test.ts @@ -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: .+/, diff --git a/src/media/web-media.ts b/src/media/web-media.ts index 5d335f9a148..53c6337fb54 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -75,9 +75,17 @@ async function resolveMediaStoreUriToPath(mediaUrl: string): Promise { 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;