mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
0f066eec81
* feat(board): add plugin widget kinds * feat(ui): render native Workboard widgets * fix(dashboard): compose plugin widgets with current main * chore: internalize widget-kind contribution types * fix(ui): retry plugin widget renderer loads * fix(ui): harden Workboard widget refresh lifecycle * fix(ci): clear plugin widget landing gates * fix(boards): migrate plugin widget storage * fix(db): migrate unreleased board widget constraint * fix(ui): retry failed Workboard widget loads * fix(ui): keep stale widget refresh cleanup inert * fix(ci): align plugin widget landing guards
185 lines
5.7 KiB
TypeScript
185 lines
5.7 KiB
TypeScript
// Projects plugin "tab" Control UI descriptors into the hello payload so the
|
|
// dashboard renders plugin tabs without hardcoding plugin ids in core.
|
|
import type { PluginControlUiDescriptor } from "../plugins/host-hooks.js";
|
|
import type { PluginRegistry } from "../plugins/registry.js";
|
|
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
|
import { resolveControlUiPluginTabPathname } from "./control-ui-contract.js";
|
|
import {
|
|
authorizeOperatorScopesForRequiredScope,
|
|
READ_SCOPE,
|
|
type OperatorScope,
|
|
} from "./method-scopes.js";
|
|
import { resolvePluginRoutePathContext } from "./server/plugins-http/path-context.js";
|
|
import { findMatchingPluginHttpRoutes } from "./server/plugins-http/route-match.js";
|
|
|
|
type ControlUiPluginTab = {
|
|
pluginId: string;
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
icon?: string;
|
|
path?: string;
|
|
group?: "control" | "agent";
|
|
order?: number;
|
|
requiresGatewayAuth?: boolean;
|
|
};
|
|
|
|
type ControlUiPluginWidgetKind = {
|
|
pluginId: string;
|
|
kind: string;
|
|
label: string;
|
|
};
|
|
|
|
function findControlUiTabGatewayRoute(
|
|
registry: PluginRegistry,
|
|
tab: ControlUiPluginTab,
|
|
): ReturnType<typeof findMatchingPluginHttpRoutes>[number] | null | undefined {
|
|
if (!tab.path) {
|
|
return undefined;
|
|
}
|
|
const routePath = resolveControlUiPluginTabPathname(tab.path);
|
|
if (!routePath) {
|
|
return undefined;
|
|
}
|
|
const route = findMatchingPluginHttpRoutes(
|
|
registry,
|
|
resolvePluginRoutePathContext(routePath),
|
|
).find((candidate) => candidate.auth === "gateway");
|
|
if (!route) {
|
|
return undefined;
|
|
}
|
|
return route.pluginId === tab.pluginId ? route : null;
|
|
}
|
|
|
|
type ControlUiDescriptorEntry = {
|
|
pluginId: string;
|
|
descriptor: PluginControlUiDescriptor;
|
|
};
|
|
|
|
export type ControlUiPluginTabAuthGrant = {
|
|
pluginId: string;
|
|
path: string;
|
|
match: "exact" | "prefix";
|
|
scopes: OperatorScope[];
|
|
};
|
|
|
|
/** Pure projection of tab descriptors visible to the presented scopes. */
|
|
function projectControlUiPluginTabs(
|
|
entries: readonly ControlUiDescriptorEntry[],
|
|
scopes: readonly string[],
|
|
): ControlUiPluginTab[] {
|
|
const tabs: ControlUiPluginTab[] = [];
|
|
for (const entry of entries) {
|
|
const descriptor = entry.descriptor;
|
|
if (descriptor.surface !== "tab") {
|
|
continue;
|
|
}
|
|
const visible = (descriptor.requiredScopes ?? []).every(
|
|
(scope) => authorizeOperatorScopesForRequiredScope(scope, scopes).allowed,
|
|
);
|
|
if (!visible) {
|
|
continue;
|
|
}
|
|
tabs.push({
|
|
pluginId: entry.pluginId,
|
|
id: descriptor.id,
|
|
label: descriptor.label,
|
|
description: descriptor.description,
|
|
icon: descriptor.icon,
|
|
path: descriptor.path,
|
|
group: descriptor.group,
|
|
order: descriptor.order,
|
|
});
|
|
}
|
|
// Deterministic ordering keeps hello payloads stable across connects.
|
|
return tabs.toSorted(
|
|
(left, right) =>
|
|
(left.order ?? 0) - (right.order ?? 0) ||
|
|
left.label.localeCompare(right.label) ||
|
|
left.id.localeCompare(right.id),
|
|
);
|
|
}
|
|
|
|
/** Lists active plugins' tab descriptors visible to the presented scopes. */
|
|
export function listControlUiPluginTabs(
|
|
scopes: readonly string[],
|
|
opts: { requireGatewayAuthGrant?: boolean } = {},
|
|
): ControlUiPluginTab[] {
|
|
const registry = getActivePluginRegistry();
|
|
return projectControlUiPluginTabs(registry?.controlUiDescriptors ?? [], scopes).flatMap((tab) => {
|
|
const route = registry ? findControlUiTabGatewayRoute(registry, tab) : undefined;
|
|
if (route === null) {
|
|
// Dispatch authenticates against its first matching gateway route. Hide
|
|
// a descriptor whose owning plugin cannot receive that request.
|
|
return [];
|
|
}
|
|
return route && opts.requireGatewayAuthGrant !== false
|
|
? [{ ...tab, requiresGatewayAuth: true }]
|
|
: [tab];
|
|
});
|
|
}
|
|
|
|
/** Lists active plugins' trusted widget kinds visible to the presented scopes. */
|
|
export function listControlUiPluginWidgetKinds(
|
|
scopes: readonly string[],
|
|
): ControlUiPluginWidgetKind[] {
|
|
const entries = getActivePluginRegistry()?.controlUiDescriptors ?? [];
|
|
return entries
|
|
.flatMap((entry) => {
|
|
const descriptor = entry.descriptor;
|
|
if (descriptor.surface !== "widget") {
|
|
return [];
|
|
}
|
|
const visible = (descriptor.requiredScopes ?? []).every(
|
|
(scope) => authorizeOperatorScopesForRequiredScope(scope, scopes).allowed,
|
|
);
|
|
return visible
|
|
? [
|
|
{
|
|
pluginId: entry.pluginId,
|
|
kind: `${entry.pluginId}:${descriptor.id}`,
|
|
label: descriptor.label,
|
|
},
|
|
]
|
|
: [];
|
|
})
|
|
.toSorted(
|
|
(left, right) => left.label.localeCompare(right.label) || left.kind.localeCompare(right.kind),
|
|
);
|
|
}
|
|
|
|
/** Builds least-privilege grants only for visible tabs backed by same-plugin gateway routes. */
|
|
export function listControlUiPluginTabAuthGrants(
|
|
callerScopes: readonly string[],
|
|
): ControlUiPluginTabAuthGrant[] {
|
|
const registry = getActivePluginRegistry();
|
|
if (!registry || !authorizeOperatorScopesForRequiredScope(READ_SCOPE, callerScopes).allowed) {
|
|
return [];
|
|
}
|
|
const grants = new Map<string, ControlUiPluginTabAuthGrant>();
|
|
for (const tab of projectControlUiPluginTabs(registry.controlUiDescriptors ?? [], callerScopes)) {
|
|
if (!tab.path) {
|
|
continue;
|
|
}
|
|
const route = findControlUiTabGatewayRoute(registry, tab);
|
|
if (!route) {
|
|
continue;
|
|
}
|
|
const key = `${tab.pluginId}\n${route.path}`;
|
|
const existing = grants.get(key);
|
|
if (existing) {
|
|
if (existing.match === "exact" && route.match === "prefix") {
|
|
grants.set(key, { ...existing, match: "prefix" });
|
|
}
|
|
continue;
|
|
}
|
|
grants.set(key, {
|
|
pluginId: tab.pluginId,
|
|
path: route.path,
|
|
match: route.match,
|
|
scopes: [READ_SCOPE],
|
|
});
|
|
}
|
|
return [...grants.values()];
|
|
}
|