first commit
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
# Plugin Bridge Contract
|
||||
|
||||
Plugins use the platform bridge for every privileged action.
|
||||
|
||||
## Allowed Bridge Areas
|
||||
|
||||
- `server.instances`: read current server instance context.
|
||||
- `jobs.dispatch`: request platform-authorized run jobs.
|
||||
- `logs.query`: query historical logs by server, stream, time range, cursor, or analysis window.
|
||||
- `artifacts.open`: request platform-mediated artifact download references.
|
||||
- `files.request`: request scoped file list/read/patch/replace operations through platform jobs.
|
||||
- `ai.invoke`: request platform-mediated AI assistance.
|
||||
- `theme.tokens`: read safe platform theme tokens.
|
||||
|
||||
## Execution Envelopes
|
||||
|
||||
Plugin pages build execution requests with `createBridgeExecutionRequest` and hand them to the host-provided bridge transport. The SDK never owns `fetch`, platform authorization headers, run sockets, or provider credentials.
|
||||
|
||||
Execution responses use `requestId`, plugin/page/server scope, action, status, optional result refs, and optional safe errors. Use `parseBridgeExecutionResponse` before reading results so plugin code handles denied, deferred, and failed states uniformly.
|
||||
|
||||
AI requests use `createAIInvocationRequest` with an explicit purpose, prompt, scoped context refs, and optional current config. Use `parseAIInvocationResponse` to consume recommendations and safe errors. Plugin code must not include provider API keys, provider base URLs, bearer tokens, or direct transport details in AI request payloads.
|
||||
|
||||
Artifact open requests use `createArtifactOpenRequest` with an artifact ID that belongs to the current server/job scope. Use `parseArtifactReference` to consume the bridge result. Parsed references contain platform-owned download URLs, filename, content type, size, checksum, expiry, range support, and chunk size; they do not contain bytes or raw storage adapter locations.
|
||||
|
||||
## Forbidden Data
|
||||
|
||||
The bridge must not expose:
|
||||
|
||||
- raw run credentials.
|
||||
- raw host paths.
|
||||
- AI provider API keys.
|
||||
- platform auth storage.
|
||||
- storage backend endpoints.
|
||||
- unrestricted artifact storage credentials.
|
||||
- direct storage URLs or presigned backend URLs.
|
||||
@@ -0,0 +1,384 @@
|
||||
export type PluginPermission =
|
||||
| "server.create"
|
||||
| "server.read"
|
||||
| "server.lifecycle"
|
||||
| "server.files.read"
|
||||
| "server.files.write"
|
||||
| "server.logs.read"
|
||||
| "server.artifacts.read"
|
||||
| "server.artifacts.write"
|
||||
| "ai.invoke";
|
||||
|
||||
export type RunCapability =
|
||||
| "process.install"
|
||||
| "process.start"
|
||||
| "process.stop"
|
||||
| "process.restart"
|
||||
| "process.status"
|
||||
| "files.list"
|
||||
| "files.read"
|
||||
| "files.write"
|
||||
| "files.patch"
|
||||
| "logs.read"
|
||||
| "artifacts.read"
|
||||
| "artifacts.write"
|
||||
| "ai.invoke";
|
||||
|
||||
export type AIPurpose = "config.read" | "config.generate" | "config.suggest" | "logs.diagnose" | "files.suggest";
|
||||
|
||||
export type PluginBridgeAction =
|
||||
| "server.instances.read"
|
||||
| "jobs.dispatch"
|
||||
| "logs.query"
|
||||
| "artifacts.open"
|
||||
| "files.request"
|
||||
| "ai.invoke";
|
||||
|
||||
export type PluginBridgeRequestPayload = Record<string, unknown>;
|
||||
|
||||
export type PluginBridgeRequest<TPayload extends PluginBridgeRequestPayload = PluginBridgeRequestPayload> = {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
routeKey: string;
|
||||
serverInstanceId?: string;
|
||||
action: PluginBridgeAction;
|
||||
payload: TPayload;
|
||||
};
|
||||
|
||||
export type PluginBridgeResponse<TResult = unknown> =
|
||||
| {
|
||||
id: string;
|
||||
ok: true;
|
||||
result: TResult;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
ok: false;
|
||||
error: PluginBridgeError;
|
||||
};
|
||||
|
||||
export interface PluginBridgeError {
|
||||
code: "unsupported_action" | "missing_permission" | "invalid_payload" | "denied" | "permission_denied" | "unsafe_payload" | "platform_error" | "deferred";
|
||||
message: string;
|
||||
details?: string[];
|
||||
}
|
||||
|
||||
export interface PluginBridgeExecutionRequest<TPayload extends Record<string, string> = Record<string, string>> {
|
||||
requestId: string;
|
||||
pluginId: string;
|
||||
routeKey: string;
|
||||
serverInstanceId?: string;
|
||||
action: PluginBridgeAction;
|
||||
aiPurpose?: AIPurpose;
|
||||
payload?: TPayload;
|
||||
}
|
||||
|
||||
export interface PluginAIInvocationRequest {
|
||||
requestId: string;
|
||||
pluginId: string;
|
||||
routeKey: string;
|
||||
serverInstanceId?: string;
|
||||
purpose: AIPurpose;
|
||||
prompt: string;
|
||||
currentConfig?: string;
|
||||
contextRefs?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PluginAIInvocationResponse {
|
||||
requestId: string;
|
||||
purpose: AIPurpose;
|
||||
status: "ok" | "denied" | "error" | string;
|
||||
recommendation?: string;
|
||||
suggestedConfig?: string;
|
||||
usage?: { model?: string; mocked?: boolean; inputTokens?: number; outputTokens?: number };
|
||||
error?: PluginBridgeError;
|
||||
}
|
||||
|
||||
export type PluginArtifactOpenPayload = Record<string, string> & {
|
||||
artifactId: string;
|
||||
};
|
||||
|
||||
export type PluginLifecycleDispatchPayload = Record<string, string> & {
|
||||
lifecycleAction: "start" | "stop";
|
||||
capability: "process.start" | "process.stop";
|
||||
expectedConfigVersion: string;
|
||||
idempotencyKey: string;
|
||||
};
|
||||
|
||||
export interface PluginArtifactReference {
|
||||
artifactId: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
checksum: string;
|
||||
downloadUrl: string;
|
||||
expiresAt: string;
|
||||
rangeSupported: boolean;
|
||||
chunkSizeBytes: number;
|
||||
storageBehavior?: string;
|
||||
}
|
||||
|
||||
export type PluginBridgeExecutionResponse<TResult extends Record<string, string> = Record<string, string>> = {
|
||||
requestId: string;
|
||||
pluginId: string;
|
||||
routeKey: string;
|
||||
serverInstanceId?: string;
|
||||
action: PluginBridgeAction;
|
||||
status: "ok" | "queued" | "denied" | "unsupported" | "cancelled" | "error" | string;
|
||||
result?: TResult;
|
||||
error?: PluginBridgeError;
|
||||
};
|
||||
|
||||
export type PluginBridgeActionPolicy = {
|
||||
permissions: PluginPermission[];
|
||||
aiPurposeRequired?: boolean;
|
||||
};
|
||||
|
||||
export const pluginBridgeActionPolicies: Record<PluginBridgeAction, PluginBridgeActionPolicy> = {
|
||||
"server.instances.read": { permissions: ["server.read"] },
|
||||
"jobs.dispatch": { permissions: ["server.lifecycle"] },
|
||||
"logs.query": { permissions: ["server.logs.read"] },
|
||||
"artifacts.open": { permissions: ["server.artifacts.read"] },
|
||||
"files.request": { permissions: ["server.files.read"] },
|
||||
"ai.invoke": { permissions: ["ai.invoke"], aiPurposeRequired: true }
|
||||
};
|
||||
|
||||
export type PluginLifecycleAction = "install" | "start" | "stop" | "restart" | "status";
|
||||
|
||||
export type GamePluginActions = Partial<Record<PluginLifecycleAction, string>> & {
|
||||
install: string;
|
||||
start: string;
|
||||
stop: string;
|
||||
};
|
||||
|
||||
export interface GamePluginPage {
|
||||
key: string;
|
||||
title: string;
|
||||
path: string;
|
||||
permissions?: PluginPermission[];
|
||||
bridgeActions?: PluginBridgeAction[];
|
||||
}
|
||||
|
||||
export interface GamePluginBridge {
|
||||
actions: PluginBridgeAction[];
|
||||
}
|
||||
|
||||
export interface GamePluginManifest {
|
||||
id: `game.${string}`;
|
||||
name: string;
|
||||
description?: string;
|
||||
version: string;
|
||||
kind: "game-plugin";
|
||||
tags?: string[];
|
||||
server: {
|
||||
type: string;
|
||||
displayName: string;
|
||||
supportedOS?: Array<"windows" | "linux" | "darwin">;
|
||||
createFormSchema: string;
|
||||
};
|
||||
bridge?: GamePluginBridge;
|
||||
capabilities: RunCapability[];
|
||||
permissions: PluginPermission[];
|
||||
actions?: GamePluginActions;
|
||||
pages?: GamePluginPage[];
|
||||
ai?: {
|
||||
purposes?: AIPurpose[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginBridgeContext {
|
||||
pluginId: string;
|
||||
routeKey: string;
|
||||
serverInstanceId?: string;
|
||||
permissions: PluginPermission[];
|
||||
aiPurposes?: AIPurpose[];
|
||||
}
|
||||
|
||||
export function hasPluginPermission(context: PluginBridgeContext, permission: PluginPermission): boolean {
|
||||
return context.permissions.includes(permission);
|
||||
}
|
||||
|
||||
export function canRequestBridgeAction(
|
||||
context: PluginBridgeContext,
|
||||
action: PluginBridgeAction,
|
||||
options: { aiPurpose?: AIPurpose } = {}
|
||||
): boolean {
|
||||
const policy = pluginBridgeActionPolicies[action];
|
||||
if (!policy.permissions.every((permission) => hasPluginPermission(context, permission))) {
|
||||
return false;
|
||||
}
|
||||
if (policy.aiPurposeRequired) {
|
||||
return typeof options.aiPurpose === "string" && (context.aiPurposes ?? []).includes(options.aiPurpose);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createBridgeRequest<TPayload extends PluginBridgeRequestPayload>(input: {
|
||||
id: string;
|
||||
context: PluginBridgeContext;
|
||||
action: PluginBridgeAction;
|
||||
payload: TPayload;
|
||||
}): PluginBridgeRequest<TPayload> {
|
||||
return {
|
||||
id: input.id,
|
||||
pluginId: input.context.pluginId,
|
||||
routeKey: input.context.routeKey,
|
||||
serverInstanceId: input.context.serverInstanceId,
|
||||
action: input.action,
|
||||
payload: input.payload
|
||||
};
|
||||
}
|
||||
|
||||
export function createBridgeExecutionRequest<TPayload extends Record<string, string>>(input: {
|
||||
requestId: string;
|
||||
context: PluginBridgeContext;
|
||||
action: PluginBridgeAction;
|
||||
aiPurpose?: AIPurpose;
|
||||
payload?: TPayload;
|
||||
}): PluginBridgeExecutionRequest<TPayload> {
|
||||
return {
|
||||
requestId: input.requestId,
|
||||
pluginId: input.context.pluginId,
|
||||
routeKey: input.context.routeKey,
|
||||
serverInstanceId: input.context.serverInstanceId,
|
||||
action: input.action,
|
||||
aiPurpose: input.aiPurpose,
|
||||
payload: input.payload
|
||||
};
|
||||
}
|
||||
|
||||
export function createArtifactOpenRequest(input: {
|
||||
requestId: string;
|
||||
context: PluginBridgeContext;
|
||||
artifactId: string;
|
||||
}): PluginBridgeExecutionRequest<PluginArtifactOpenPayload> {
|
||||
return createBridgeExecutionRequest({
|
||||
requestId: input.requestId,
|
||||
context: input.context,
|
||||
action: "artifacts.open",
|
||||
payload: { artifactId: input.artifactId }
|
||||
});
|
||||
}
|
||||
|
||||
export function createLifecycleDispatchRequest(input: {
|
||||
requestId: string;
|
||||
context: PluginBridgeContext;
|
||||
action: "start" | "stop";
|
||||
expectedConfigVersion: number;
|
||||
idempotencyKey: string;
|
||||
}): PluginBridgeExecutionRequest<PluginLifecycleDispatchPayload> {
|
||||
return createBridgeExecutionRequest({
|
||||
requestId: input.requestId,
|
||||
context: input.context,
|
||||
action: "jobs.dispatch",
|
||||
payload: {
|
||||
lifecycleAction: input.action,
|
||||
capability: input.action === "start" ? "process.start" : "process.stop",
|
||||
expectedConfigVersion: String(input.expectedConfigVersion),
|
||||
idempotencyKey: input.idempotencyKey
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function parseArtifactReference(result: Record<string, string> | undefined): PluginArtifactReference | undefined {
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
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 undefined;
|
||||
}
|
||||
if (!reference.downloadUrl.startsWith(`/api/v1/artifacts/${encodeURIComponent(reference.artifactId)}/content`)) {
|
||||
return undefined;
|
||||
}
|
||||
for (const value of Object.values(reference)) {
|
||||
if (typeof value === "string" && containsUnsafeReferenceContent(value)) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return reference;
|
||||
}
|
||||
|
||||
export function parseBridgeExecutionResponse<TResult extends Record<string, string>>(response: PluginBridgeExecutionResponse<TResult>): PluginBridgeExecutionResponse<TResult> {
|
||||
const safeError = response.error ? bridgeError(response.error.code, response.error.message, response.error.details ?? []) : undefined;
|
||||
return {
|
||||
requestId: response.requestId,
|
||||
pluginId: response.pluginId,
|
||||
routeKey: response.routeKey,
|
||||
serverInstanceId: response.serverInstanceId,
|
||||
action: response.action,
|
||||
status: response.status,
|
||||
result: response.result ? { ...response.result } : undefined,
|
||||
error: safeError
|
||||
};
|
||||
}
|
||||
|
||||
export function createAIInvocationRequest(input: {
|
||||
requestId: string;
|
||||
context: PluginBridgeContext;
|
||||
purpose: AIPurpose;
|
||||
prompt: string;
|
||||
currentConfig?: string;
|
||||
contextRefs?: Record<string, string>;
|
||||
}): PluginAIInvocationRequest {
|
||||
return {
|
||||
requestId: input.requestId,
|
||||
pluginId: input.context.pluginId,
|
||||
routeKey: input.context.routeKey,
|
||||
serverInstanceId: input.context.serverInstanceId,
|
||||
purpose: input.purpose,
|
||||
prompt: input.prompt,
|
||||
currentConfig: input.currentConfig,
|
||||
contextRefs: input.contextRefs
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAIInvocationResponse(response: PluginAIInvocationResponse): PluginAIInvocationResponse {
|
||||
return {
|
||||
requestId: response.requestId,
|
||||
purpose: response.purpose,
|
||||
status: response.status,
|
||||
recommendation: response.recommendation,
|
||||
suggestedConfig: response.suggestedConfig,
|
||||
usage: response.usage ? { ...response.usage } : undefined,
|
||||
error: response.error ? bridgeError(response.error.code, response.error.message, response.error.details ?? []) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function bridgeError(
|
||||
code: PluginBridgeError["code"],
|
||||
message: string,
|
||||
details: string[] = []
|
||||
): PluginBridgeError {
|
||||
return { code, message, details };
|
||||
}
|
||||
|
||||
function containsUnsafeReferenceContent(value: string): boolean {
|
||||
const lowered = value.trim().toLowerCase();
|
||||
return (
|
||||
lowered.includes("/users/") ||
|
||||
lowered.includes("/private/") ||
|
||||
lowered.includes("unix://") ||
|
||||
lowered.includes("tcp://") ||
|
||||
lowered.includes("bearer ") ||
|
||||
lowered.includes("password=") ||
|
||||
lowered.includes("api_key=") ||
|
||||
lowered.includes("apikey=") ||
|
||||
lowered.includes("storage://") ||
|
||||
lowered.includes("file://") ||
|
||||
lowered.startsWith("sk-")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user