Files
browser/plugins/sdk/index.ts
T

678 lines
20 KiB
TypeScript

export type PluginPermission =
| "server.create"
| "server.read"
| "server.lifecycle"
| "server.files.read"
| "server.files.write"
| "server.logs.read"
| "server.artifacts.read"
| "server.artifacts.write"
| "server.remote.access"
| "server.run.distribution"
| "server.dependencies.manage"
| "server.client-manager.manage"
| "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"
| "remote.ftp.read"
| "remote.ftp.write"
| "remote.rsync.read"
| "remote.rsync.write"
| "remote.run.files.read"
| "remote.run.files.write"
| "remote.run.process.start"
| "remote.run.process.stop"
| "remote.run.db.mysql.query"
| "remote.run.db.sqlite.query"
| "remote.run.logs.transfer"
| "remote.run.rcon.command"
| "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"
| "remote.access.request"
| "run.distribution.request"
| "dependencies.request"
| "logs.backfill.request"
| "client-manager.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 type PluginRemoteAccessPayload = Record<string, string> & {
capability: Extract<RunCapability, `remote.${string}`>;
targetKey?: string;
inputRef?: string;
idempotencyKey: string;
};
export type PluginRunDistributionPayload = Record<string, string> & {
operation: "generate" | "download" | "reset-key" | "update";
targetOS?: RuntimePlatform;
targetArch?: RuntimeArch;
artifactId?: string;
idempotencyKey: string;
};
export type PluginDependencyActionPayload = Record<string, string> & {
operation: "check" | "install";
probeKey?: string;
planKey?: string;
idempotencyKey: string;
};
export type PluginLogBackfillPayload = Record<string, string> & {
sourceKey: string;
cursor?: string;
limit?: string;
idempotencyKey: string;
};
export type PluginClientManagerPayload = Record<string, string> & {
operation: "generate" | "download" | "reset-key";
profileKey: string;
targetOS?: RuntimePlatform;
targetArch?: RuntimeArch;
artifactId?: string;
idempotencyKey: string;
};
export type RemoteAccessMethod = "ftp" | "rsync" | "run";
export type RemoteDatabaseEngine = "mysql" | "sqlite";
export interface GamePluginRemoteAccess {
methods: RemoteAccessMethod[];
runCapabilities?: Array<Extract<RunCapability, `remote.${string}`>>;
databaseEngines?: RemoteDatabaseEngine[];
rcon?: boolean;
logTransfer?: boolean;
}
export type RuntimePlatform = "windows" | "linux" | "darwin";
export type RuntimeArch = "amd64" | "arm64";
export type RuntimeTarget = { os: RuntimePlatform; arch: RuntimeArch };
export interface RuntimeDiscoveryProbe {
key: string;
kind: "file.exists" | "command.version" | "service.status" | "port.open" | "steam.app" | "docker.container";
targetKey: string;
required?: boolean;
expected?: string;
platforms?: RuntimePlatform[];
}
export interface RuntimeLifecycleProfile {
key: string;
mode: "local-process" | "hosted-ftp-rcon" | "ftp-only" | "custom-client";
capabilities: RunCapability[];
actionRefs?: Partial<Record<PluginLifecycleAction, string>>;
transportKeys?: string[];
clientManagerRef?: string;
platforms?: RuntimePlatform[];
}
export interface RuntimeDependencyProbe {
key: string;
kind: "command.version" | "service.exists" | "port.available" | "steam.app" | "java.version" | "docker.available" | "package.installed" | "file.exists";
targetKey: string;
required?: boolean;
minimumVersion?: string;
platforms?: RuntimePlatform[];
}
export interface RuntimeInstallStep {
type: "package" | "verified-download" | "steamcmd-app" | "manual";
targetKey: string;
packageManager?: "winget" | "choco" | "scoop" | "apt" | "yum" | "dnf" | "pacman" | "zypper" | "brew" | "steamcmd" | "manual";
packageName?: string;
version?: string;
downloadRef?: string;
checksum?: `sha256:${string}`;
}
export interface RuntimeInstallPlan {
key: string;
title: string;
platforms?: RuntimePlatform[];
steps: RuntimeInstallStep[];
}
export interface RuntimeLogSource {
key: string;
kind: "process.stdout" | "process.stderr" | "file.tail" | "ftp.poll" | "sql.query" | "client-manager";
targetKey?: string;
streamKey: string;
cursorKind?: "sequence" | "offset" | "fingerprint" | "ftp-listing" | "sql-cursor";
retentionDays?: number;
}
export interface RuntimeTransportProfile {
key: string;
kind: "file" | "ftp" | "rsync" | "mysql" | "sqlite" | "rcon";
targetKey?: string;
capabilities: RunCapability[];
}
export interface RuntimeClientManagerProfile {
key: string;
displayName?: string;
repository: {
url: string;
revisionPolicy: "pinned" | "branch" | "tag";
branch?: string;
tag?: string;
revision?: string;
};
supportedTargets: RuntimeTarget[];
build: {
system: "go" | "npm" | "cargo" | "make";
workspaceRef?: string;
entryRef?: string;
};
configTemplates?: Array<{ key: string; templateRef: string; outputRef: string }>;
outputArtifacts: string[];
}
export interface GamePluginRuntimeProfiles {
discovery?: RuntimeDiscoveryProbe[];
lifecycleProfiles?: RuntimeLifecycleProfile[];
dependencyProbes?: RuntimeDependencyProbe[];
installPlans?: RuntimeInstallPlan[];
logSources?: RuntimeLogSource[];
transportProfiles?: RuntimeTransportProfile[];
clientManagers?: RuntimeClientManagerProfile[];
}
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"] },
"remote.access.request": { permissions: ["server.remote.access"] },
"run.distribution.request": { permissions: ["server.run.distribution"] },
"dependencies.request": { permissions: ["server.dependencies.manage"] },
"logs.backfill.request": { permissions: ["server.logs.read"] },
"client-manager.request": { permissions: ["server.client-manager.manage"] },
"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[];
remoteAccess?: GamePluginRemoteAccess;
runtimeProfiles?: GamePluginRuntimeProfiles;
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 createRemoteAccessRequest(input: {
requestId: string;
context: PluginBridgeContext;
capability: PluginRemoteAccessPayload["capability"];
targetKey?: string;
inputRef?: string;
idempotencyKey: string;
}): PluginBridgeExecutionRequest<PluginRemoteAccessPayload> {
return createBridgeExecutionRequest({
requestId: input.requestId,
context: input.context,
action: "remote.access.request",
payload: {
capability: input.capability,
targetKey: input.targetKey ?? "",
inputRef: input.inputRef ?? "",
idempotencyKey: input.idempotencyKey
}
});
}
export function createRunDistributionRequest(input: {
requestId: string;
context: PluginBridgeContext;
operation: PluginRunDistributionPayload["operation"];
targetOS?: RuntimePlatform;
targetArch?: RuntimeArch;
artifactId?: string;
idempotencyKey: string;
}): PluginBridgeExecutionRequest<PluginRunDistributionPayload> {
const payload: PluginRunDistributionPayload = {
operation: input.operation,
artifactId: input.artifactId ?? "",
idempotencyKey: input.idempotencyKey
};
if (input.targetOS) {
payload.targetOS = input.targetOS;
}
if (input.targetArch) {
payload.targetArch = input.targetArch;
}
return createBridgeExecutionRequest({
requestId: input.requestId,
context: input.context,
action: "run.distribution.request",
payload
});
}
export function createDependencyActionRequest(input: {
requestId: string;
context: PluginBridgeContext;
operation: PluginDependencyActionPayload["operation"];
probeKey?: string;
planKey?: string;
idempotencyKey: string;
}): PluginBridgeExecutionRequest<PluginDependencyActionPayload> {
return createBridgeExecutionRequest({
requestId: input.requestId,
context: input.context,
action: "dependencies.request",
payload: {
operation: input.operation,
probeKey: input.probeKey ?? "",
planKey: input.planKey ?? "",
idempotencyKey: input.idempotencyKey
}
});
}
export function createLogBackfillRequest(input: {
requestId: string;
context: PluginBridgeContext;
sourceKey: string;
cursor?: string;
limit?: number;
idempotencyKey: string;
}): PluginBridgeExecutionRequest<PluginLogBackfillPayload> {
return createBridgeExecutionRequest({
requestId: input.requestId,
context: input.context,
action: "logs.backfill.request",
payload: {
sourceKey: input.sourceKey,
cursor: input.cursor ?? "",
limit: typeof input.limit === "number" ? String(input.limit) : "",
idempotencyKey: input.idempotencyKey
}
});
}
export function createClientManagerRequest(input: {
requestId: string;
context: PluginBridgeContext;
operation: PluginClientManagerPayload["operation"];
profileKey: string;
targetOS?: RuntimePlatform;
targetArch?: RuntimeArch;
artifactId?: string;
idempotencyKey: string;
}): PluginBridgeExecutionRequest<PluginClientManagerPayload> {
const payload: PluginClientManagerPayload = {
operation: input.operation,
profileKey: input.profileKey,
artifactId: input.artifactId ?? "",
idempotencyKey: input.idempotencyKey
};
if (input.targetOS) {
payload.targetOS = input.targetOS;
}
if (input.targetArch) {
payload.targetArch = input.targetArch;
}
return createBridgeExecutionRequest({
requestId: input.requestId,
context: input.context,
action: "client-manager.request",
payload
});
}
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-")
);
}