mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
fix: harden generated surface pruning
This commit is contained in:
@@ -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
|
||||||
|
|||||||
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)
|
## 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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ export async function createGatewayRuntimeState(params: {
|
|||||||
httpServer,
|
httpServer,
|
||||||
wss,
|
wss,
|
||||||
handlePluginUpgrade,
|
handlePluginUpgrade,
|
||||||
|
shouldEnforcePluginGatewayAuth,
|
||||||
resolvePluginNodeCapabilityRoute,
|
resolvePluginNodeCapabilityRoute,
|
||||||
clients,
|
clients,
|
||||||
preauthConnectionBudget,
|
preauthConnectionBudget,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: .+/,
|
||||||
|
|||||||
@@ -75,10 +75,18 @@ 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 ?? []) {
|
||||||
|
try {
|
||||||
const resolved = await entry.resolver(mediaUrl);
|
const resolved = await entry.resolver(mediaUrl);
|
||||||
if (typeof resolved === "string" && resolved.trim()) {
|
if (typeof resolved === "string" && resolved.trim()) {
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (shouldLogVerbose()) {
|
||||||
|
logVerbose(
|
||||||
|
`Hosted media resolver failed (${entry.pluginId ?? "unknown"}): ${formatErrorMessage(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user