first commit

This commit is contained in:
npc0-hue
2026-07-11 14:56:10 +08:00
commit 7e05d0a4e7
660 changed files with 78119 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# AGENTS.md for plugins
This file applies to `plugins/`.
## Plugin Boundary
Plugins are game management plugins. Do not model unrelated billing, cloud sales, host provider behavior, or independent plugin backend services here.
## Structure Rules
Manifests, schemas, SDK contracts, example plugins, and test fixtures must live in dedicated directories. Do not bury action schemas or bridge types inside UI page code.
## Security Rules
Plugins must never receive raw AI provider keys, run credentials, raw host paths, or direct storage endpoints. Use platform-mediated bridge calls for jobs, logs, files, artifacts, and AI.
## Local Development
Local plugin development should exercise real platform-run flows through dev registration instead of bypassing platform authorization.
+49
View File
@@ -0,0 +1,49 @@
# plugins
Game management plugin workspace.
A game management plugin defines how the platform creates and manages one type of game server. One installed plugin can create many server instances.
## Plugin Responsibilities
- Manifest identity and server type declaration.
- Create-server form schema.
- Lifecycle action definitions.
- Required run capabilities.
- Optional plugin pages hosted by platform_web.
- AI/file/log permissions declared for platform authorization.
## Required Directory Plan
Implementation should use dedicated directories for:
- `manifests/`: plugin manifest examples or shared manifest schemas.
- `schemas/`: action, config, and create-form schemas.
- `sdk/`: plugin bridge SDK and shared contracts.
- `examples/`: example game management plugins.
- `tests/`: plugin contract tests and fixtures.
Plugins must use the platform bridge and must not connect directly to run, log storage, artifact storage internals, or AI provider endpoints.
Manifest validation is the plugin-side installability gate. The shared manifest schema defines identity, version, server type/display metadata, create form schema reference, lifecycle action references, required run capabilities, scoped permissions, optional pages, tags, and AI purposes. `scripts/validate-manifest.ts` also scans manifest and create-form content for unsafe raw host path, raw credential, direct run, and raw AI/provider key requests.
Validated manifests are registered through the platform registry API rather than by plugin code importing platform internals. Platform stores registry metadata only and repeats safety validation before a plugin becomes installable.
## Development Baseline
Tooling:
- Node 22.17.0.
- npm 11.6.1.
- TypeScript 5.
Commands:
```bash
npm install
npm run typecheck
npm run test
npm run validate:manifest
```
Current plugin behavior includes SDK bridge contracts, manifest schema validation, the `examples/dev-game-plugin` fixture, platform registry metadata registration, marketplace projections, hosted plugin-page bridge execution, and platform-mediated lifecycle job dispatch. Marketplace package acquisition, remote plugin hosting policies, and external package distribution remain future OpenSpec work.
+24
View File
@@ -0,0 +1,24 @@
# Local Plugin Development
Local development must exercise the same platform-mediated flows used by installed plugins.
## Flow
1. Validate the plugin manifest with `npm run validate:manifest`.
2. Register the validated manifest with platform `POST /api/v1/game-plugins/register-manifest` using a manifest artifact reference and the manifest payload.
3. Serve plugin page from a local dev server or static directory.
4. Select a test server instance and run endpoint.
5. Use platform bridge calls for jobs, logs, files, artifacts, and AI.
Plugins must not bypass platform authorization by calling run directly.
Manifest validation rejects raw host paths, raw credentials, direct run sockets, and raw AI/provider keys. Platform registration repeats the same safety class of validation before storing registry metadata, so a plugin workspace validation result is not treated as a trusted backend bypass.
For full local stack proof, run the repository local debug workspace:
```bash
scripts/local-debug-start.sh
scripts/local-debug-smoke.sh
```
That workflow registers `plugins/examples/dev-game-plugin/manifest.json`, creates a safe `server-local-debug` fixture through platform APIs, and verifies that plugin/browser evidence exposes only logical IDs, platform routes, job refs, log refs, artifact refs, and safe metadata.
@@ -0,0 +1,83 @@
{
"$schema": "../../manifests/game-plugin.manifest.schema.json",
"id": "game.example",
"name": "Example Server",
"description": "Development game management plugin for local registry and lifecycle testing.",
"version": "0.1.0",
"kind": "game-plugin",
"tags": ["example", "development"],
"server": {
"type": "example",
"displayName": "Example Server",
"supportedOS": ["windows", "linux", "darwin"],
"createFormSchema": "schemas/create-form.schema.json"
},
"capabilities": [
"process.install",
"process.start",
"process.stop",
"process.restart",
"process.status",
"files.list",
"files.read",
"files.patch",
"logs.read",
"artifacts.read",
"artifacts.write",
"ai.invoke"
],
"bridge": {
"actions": [
"server.instances.read",
"jobs.dispatch",
"logs.query",
"artifacts.open",
"files.request",
"ai.invoke"
]
},
"permissions": [
"server.create",
"server.read",
"server.lifecycle",
"server.files.read",
"server.files.write",
"server.logs.read",
"server.artifacts.read",
"server.artifacts.write",
"ai.invoke"
],
"actions": {
"install": "actions/install.json",
"start": "actions/start.json",
"stop": "actions/stop.json",
"restart": "actions/restart.json",
"status": "actions/status.json"
},
"pages": [
{
"key": "overview",
"title": "概览",
"path": "/overview",
"permissions": ["server.read"],
"bridgeActions": ["server.instances.read"]
},
{
"key": "config",
"title": "配置",
"path": "/config",
"permissions": ["server.files.read", "server.files.write", "ai.invoke"],
"bridgeActions": ["files.request", "ai.invoke"]
},
{
"key": "logs",
"title": "日志",
"path": "/logs",
"permissions": ["server.logs.read", "ai.invoke"],
"bridgeActions": ["logs.query", "ai.invoke"]
}
],
"ai": {
"purposes": ["config.suggest", "logs.diagnose"]
}
}
@@ -0,0 +1,17 @@
{
"fields": [
{
"key": "serverName",
"label": "服务器名称",
"type": "text",
"required": true
},
{
"key": "gamePort",
"label": "游戏端口",
"type": "port",
"required": true,
"default": 27015
}
]
}
@@ -0,0 +1,8 @@
{
"command": ["true"],
"env": {
"GAME_ID": "scum",
"SERVER_TEMPLATE": "scum-local-proof"
},
"timeoutMs": 30000
}
@@ -0,0 +1,8 @@
{
"command": ["true"],
"env": {
"GAME_ID": "scum",
"SERVER_ACTION": "restart"
},
"timeoutMs": 30000
}
@@ -0,0 +1,8 @@
{
"command": ["true"],
"env": {
"GAME_ID": "scum",
"SERVER_ACTION": "start"
},
"timeoutMs": 30000
}
@@ -0,0 +1,8 @@
{
"command": ["true"],
"env": {
"GAME_ID": "scum",
"SERVER_ACTION": "status"
},
"timeoutMs": 30000
}
@@ -0,0 +1,8 @@
{
"command": ["true"],
"env": {
"GAME_ID": "scum",
"SERVER_ACTION": "stop"
},
"timeoutMs": 30000
}
@@ -0,0 +1,83 @@
{
"$schema": "../../manifests/game-plugin.manifest.schema.json",
"id": "game.scum",
"name": "SCUM Server",
"description": "First-party local SCUM game server management plugin for platform-mediated lifecycle proof.",
"version": "0.1.0",
"kind": "game-plugin",
"tags": ["scum", "survival", "dedicated-server", "local-proof"],
"server": {
"type": "scum",
"displayName": "SCUM Dedicated Server",
"supportedOS": ["windows", "linux"],
"createFormSchema": "schemas/create-form.schema.json"
},
"capabilities": [
"process.install",
"process.start",
"process.stop",
"process.restart",
"process.status",
"files.list",
"files.read",
"files.patch",
"logs.read",
"artifacts.read",
"artifacts.write",
"ai.invoke"
],
"bridge": {
"actions": [
"server.instances.read",
"jobs.dispatch",
"logs.query",
"artifacts.open",
"files.request",
"ai.invoke"
]
},
"permissions": [
"server.create",
"server.read",
"server.lifecycle",
"server.files.read",
"server.files.write",
"server.logs.read",
"server.artifacts.read",
"server.artifacts.write",
"ai.invoke"
],
"actions": {
"install": "actions/install.json",
"start": "actions/start.json",
"stop": "actions/stop.json",
"restart": "actions/restart.json",
"status": "actions/status.json"
},
"pages": [
{
"key": "overview",
"title": "SCUM 概览",
"path": "/overview",
"permissions": ["server.read", "server.lifecycle"],
"bridgeActions": ["server.instances.read", "jobs.dispatch"]
},
{
"key": "config",
"title": "SCUM 配置",
"path": "/config",
"permissions": ["server.files.read", "server.files.write", "ai.invoke"],
"bridgeActions": ["files.request", "ai.invoke"]
},
{
"key": "logs",
"title": "SCUM 日志",
"path": "/logs",
"permissions": ["server.logs.read", "server.artifacts.read", "ai.invoke"],
"bridgeActions": ["logs.query", "artifacts.open", "ai.invoke"]
}
],
"ai": {
"purposes": ["config.suggest", "logs.diagnose"]
}
}
@@ -0,0 +1,31 @@
{
"fields": [
{
"key": "serverName",
"label": "SCUM 服务器名称",
"type": "text",
"required": true
},
{
"key": "gamePort",
"label": "游戏端口",
"type": "port",
"required": true,
"default": 7777
},
{
"key": "queryPort",
"label": "查询端口",
"type": "port",
"required": true,
"default": 27015
},
{
"key": "maxPlayers",
"label": "最大玩家数",
"type": "number",
"required": true,
"default": 64
}
]
}
@@ -0,0 +1,145 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://browser.local/schemas/game-plugin.manifest.schema.json",
"title": "GamePluginManifest",
"type": "object",
"required": ["id", "name", "version", "kind", "server", "capabilities", "permissions"],
"additionalProperties": false,
"properties": {
"$schema": { "type": "string" },
"id": { "type": "string", "pattern": "^game\\.[a-z0-9][a-z0-9._-]*$" },
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
"description": { "type": "string", "minLength": 1, "maxLength": 240 },
"version": { "type": "string", "minLength": 1, "maxLength": 40 },
"kind": { "const": "game-plugin" },
"tags": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"uniqueItems": true,
"maxItems": 8
},
"server": {
"type": "object",
"required": ["type", "displayName", "createFormSchema"],
"additionalProperties": false,
"properties": {
"type": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$", "maxLength": 80 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
"supportedOS": { "type": "array", "items": { "enum": ["windows", "linux", "darwin"] } },
"createFormSchema": { "$ref": "#/$defs/relativeJsonRef" }
}
},
"bridge": {
"type": "object",
"required": ["actions"],
"additionalProperties": false,
"properties": {
"actions": {
"type": "array",
"items": { "$ref": "#/$defs/bridgeAction" },
"uniqueItems": true,
"minItems": 1
}
}
},
"capabilities": {
"type": "array",
"items": { "$ref": "#/$defs/runCapability" },
"uniqueItems": true
},
"permissions": {
"type": "array",
"items": { "$ref": "#/$defs/pluginPermission" },
"uniqueItems": true
},
"actions": {
"type": "object",
"required": ["install", "start", "stop"],
"additionalProperties": false,
"properties": {
"install": { "$ref": "#/$defs/relativeJsonRef" },
"start": { "$ref": "#/$defs/relativeJsonRef" },
"stop": { "$ref": "#/$defs/relativeJsonRef" },
"restart": { "$ref": "#/$defs/relativeJsonRef" },
"status": { "$ref": "#/$defs/relativeJsonRef" }
}
},
"pages": {
"type": "array",
"items": {
"type": "object",
"required": ["key", "title", "path"],
"additionalProperties": false,
"properties": {
"key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
"title": { "type": "string", "minLength": 1, "maxLength": 40 },
"path": { "type": "string", "pattern": "^/[a-z0-9_./-]*$" },
"permissions": { "type": "array", "items": { "$ref": "#/$defs/pluginPermission" }, "uniqueItems": true },
"bridgeActions": { "type": "array", "items": { "$ref": "#/$defs/bridgeAction" }, "uniqueItems": true }
}
}
},
"ai": {
"type": "object",
"additionalProperties": false,
"properties": {
"purposes": { "type": "array", "items": { "$ref": "#/$defs/aiPurpose" }, "uniqueItems": true }
}
}
},
"$defs": {
"relativeJsonRef": {
"type": "string",
"pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+\\.json$"
},
"runCapability": {
"enum": [
"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"
]
},
"pluginPermission": {
"enum": [
"server.create",
"server.read",
"server.lifecycle",
"server.files.read",
"server.files.write",
"server.logs.read",
"server.artifacts.read",
"server.artifacts.write",
"ai.invoke"
]
},
"bridgeAction": {
"enum": [
"server.instances.read",
"jobs.dispatch",
"logs.query",
"artifacts.open",
"files.request",
"ai.invoke"
]
},
"aiPurpose": {
"enum": [
"config.read",
"config.generate",
"config.suggest",
"logs.diagnose",
"files.suggest"
]
}
}
}
+1602
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "browser-game-plugins",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"validate:manifest": "tsx scripts/validate-manifest.ts examples/dev-game-plugin/manifest.json && tsx scripts/validate-manifest.ts examples/scum-server-plugin/manifest.json"
},
"dependencies": {
"ajv": "8.18.0"
},
"devDependencies": {
"@types/node": "25.3.3",
"tsx": "4.21.0",
"typescript": "5.9.3",
"vitest": "4.0.18"
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://browser.local/schemas/plugin-create-form.schema.json",
"title": "PluginCreateFormSchema",
"type": "object",
"required": ["fields"],
"additionalProperties": false,
"properties": {
"fields": {
"type": "array",
"items": {
"type": "object",
"required": ["key", "label", "type"],
"additionalProperties": false,
"properties": {
"key": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9_]*$" },
"label": { "type": "string", "minLength": 1, "maxLength": 60 },
"type": { "enum": ["text", "number", "boolean", "select", "path", "port"] },
"required": { "type": "boolean" },
"default": {},
"options": { "type": "array", "items": { "type": "string" } }
}
}
}
}
}
+150
View File
@@ -0,0 +1,150 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Ajv2020, type AnySchema, type ErrorObject } from "ajv/dist/2020.js";
const rootDir = fileURLToPath(new URL("..", import.meta.url));
const manifestSchemaPath = path.join(rootDir, "manifests", "game-plugin.manifest.schema.json");
const createFormSchemaPath = path.join(rootDir, "schemas", "create-form.schema.json");
function readJson(filePath: string): unknown {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function formatErrors(prefix: string, errors: ErrorObject[] | null | undefined): string[] {
return (errors ?? []).map((error) => `${prefix}${error.instancePath}: ${error.message}`);
}
function unsafeFieldReason(fieldName: string): string | undefined {
const compact = fieldName.toLowerCase().replace(/[^a-z0-9]/g, "");
if (compact.includes("rawapikey") || compact.includes("apikey") || compact.includes("providerkey")) {
return "raw AI/provider key field is not allowed";
}
if (compact.includes("runcredential") || compact.includes("runsocket") || compact.includes("directrun")) {
return "direct run access field is not allowed";
}
if (compact.includes("hostpath") || compact.includes("rawpath")) {
return "raw host path field is not allowed";
}
return undefined;
}
function unsafeStringReasons(value: string): string[] {
const trimmed = value.trim();
const lowered = trimmed.toLowerCase();
if (trimmed === "") {
return [];
}
const reasons: string[] = [];
if (
lowered.startsWith("sk-") ||
lowered.startsWith("sk_") ||
lowered.includes("bearer ") ||
lowered.includes("api_key=") ||
lowered.includes("apikey=") ||
lowered.includes("raw api key") ||
lowered.includes("raw credential") ||
lowered.includes("provider key") ||
lowered.includes("ai key")
) {
reasons.push("raw credential or AI/provider key content is not allowed");
}
if (
lowered.includes("direct run") ||
lowered.includes("run socket") ||
lowered.includes("run credential") ||
lowered.includes("run token") ||
lowered.includes("direct socket")
) {
reasons.push("direct run access request is not allowed");
}
if (
lowered.startsWith("file://") ||
lowered.startsWith("\\\\") ||
/^[a-z]:[\\/]/i.test(trimmed) ||
/^\/(users|etc|var|tmp|home|root|private|volumes|opt)\//i.test(trimmed) ||
lowered.includes("host path") ||
lowered.includes("raw host path")
) {
reasons.push("raw host path access is not allowed");
}
return reasons;
}
function scanUnsafeValues(value: unknown, location: string): string[] {
if (typeof value === "string") {
return unsafeStringReasons(value).map((reason) => `${location}: ${reason}`);
}
if (Array.isArray(value)) {
return value.flatMap((item, index) => scanUnsafeValues(item, `${location}[${index}]`));
}
if (typeof value === "object" && value !== null) {
return Object.entries(value).flatMap(([key, child]) => {
const keyReason = unsafeFieldReason(key);
const keyErrors = keyReason ? [`${location}.${key}: ${keyReason}`] : [];
return [...keyErrors, ...scanUnsafeValues(child, `${location}.${key}`)];
});
}
return [];
}
function isSafeRelativeJsonRef(value: string): boolean {
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value);
}
export function validateManifestFile(manifestPath: string): string[] {
const absoluteManifestPath = path.resolve(rootDir, manifestPath);
const manifest = readJson(absoluteManifestPath);
const manifestDir = path.dirname(absoluteManifestPath);
const ajv = new Ajv2020({ allErrors: true });
const validateManifest = ajv.compile(readJson(manifestSchemaPath) as AnySchema);
const validateCreateForm = ajv.compile(readJson(createFormSchemaPath) as AnySchema);
const errors: string[] = [];
if (!validateManifest(manifest)) {
errors.push(...formatErrors("manifest", validateManifest.errors));
}
errors.push(...scanUnsafeValues(manifest, "manifest"));
if (typeof manifest === "object" && manifest !== null && "server" in manifest) {
const server = (manifest as { server?: { createFormSchema?: string } }).server;
if (server?.createFormSchema) {
if (!isSafeRelativeJsonRef(server.createFormSchema)) {
errors.push("createFormSchema: unsafe file reference");
return errors;
}
const createFormPath = path.resolve(manifestDir, server.createFormSchema);
if (!fs.existsSync(createFormPath)) {
errors.push(`createFormSchema: missing file ${server.createFormSchema}`);
} else {
const createForm = readJson(createFormPath);
if (!validateCreateForm(createForm)) {
errors.push(...formatErrors("createForm", validateCreateForm.errors));
}
errors.push(...scanUnsafeValues(createForm, "createForm"));
}
}
}
return errors;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
const target = process.argv[2];
if (!target) {
console.error("usage: npm run validate:manifest -- <manifest-path>");
process.exit(2);
}
const errors = validateManifestFile(target);
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}
console.log(`validated ${target}`);
}
+35
View File
@@ -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.
+384
View File
@@ -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-")
);
}
+9
View File
@@ -0,0 +1,9 @@
{
"fields": [
{
"key": "serverName",
"type": "text",
"required": true
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"fields": [
{
"key": "serverName",
"label": "Server name",
"type": "text",
"required": true
}
]
}
@@ -0,0 +1,20 @@
{
"$schema": "../../manifests/game-plugin.manifest.schema.json",
"id": "game.invalid-form",
"name": "Invalid Form Fixture",
"description": "Fixture with a create form schema violation.",
"version": "0.1.0",
"kind": "game-plugin",
"server": {
"type": "invalid-form",
"displayName": "Invalid Form Fixture",
"createFormSchema": "create-form.invalid.json"
},
"capabilities": ["process.start", "process.stop", "logs.read"],
"permissions": ["server.read", "server.lifecycle", "server.logs.read"],
"actions": {
"install": "actions/install.json",
"start": "actions/start.json",
"stop": "actions/stop.json"
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "../../manifests/game-plugin.manifest.schema.json",
"id": "game.unsafe",
"name": "Unsafe Fixture",
"description": "Requests direct run socket access and raw AI key material.",
"version": "0.1.0",
"kind": "game-plugin",
"server": {
"type": "unsafe",
"displayName": "Unsafe Fixture",
"createFormSchema": "create-form.valid.json"
},
"capabilities": ["process.start", "process.stop", "logs.read", "ai.invoke"],
"permissions": ["server.read", "server.lifecycle", "server.logs.read", "ai.invoke"],
"actions": {
"install": "actions/install.json",
"start": "actions/start.json",
"stop": "actions/stop.json"
},
"ai": {
"purposes": ["logs.diagnose"]
}
}
+269
View File
@@ -0,0 +1,269 @@
import { describe, expect, it } from "vitest";
import {
bridgeError,
canRequestBridgeAction,
createAIInvocationRequest,
createArtifactOpenRequest,
createBridgeExecutionRequest,
createLifecycleDispatchRequest,
createBridgeRequest,
hasPluginPermission,
parseArtifactReference,
parseBridgeExecutionResponse,
parseAIInvocationResponse,
type PluginBridgeContext
} from "../sdk/index.js";
import { validateManifestFile } from "../scripts/validate-manifest.js";
describe("plugin manifest validation", () => {
it("accepts the development example manifest", () => {
expect(validateManifestFile("examples/dev-game-plugin/manifest.json")).toEqual([]);
});
it("accepts the SCUM server plugin manifest", () => {
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
});
it("rejects a manifest with an invalid create form schema", () => {
const errors = validateManifestFile("tests/fixtures/invalid-create-form-manifest.json");
expect(errors.some((error) => error.includes("createForm") && error.includes("label"))).toBe(true);
});
it("rejects unsafe direct run and raw AI key requests", () => {
const errors = validateManifestFile("tests/fixtures/unsafe-manifest.json");
expect(errors.some((error) => error.includes("direct run access"))).toBe(true);
expect(errors.some((error) => error.includes("raw credential or AI/provider key"))).toBe(true);
});
});
describe("plugin SDK", () => {
it("checks declared bridge permissions", () => {
const context: PluginBridgeContext = {
pluginId: "game.example",
routeKey: "logs",
permissions: ["server.read", "server.logs.read", "server.artifacts.read"]
};
expect(hasPluginPermission(context, "server.logs.read")).toBe(true);
expect(hasPluginPermission(context, "server.artifacts.read")).toBe(true);
expect(hasPluginPermission(context, "ai.invoke")).toBe(false);
});
it("builds typed bridge request envelopes without owning transport", () => {
const context: PluginBridgeContext = {
pluginId: "game.example",
routeKey: "logs",
serverInstanceId: "server-1",
permissions: ["server.read", "server.logs.read"]
};
const request = createBridgeRequest({
id: "request-1",
context,
action: "logs.query",
payload: { streamKey: "stdout", limit: 100 }
});
expect(request).toEqual({
id: "request-1",
pluginId: "game.example",
routeKey: "logs",
serverInstanceId: "server-1",
action: "logs.query",
payload: { streamKey: "stdout", limit: 100 }
});
});
it("checks bridge action permissions and AI purposes locally", () => {
const context: PluginBridgeContext = {
pluginId: "game.example",
routeKey: "logs",
permissions: ["server.read", "server.logs.read", "ai.invoke"],
aiPurposes: ["logs.diagnose"]
};
expect(canRequestBridgeAction(context, "logs.query")).toBe(true);
expect(canRequestBridgeAction(context, "files.request")).toBe(false);
expect(canRequestBridgeAction(context, "ai.invoke", { aiPurpose: "logs.diagnose" })).toBe(true);
expect(canRequestBridgeAction(context, "ai.invoke", { aiPurpose: "config.suggest" })).toBe(false);
});
it("returns safe bridge errors without credential or transport fields", () => {
const error = bridgeError("missing_permission", "Permission is required", ["server.files.read"]);
expect(error).toEqual({
code: "missing_permission",
message: "Permission is required",
details: ["server.files.read"]
});
expect(error).not.toHaveProperty("apiKey");
expect(error).not.toHaveProperty("runSocket");
expect(error).not.toHaveProperty("hostPath");
});
it("builds and parses execution envelopes without owning transport", () => {
const context: PluginBridgeContext = {
pluginId: "game.example",
routeKey: "logs",
serverInstanceId: "server-1",
permissions: ["server.read", "server.logs.read"]
};
const request = createBridgeExecutionRequest({
requestId: "exec-1",
context,
action: "logs.query",
payload: { logStreamId: "log-1", limit: "100" }
});
expect(request).toEqual({
requestId: "exec-1",
pluginId: "game.example",
routeKey: "logs",
serverInstanceId: "server-1",
action: "logs.query",
aiPurpose: undefined,
payload: { logStreamId: "log-1", limit: "100" }
});
expect(request).not.toHaveProperty("fetch");
expect(request).not.toHaveProperty("authorization");
const parsed = parseBridgeExecutionResponse({
requestId: "exec-1",
pluginId: "game.example",
routeKey: "logs",
serverInstanceId: "server-1",
action: "logs.query",
status: "ok",
result: { entryCount: "0" }
});
expect(parsed).toMatchObject({ status: "ok", result: { entryCount: "0" } });
expect(parsed).not.toHaveProperty("apiKey");
expect(parsed).not.toHaveProperty("runSocket");
});
it("builds mediated AI requests and parses redacted responses", () => {
const context: PluginBridgeContext = {
pluginId: "game.example",
routeKey: "logs",
serverInstanceId: "server-1",
permissions: ["ai.invoke"],
aiPurposes: ["logs.diagnose"]
};
const request = createAIInvocationRequest({
requestId: "ai-1",
context,
purpose: "logs.diagnose",
prompt: "Summarize warnings",
contextRefs: { server: "server://server-1" }
});
expect(request).toMatchObject({ pluginId: "game.example", purpose: "logs.diagnose", prompt: "Summarize warnings" });
expect(request).not.toHaveProperty("apiKeyRef");
expect(request).not.toHaveProperty("providerBaseUrl");
const parsed = parseAIInvocationResponse({
requestId: "ai-1",
purpose: "logs.diagnose",
status: "ok",
recommendation: "Review the warning trend.",
usage: { model: "mock", mocked: true, inputTokens: 5, outputTokens: 6 }
});
expect(parsed).toMatchObject({ status: "ok", usage: { mocked: true } });
expect(JSON.stringify(parsed)).not.toContain("sk-");
expect(JSON.stringify(parsed)).not.toContain("apiKeyRef");
});
it("builds artifact open envelopes and parses safe platform references", () => {
const context: PluginBridgeContext = {
pluginId: "game.example",
routeKey: "logs",
serverInstanceId: "server-1",
permissions: ["server.artifacts.read"]
};
expect(canRequestBridgeAction(context, "artifacts.open")).toBe(true);
expect(
createArtifactOpenRequest({ requestId: "artifact-open-1", context, artifactId: "artifact-1" })
).toEqual({
requestId: "artifact-open-1",
pluginId: "game.example",
routeKey: "logs",
serverInstanceId: "server-1",
action: "artifacts.open",
aiPurpose: undefined,
payload: { artifactId: "artifact-1" }
});
const reference = parseArtifactReference({
artifactId: "artifact-1",
filename: "artifact-1.bin",
contentType: "application/octet-stream",
sizeBytes: "64",
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"
});
expect(reference).toMatchObject({ artifactId: "artifact-1", rangeSupported: true });
expect(JSON.stringify(reference)).not.toContain("/Users/");
expect(JSON.stringify(reference)).not.toContain("storage://");
expect(JSON.stringify(reference)).not.toContain("Bearer ");
expect(
parseArtifactReference({
artifactId: "artifact-1",
filename: "artifact.bin",
contentType: "application/octet-stream",
sizeBytes: "64",
checksum: "sha256:abc",
downloadUrl: "storage://bucket/artifact-1",
expiresAt: "2026-07-03T00:15:00Z",
rangeSupported: "true",
chunkSizeBytes: "1048576"
})
).toBeUndefined();
});
it("builds lifecycle dispatch envelopes without direct run transport", () => {
const context: PluginBridgeContext = {
pluginId: "game.example",
routeKey: "overview",
serverInstanceId: "server-1",
permissions: ["server.lifecycle"]
};
expect(canRequestBridgeAction(context, "jobs.dispatch")).toBe(true);
const request = createLifecycleDispatchRequest({
requestId: "lifecycle-start-1",
context,
action: "start",
expectedConfigVersion: 2,
idempotencyKey: "idem-lifecycle-start"
});
expect(request).toEqual({
requestId: "lifecycle-start-1",
pluginId: "game.example",
routeKey: "overview",
serverInstanceId: "server-1",
action: "jobs.dispatch",
aiPurpose: undefined,
payload: {
lifecycleAction: "start",
capability: "process.start",
expectedConfigVersion: "2",
idempotencyKey: "idem-lifecycle-start"
}
});
expect(JSON.stringify(request)).not.toContain("http://");
expect(JSON.stringify(request)).not.toContain("unix://");
expect(JSON.stringify(request)).not.toContain("/Users/");
expect(JSON.stringify(request)).not.toContain("Bearer ");
expect(JSON.stringify(request)).not.toContain("sk-");
});
});
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": [
"sdk/**/*.ts",
"scripts/**/*.ts",
"tests/**/*.ts"
]
}