Files
browser/platform_web/utils/pluginBridgeHost.ts
T

220 lines
7.5 KiB
TypeScript

import type {
PluginBridgeAction,
PluginBridgeExecuteEnvelope,
PluginBridgeExecutionResult,
PluginBridgeHostContext,
PluginBridgeManifestContract,
PluginBridgeThemeTokens,
PluginPermission
} from "../contracts/pluginBridge";
import type { PlatformApiClient } from "../api/client";
export function createPluginBridgeHostContext(input: {
plugin: PluginBridgeManifestContract;
routeKey: string;
serverInstanceId?: string;
themeTokens: PluginBridgeThemeTokens;
}): PluginBridgeHostContext {
const page = input.plugin.pages.find((candidate) => candidate.key === input.routeKey);
const permissions = filterAllowedPermissions(input.plugin.declaredPermissions, page?.permissions);
const bridgeActions =
page?.bridgeActions === undefined
? [...input.plugin.bridgeActions]
: page.bridgeActions.filter((action) => input.plugin.bridgeActions.includes(action));
return {
pluginId: input.plugin.id,
routeKey: input.routeKey,
serverInstanceId: input.serverInstanceId,
themeTokens: input.themeTokens,
permissions,
bridgeActions,
aiPurposes: [...(input.plugin.aiPurposes ?? [])]
};
}
export function filterAllowedPermissions(
declaredPermissions: PluginPermission[],
pagePermissions: PluginPermission[] | undefined
): PluginPermission[] {
if (pagePermissions === undefined) {
return [...declaredPermissions];
}
return pagePermissions.filter((permission) => declaredPermissions.includes(permission));
}
export function createPluginBridgeDispatcher(context: PluginBridgeHostContext, client: Pick<PlatformApiClient, "executePluginBridge">) {
return async function dispatchBridgeRequest(envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal): Promise<PluginBridgeExecutionResult> {
const localError = validateBridgeExecutionRequest(context, envelope);
if (localError) {
return {
requestId: envelope.requestId,
action: envelope.action,
status: "denied",
error: localError
};
}
if (signal?.aborted) {
return {
requestId: envelope.requestId,
action: envelope.action,
status: "cancelled",
error: { code: "cancelled", message: "bridge request was cancelled" }
};
}
try {
const response = await client.executePluginBridge({
requestId: envelope.requestId,
pluginId: context.pluginId,
routeKey: context.routeKey,
serverInstanceId: context.serverInstanceId,
action: envelope.action,
aiPurpose: envelope.aiPurpose,
payload: envelope.payload
});
return {
requestId: response.requestId,
action: response.action as PluginBridgeAction,
status: response.status,
result: response.result,
error: response.error
};
} catch (error) {
return {
requestId: envelope.requestId,
action: envelope.action,
status: "error",
error: { code: "platform_error", message: error instanceof Error ? error.message : "bridge execution failed" }
};
}
};
}
export function validateBridgeExecutionRequest(context: PluginBridgeHostContext, envelope: PluginBridgeExecuteEnvelope): PluginBridgeExecutionResult["error"] | null {
if (!envelope.requestId.trim()) {
return { code: "validation", message: "requestId is required" };
}
if (!context.bridgeActions.includes(envelope.action)) {
return { code: "unsupported_action", message: "bridge action is not allowed for this page" };
}
for (const permission of requiredPermissions(envelope.action)) {
if (!context.permissions.includes(permission)) {
return { code: "permission_denied", message: "bridge action is missing required permission" };
}
}
if (envelope.action === "ai.invoke" && (!envelope.aiPurpose || !context.aiPurposes.includes(envelope.aiPurpose))) {
return { code: "ai_purpose_denied", message: "AI purpose is not allowed for this plugin" };
}
const payload = envelope.payload ?? {};
if (Object.keys(payload).length > 16) {
return { code: "payload_too_large", message: "bridge payload has too many keys" };
}
const encodedSize = Object.entries(payload).reduce((sum, [key, value]) => sum + key.length + value.length, 0);
if (encodedSize > 16 * 1024) {
return { code: "payload_too_large", message: "bridge payload is too large" };
}
for (const [key, value] of Object.entries(payload)) {
if (!key.trim() || key !== key.trim()) {
return { code: "validation", message: "bridge payload key is invalid" };
}
if (containsUnsafeBridgeContent(key) || containsUnsafeBridgeContent(value)) {
return { code: "unsafe_payload", message: "bridge payload contains unsafe content" };
}
}
return null;
}
export interface PluginArtifactReference {
artifactId: string;
filename: string;
contentType: string;
sizeBytes: number;
checksum: string;
downloadUrl: string;
expiresAt: string;
rangeSupported: boolean;
chunkSizeBytes: number;
storageBehavior?: string;
}
export function parsePluginArtifactReference(result: Record<string, string> | undefined): PluginArtifactReference | null {
if (!result) {
return null;
}
const sizeBytes = Number(result.sizeBytes);
const chunkSizeBytes = Number(result.chunkSizeBytes);
const reference: PluginArtifactReference = {
artifactId: result.artifactId ?? "",
filename: result.filename ?? "artifact.bin",
contentType: result.contentType ?? "application/octet-stream",
sizeBytes,
checksum: result.checksum ?? "",
downloadUrl: result.downloadUrl ?? "",
expiresAt: result.expiresAt ?? "",
rangeSupported: result.rangeSupported === "true",
chunkSizeBytes,
storageBehavior: result.storageBehavior
};
if (!reference.artifactId || !Number.isFinite(sizeBytes) || sizeBytes <= 0 || !Number.isFinite(chunkSizeBytes) || chunkSizeBytes <= 0) {
return null;
}
if (!reference.downloadUrl.startsWith(`/api/v1/artifacts/${encodeURIComponent(reference.artifactId)}/content`)) {
return null;
}
for (const value of Object.values(reference)) {
if (typeof value === "string" && containsUnsafeBridgeContent(value)) {
return null;
}
}
return reference;
}
function requiredPermissions(action: PluginBridgeAction): PluginPermission[] {
switch (action) {
case "server.instances.read":
return ["server.read"];
case "jobs.dispatch":
return ["server.lifecycle"];
case "logs.query":
return ["server.logs.read"];
case "artifacts.open":
return ["server.artifacts.read"];
case "files.request":
return ["server.files.read"];
case "remote.access.request":
return ["server.remote.access"];
case "run.distribution.request":
return ["server.run.distribution"];
case "dependencies.request":
return ["server.dependencies.manage"];
case "logs.backfill.request":
return ["server.logs.read"];
case "plugin-lifecycle.request":
return ["server.lifecycle"];
case "ai.invoke":
return ["ai.invoke"];
default:
return [];
}
}
function containsUnsafeBridgeContent(value: string): boolean {
const lowered = value.trim().toLowerCase();
if (!lowered) {
return false;
}
return (
lowered.includes("/users/") ||
lowered.includes("/private/") ||
lowered.includes("unix://") ||
lowered.includes("tcp://") ||
lowered.includes("bearer ") ||
lowered.startsWith("sk-") ||
lowered.includes("password=") ||
lowered.includes("api_key=") ||
lowered.includes("apikey=") ||
lowered.includes("host path") ||
lowered.includes("rawapikey")
);
}