first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export function cx(...tokens: Array<string | false | null | undefined>): string {
|
||||
return tokens.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ConfigDiffView, DiffLine } from "../contracts/workspace";
|
||||
|
||||
export function computeLineDiff(previous: string, next: string): DiffLine[] {
|
||||
const previousLines = previous.split("\n");
|
||||
const nextLines = next.split("\n");
|
||||
const m = previousLines.length;
|
||||
const n = nextLines.length;
|
||||
// classic LCS table; config files are small enough for O(m*n)
|
||||
const lcs: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0));
|
||||
for (let i = m - 1; i >= 0; i -= 1) {
|
||||
for (let j = n - 1; j >= 0; j -= 1) {
|
||||
lcs[i][j] = previousLines[i] === nextLines[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const lines: DiffLine[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (previousLines[i] === nextLines[j]) {
|
||||
lines.push({ kind: "same", text: previousLines[i] });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
lines.push({ kind: "removed", text: previousLines[i] });
|
||||
i += 1;
|
||||
} else {
|
||||
lines.push({ kind: "added", text: nextLines[j] });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
while (i < m) {
|
||||
lines.push({ kind: "removed", text: previousLines[i] });
|
||||
i += 1;
|
||||
}
|
||||
while (j < n) {
|
||||
lines.push({ kind: "added", text: nextLines[j] });
|
||||
j += 1;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function buildConfigDiff(serverInstanceId: string, previous: string, next: string): ConfigDiffView {
|
||||
const lines = computeLineDiff(previous, next);
|
||||
const added = lines.filter((line) => line.kind === "added").length;
|
||||
const removed = lines.filter((line) => line.kind === "removed").length;
|
||||
return {
|
||||
serverInstanceId,
|
||||
summary: `+${added} / -${removed} 行变更`,
|
||||
lines,
|
||||
nextContent: next
|
||||
};
|
||||
}
|
||||
|
||||
export function diffHasChanges(diff: ConfigDiffView): boolean {
|
||||
return diff.lines.some((line) => line.kind !== "same");
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PluginBridgeManifestContract } from "../contracts/pluginBridge";
|
||||
import {
|
||||
createPluginBridgeDispatcher,
|
||||
createPluginBridgeHostContext,
|
||||
filterAllowedPermissions,
|
||||
parsePluginArtifactReference,
|
||||
validateBridgeExecutionRequest
|
||||
} from "./pluginBridgeHost";
|
||||
|
||||
const plugin: PluginBridgeManifestContract = {
|
||||
id: "game.example",
|
||||
declaredPermissions: ["server.read", "server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"],
|
||||
bridgeActions: ["server.instances.read", "logs.query", "files.request", "artifacts.open", "ai.invoke"],
|
||||
pages: [
|
||||
{
|
||||
key: "logs",
|
||||
title: "Logs",
|
||||
path: "/logs",
|
||||
permissions: ["server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"],
|
||||
bridgeActions: ["logs.query", "files.request", "artifacts.open", "ai.invoke"]
|
||||
}
|
||||
],
|
||||
aiPurposes: ["logs.diagnose"]
|
||||
};
|
||||
|
||||
describe("plugin bridge host utilities", () => {
|
||||
it("filters page permissions against manifest permissions", () => {
|
||||
expect(filterAllowedPermissions(plugin.declaredPermissions, ["server.logs.read", "server.artifacts.read"])).toEqual(["server.logs.read", "server.artifacts.read"]);
|
||||
});
|
||||
|
||||
it("creates safe host context without secret-bearing fields", () => {
|
||||
const context = createPluginBridgeHostContext({
|
||||
plugin,
|
||||
routeKey: "logs",
|
||||
serverInstanceId: "server-1",
|
||||
themeTokens: { colorScheme: "dark", accentColor: "#22c55e" }
|
||||
});
|
||||
|
||||
expect(context).toEqual({
|
||||
pluginId: "game.example",
|
||||
routeKey: "logs",
|
||||
serverInstanceId: "server-1",
|
||||
themeTokens: { colorScheme: "dark", accentColor: "#22c55e" },
|
||||
permissions: ["server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"],
|
||||
bridgeActions: ["logs.query", "files.request", "artifacts.open", "ai.invoke"],
|
||||
aiPurposes: ["logs.diagnose"]
|
||||
});
|
||||
expect(context).not.toHaveProperty("apiKey");
|
||||
expect(context).not.toHaveProperty("runCredential");
|
||||
expect(context).not.toHaveProperty("hostPath");
|
||||
});
|
||||
|
||||
it("builds bridge execution requests through the platform client", async () => {
|
||||
const context = createPluginBridgeHostContext({
|
||||
plugin,
|
||||
routeKey: "logs",
|
||||
serverInstanceId: "server-1",
|
||||
themeTokens: { colorScheme: "dark", accentColor: "#22c55e" }
|
||||
});
|
||||
const client = {
|
||||
executePluginBridge: vi.fn(async () => ({
|
||||
requestId: "req-1",
|
||||
pluginId: "game.example",
|
||||
routeKey: "logs",
|
||||
serverInstanceId: "server-1",
|
||||
action: "logs.query",
|
||||
status: "ok",
|
||||
result: { entryCount: "0" }
|
||||
}))
|
||||
};
|
||||
|
||||
const dispatch = createPluginBridgeDispatcher(context, client);
|
||||
await expect(dispatch({ requestId: "req-1", action: "logs.query", payload: { logStreamId: "log-1" } })).resolves.toMatchObject({
|
||||
status: "ok",
|
||||
result: { entryCount: "0" }
|
||||
});
|
||||
expect(client.executePluginBridge).toHaveBeenCalledWith({
|
||||
requestId: "req-1",
|
||||
pluginId: "game.example",
|
||||
routeKey: "logs",
|
||||
serverInstanceId: "server-1",
|
||||
action: "logs.query",
|
||||
aiPurpose: undefined,
|
||||
payload: { logStreamId: "log-1" }
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects denied, unsafe, and cancelled bridge execution locally", async () => {
|
||||
const context = createPluginBridgeHostContext({
|
||||
plugin,
|
||||
routeKey: "logs",
|
||||
serverInstanceId: "server-1",
|
||||
themeTokens: { colorScheme: "dark", accentColor: "#22c55e" }
|
||||
});
|
||||
expect(validateBridgeExecutionRequest(context, { requestId: "req-denied", action: "server.instances.read" })).toMatchObject({ code: "unsupported_action" });
|
||||
expect(
|
||||
validateBridgeExecutionRequest(context, { requestId: "req-unsafe", action: "files.request", payload: { key: "/Users/tasia/.ssh/id_rsa" } })
|
||||
).toMatchObject({ code: "unsafe_payload" });
|
||||
|
||||
const client = { executePluginBridge: vi.fn() };
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const dispatch = createPluginBridgeDispatcher(context, client);
|
||||
await expect(dispatch({ requestId: "req-cancel", action: "logs.query", payload: { logStreamId: "log-1" } }, controller.signal)).resolves.toMatchObject({
|
||||
status: "cancelled",
|
||||
error: { code: "cancelled" }
|
||||
});
|
||||
expect(client.executePluginBridge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches mediated AI requests without provider configuration", async () => {
|
||||
const context = createPluginBridgeHostContext({
|
||||
plugin,
|
||||
routeKey: "logs",
|
||||
serverInstanceId: "server-1",
|
||||
themeTokens: { colorScheme: "dark", accentColor: "#22c55e" }
|
||||
});
|
||||
expect(validateBridgeExecutionRequest(context, { requestId: "ai-denied", action: "ai.invoke", aiPurpose: "config.suggest" })).toMatchObject({ code: "ai_purpose_denied" });
|
||||
const client = {
|
||||
executePluginBridge: vi.fn(async () => ({
|
||||
requestId: "ai-1",
|
||||
pluginId: "game.example",
|
||||
routeKey: "logs",
|
||||
serverInstanceId: "server-1",
|
||||
action: "ai.invoke",
|
||||
status: "ok",
|
||||
result: { recommendation: "Mock AI recommendation", mocked: "true" }
|
||||
}))
|
||||
};
|
||||
const dispatch = createPluginBridgeDispatcher(context, client);
|
||||
const response = await dispatch({ requestId: "ai-1", action: "ai.invoke", aiPurpose: "logs.diagnose", payload: { prompt: "Summarize logs" } });
|
||||
expect(response).toMatchObject({ status: "ok", result: { mocked: "true" } });
|
||||
const serialized = JSON.stringify(response);
|
||||
expect(serialized).not.toContain("apiKeyRef");
|
||||
expect(serialized).not.toContain("rawApiKey");
|
||||
expect(serialized).not.toContain("baseUrl");
|
||||
});
|
||||
|
||||
it("parses safe artifact bridge references and rejects backend internals", () => {
|
||||
expect(
|
||||
parsePluginArtifactReference({
|
||||
artifactId: "artifact-1",
|
||||
filename: "artifact-1.bin",
|
||||
contentType: "application/octet-stream",
|
||||
sizeBytes: "128",
|
||||
checksum: "sha256:abc",
|
||||
downloadUrl: "/api/v1/artifacts/artifact-1/content",
|
||||
expiresAt: "2026-07-03T00:15:00Z",
|
||||
rangeSupported: "true",
|
||||
chunkSizeBytes: "1048576",
|
||||
storageBehavior: "platform-memory-transfer-session"
|
||||
})
|
||||
).toMatchObject({ artifactId: "artifact-1", downloadUrl: "/api/v1/artifacts/artifact-1/content", rangeSupported: true });
|
||||
|
||||
expect(
|
||||
parsePluginArtifactReference({
|
||||
artifactId: "artifact-1",
|
||||
filename: "/Users/tasia/artifact.bin",
|
||||
contentType: "application/octet-stream",
|
||||
sizeBytes: "128",
|
||||
checksum: "sha256:abc",
|
||||
downloadUrl: "storage://bucket/artifact-1",
|
||||
expiresAt: "2026-07-03T00:15:00Z",
|
||||
rangeSupported: "true",
|
||||
chunkSizeBytes: "1048576"
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
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 > 4096) {
|
||||
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 "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")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user