first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1602
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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-")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"fields": [
|
||||
{
|
||||
"key": "serverName",
|
||||
"type": "text",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+10
@@ -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
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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-");
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user