feat: 完整游戏运维功能

This commit is contained in:
npc0-hue
2026-07-18 09:04:01 +08:00
parent f3b14b7945
commit 48b8ad8d6c
187 changed files with 16607 additions and 1140 deletions
+10 -5
View File
@@ -40,18 +40,21 @@ Runtime profiles are declarative contracts, not executable scripts. A profile ca
- transport profiles for declared file, FTP/rsync, SQL, RCON, and run-mediated operations.
- client-manager build profiles for games such as SCUM that need a separate companion executable.
Client-manager profiles declare repository URL, revision policy, supported target OS/architecture pairs, build system hints, config template keys, dependency hints, and produced artifact paths. Platform performs target validation, creates a build record, injects a distinct server/component key into the generated package config, redacts build logs, and publishes a downloadable artifact. The run key and client-manager key are separate singleton keys in platform storage; resetting either key revokes packages from older generations and requires regenerating that component.
Client-manager profiles declare repository URL, revision policy, semantic version, supported target OS/architecture pairs, a fixed build adapter, config template keys, produced artifacts, and an optional complete lifecycle contract. The lifecycle contract names a safe relative executable, fixed arguments, required Run capabilities, bounded start/stop/restart/status/update/rollback/uninstall actions, heartbeat/process health thresholds, component capabilities, compatibility bounds, and a manual staged-update policy. It cannot contain arbitrary shell, absolute/traversing paths, direct sockets, endpoints, raw credentials, or secret/session values.
Platform performs target and lifecycle validation, creates a real build record, injects a distinct server/component key into the generated package config, redacts build logs, and publishes a downloadable artifact. For profiles with a complete lifecycle contract, the artifact can then be deployed by a typed Run job into a controlled workspace, registered using a separate short-lived component session, health-checked, controlled, updated/rolled back, revoked, and safely uninstalled. The Run key and client-manager key/session remain separate; resetting the client-manager key revokes old packages and sessions and requires a current-generation rebuild and redeploy.
Plugin pages may request these operations only through bridge helpers:
- `createRunDistributionRequest`: generate/download/reset/update run packages.
- `createDependencyActionRequest`: check or install declared dependency probes/plans.
- `createLogBackfillRequest`: request historical log cursors for declared sources.
- `createClientManagerRequest`: generate/download/reset declared client-manager packages.
- `createClientManagerRequest`: generate/download/reset or request safe status/deploy/control/update/rollback/revoke/retry/uninstall operations for declared client-manager packages.
- `parseClientManagerLifecycleStatus`: whitelist the plugin-visible status, version, health, artifact/job IDs, deployment generation, and allowed actions without component secrets or machine details.
Bridge envelopes carry operation names, profile keys, target platforms, artifact IDs, checkpoint refs, and idempotency keys only. The plugin SDK and manifest validation reject raw run keys, client-manager keys, FTP passwords, rsync endpoints, SQL DSNs, RCON passwords, direct run sockets, host paths, and arbitrary shell snippets.
Bridge envelopes carry operation names, profile keys, target platforms, artifact IDs, checkpoint refs, immutable reviewed dependency plan digests, and idempotency keys only. Dependency install bridge helpers require a `sha256:<64 hex>` reviewed plan digest; Platform re-resolves the declaration and rejects stale or missing approvals. The plugin SDK and manifest validation reject raw run keys, client-manager keys, FTP passwords, rsync endpoints, SQL DSNs, RCON passwords, direct run sockets, host paths, and arbitrary shell snippets.
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.
Validated manifests are registered through the platform registry API rather than by plugin code importing platform internals. Platform persists the validated runtime-profile declaration with the installed plugin contract and repeats safety validation before a plugin becomes installable. Per-server values are stored separately as platform-owned runtime bindings; plugin pages receive only logical readiness and never the stored values.
## Development Baseline
@@ -70,4 +73,6 @@ npm run test
npm run validate:manifest
```
Current plugin behavior includes SDK bridge contracts, manifest schema validation, the `examples/dev-game-plugin`, `examples/scum-server-plugin`, and `examples/minecraft-server-plugin` fixtures, platform registry metadata registration, marketplace projections, hosted plugin-page bridge execution, platform-mediated lifecycle job dispatch, declared remote access envelopes, runtime profile declarations, run distribution envelopes, typed dependency/log backfill requests, and SCUM-style client-manager build declarations. Marketplace package acquisition, private source credentials, public build-worker sandboxing, real FTP/rsync/database/RCON adapters beyond bounded envelopes, remote plugin hosting policies, and external package distribution remain future OpenSpec work.
Current plugin behavior includes SDK bridge contracts, manifest schema validation, the `examples/dev-game-plugin`, `examples/scum-server-plugin`, and `examples/minecraft-server-plugin` fixtures, platform registry metadata registration, marketplace projections, hosted plugin-page bridge execution, platform-mediated lifecycle job dispatch, declared remote access envelopes, runtime profile declarations, target-matched typed dependency plan requests, run distribution envelopes, typed dependency/log backfill requests, and a SCUM-style client-manager declaration with a complete bounded lifecycle contract. Minecraft deliberately remains a no-client-manager example so action gating proves the feature is optional. Marketplace package acquisition, private source credentials, public build-worker sandboxing, remote plugin hosting policies, production KMS/code signing/fleet rollout, and external package distribution remain future work.
Runtime-profile declarations do not provide a general secret vault, arbitrary machine execution, production code signing/KMS, or fleet orchestration. The durable Client Manager installation/session state, bounded scheduler, process supervisor, and isolated log/artifact/control channels are Platform/Run capabilities; plugins receive only declarations and safe status projections.
@@ -0,0 +1,9 @@
{
"version": 1,
"action": "install",
"mode": "oneshot",
"executableKey": "bin/install-server",
"arguments": [],
"environment": { "GAME_ID": "dev" },
"timeoutMs": 30000
}
@@ -0,0 +1,7 @@
{
"version": 1,
"action": "restart",
"mode": "control",
"environment": { "GAME_ID": "dev" },
"timeoutMs": 30000
}
@@ -0,0 +1,9 @@
{
"version": 1,
"action": "start",
"mode": "supervised",
"executableKey": "bin/game-server",
"arguments": ["--foreground"],
"environment": { "GAME_ID": "dev" },
"timeoutMs": 30000
}
@@ -0,0 +1,7 @@
{
"version": 1,
"action": "status",
"mode": "control",
"environment": { "GAME_ID": "dev" },
"timeoutMs": 30000
}
@@ -0,0 +1,7 @@
{
"version": 1,
"action": "stop",
"mode": "control",
"environment": { "GAME_ID": "dev" },
"stopTimeoutMs": 30000
}
@@ -1,6 +1,10 @@
{
"command": ["true"],
"env": {
"version": 1,
"action": "install",
"mode": "oneshot",
"executableKey": "bin/install-server",
"arguments": [],
"environment": {
"GAME_ID": "minecraft",
"SERVER_TEMPLATE": "minecraft-java"
},
@@ -1,6 +1,8 @@
{
"command": ["true"],
"env": {
"version": 1,
"action": "restart",
"mode": "control",
"environment": {
"GAME_ID": "minecraft",
"SERVER_ACTION": "restart"
},
@@ -1,6 +1,10 @@
{
"command": ["true"],
"env": {
"version": 1,
"action": "start",
"mode": "supervised",
"executableKey": "bin/game-server",
"arguments": ["--foreground"],
"environment": {
"GAME_ID": "minecraft",
"SERVER_ACTION": "start"
},
@@ -1,6 +1,8 @@
{
"command": ["true"],
"env": {
"version": 1,
"action": "status",
"mode": "control",
"environment": {
"GAME_ID": "minecraft",
"SERVER_ACTION": "status"
},
@@ -1,8 +1,10 @@
{
"command": ["true"],
"env": {
"version": 1,
"action": "stop",
"mode": "control",
"environment": {
"GAME_ID": "minecraft",
"SERVER_ACTION": "stop"
},
"timeoutMs": 30000
"stopTimeoutMs": 30000
}
@@ -1,6 +1,10 @@
{
"command": ["true"],
"env": {
"version": 1,
"action": "install",
"mode": "oneshot",
"executableKey": "bin/install-server",
"arguments": [],
"environment": {
"GAME_ID": "scum",
"SERVER_TEMPLATE": "scum-local-proof"
},
@@ -1,6 +1,8 @@
{
"command": ["true"],
"env": {
"version": 1,
"action": "restart",
"mode": "control",
"environment": {
"GAME_ID": "scum",
"SERVER_ACTION": "restart"
},
@@ -1,6 +1,10 @@
{
"command": ["true"],
"env": {
"version": 1,
"action": "start",
"mode": "supervised",
"executableKey": "bin/game-server",
"arguments": ["--foreground"],
"environment": {
"GAME_ID": "scum",
"SERVER_ACTION": "start"
},
@@ -1,6 +1,8 @@
{
"command": ["true"],
"env": {
"version": 1,
"action": "status",
"mode": "control",
"environment": {
"GAME_ID": "scum",
"SERVER_ACTION": "status"
},
@@ -1,8 +1,10 @@
{
"command": ["true"],
"env": {
"version": 1,
"action": "stop",
"mode": "control",
"environment": {
"GAME_ID": "scum",
"SERVER_ACTION": "stop"
},
"timeoutMs": 30000
"stopTimeoutMs": 30000
}
@@ -42,6 +42,11 @@
"remote.run.db.sqlite.query",
"remote.run.logs.transfer",
"remote.run.rcon.command",
"client-manager.deploy",
"client-manager.control",
"client-manager.update",
"client-manager.rollback",
"client-manager.uninstall",
"artifacts.read",
"artifacts.write",
"ai.invoke"
@@ -386,6 +391,7 @@
{
"key": "scum-client-manager",
"displayName": "SCUM Client Manager",
"version": "1.0.0",
"repository": {
"url": "https://github.com/F88888/scum_client.git",
"revisionPolicy": "branch",
@@ -411,7 +417,42 @@
],
"outputArtifacts": [
"scum_client.exe"
]
],
"deployment": {
"mode": "run-supervised",
"executableRef": "scum_client.exe",
"arguments": ["--config", "config.json"],
"autoStart": true,
"requiredRunCapabilities": [
"client-manager.deploy",
"client-manager.control",
"client-manager.update",
"client-manager.rollback",
"client-manager.uninstall"
]
},
"lifecycle": {
"actions": ["start", "stop", "restart", "status", "update", "rollback", "uninstall"],
"startupTimeoutSeconds": 60,
"stopTimeoutSeconds": 30
},
"health": {
"mode": "component-heartbeat",
"intervalSeconds": 15,
"degradedAfterSeconds": 45,
"offlineAfterSeconds": 120,
"requiredCapabilities": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"]
},
"compatibility": {
"minimumVersion": "1.0.0",
"allowDowngrade": false
},
"updatePolicy": {
"strategy": "manual-staged",
"requireApproval": true,
"healthConfirmationSeconds": 60,
"retainPrevious": true
}
}
]
}
@@ -182,6 +182,11 @@
"remote.run.db.sqlite.query",
"remote.run.logs.transfer",
"remote.run.rcon.command",
"client-manager.deploy",
"client-manager.control",
"client-manager.update",
"client-manager.rollback",
"client-manager.uninstall",
"artifacts.read",
"artifacts.write",
"ai.invoke"
@@ -307,7 +312,30 @@
"version": { "type": "string", "maxLength": 80 },
"downloadRef": { "type": "string", "pattern": "^https://[a-zA-Z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+$", "maxLength": 240 },
"checksum": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" }
}
},
"allOf": [
{
"if": { "properties": { "type": { "const": "package" } }, "required": ["type"] },
"then": {
"required": ["packageManager", "packageName"],
"properties": { "packageManager": { "enum": ["winget", "choco", "scoop", "apt", "yum", "dnf", "pacman", "zypper", "brew"] } }
}
},
{
"if": { "properties": { "type": { "const": "verified-download" } }, "required": ["type"] },
"then": { "required": ["downloadRef", "checksum"] }
},
{
"if": { "properties": { "type": { "const": "steamcmd-app" } }, "required": ["type"] },
"then": {
"required": ["packageName"],
"properties": {
"packageManager": { "const": "steamcmd" },
"packageName": { "type": "string", "pattern": "^[0-9]{1,12}$" }
}
}
}
]
},
"runtimeInstallPlan": {
"type": "object",
@@ -317,7 +345,7 @@
"key": { "$ref": "#/$defs/logicalKey" },
"title": { "type": "string", "minLength": 1, "maxLength": 80 },
"platforms": { "type": "array", "items": { "$ref": "#/$defs/runtimePlatform" }, "uniqueItems": true },
"steps": { "type": "array", "items": { "$ref": "#/$defs/runtimeInstallStep" }, "minItems": 1 }
"steps": { "type": "array", "items": { "$ref": "#/$defs/runtimeInstallStep" }, "minItems": 1, "maxItems": 64 }
}
},
"runtimeLogSource": {
@@ -349,6 +377,24 @@
"pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+$",
"maxLength": 160
},
"clientManagerComponentCapability": {
"enum": [
"component.register",
"component.heartbeat",
"component.health",
"component.control",
"game-client.bridge",
"logs.stream"
]
},
"clientManagerLifecycleAction": {
"enum": ["start", "stop", "restart", "status", "update", "rollback", "uninstall"]
},
"clientManagerArgument": {
"type": "string",
"pattern": "^[a-zA-Z0-9_./:=@+-]+$",
"maxLength": 120
},
"runtimeClientManagerProfile": {
"type": "object",
"required": ["key", "repository", "supportedTargets", "build", "outputArtifacts"],
@@ -356,6 +402,7 @@
"properties": {
"key": { "$ref": "#/$defs/logicalKey" },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$", "maxLength": 40 },
"repository": {
"type": "object",
"required": ["url", "revisionPolicy"],
@@ -393,8 +440,74 @@
},
"uniqueItems": true
},
"outputArtifacts": { "type": "array", "items": { "$ref": "#/$defs/relativePathRef" }, "minItems": 1, "uniqueItems": true }
}
"outputArtifacts": { "type": "array", "items": { "$ref": "#/$defs/relativePathRef" }, "minItems": 1, "uniqueItems": true },
"deployment": {
"type": "object",
"required": ["mode", "executableRef", "requiredRunCapabilities"],
"additionalProperties": false,
"properties": {
"mode": { "const": "run-supervised" },
"executableRef": { "$ref": "#/$defs/relativePathRef" },
"arguments": { "type": "array", "items": { "$ref": "#/$defs/clientManagerArgument" }, "maxItems": 32 },
"autoStart": { "type": "boolean" },
"requiredRunCapabilities": {
"type": "array",
"items": { "enum": ["client-manager.deploy", "client-manager.control", "client-manager.update", "client-manager.rollback", "client-manager.uninstall"] },
"uniqueItems": true,
"minItems": 1
}
}
},
"lifecycle": {
"type": "object",
"required": ["actions", "startupTimeoutSeconds", "stopTimeoutSeconds"],
"additionalProperties": false,
"properties": {
"actions": { "type": "array", "items": { "$ref": "#/$defs/clientManagerLifecycleAction" }, "uniqueItems": true, "minItems": 1 },
"startupTimeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 300 },
"stopTimeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 120 }
}
},
"health": {
"type": "object",
"required": ["mode", "intervalSeconds", "degradedAfterSeconds", "offlineAfterSeconds", "requiredCapabilities"],
"additionalProperties": false,
"properties": {
"mode": { "enum": ["component-heartbeat", "process"] },
"intervalSeconds": { "type": "integer", "minimum": 5, "maximum": 300 },
"degradedAfterSeconds": { "type": "integer", "minimum": 10, "maximum": 1800 },
"offlineAfterSeconds": { "type": "integer", "minimum": 15, "maximum": 3600 },
"requiredCapabilities": { "type": "array", "items": { "$ref": "#/$defs/clientManagerComponentCapability" }, "uniqueItems": true, "minItems": 1 }
}
},
"compatibility": {
"type": "object",
"required": ["allowDowngrade"],
"additionalProperties": false,
"properties": {
"minimumVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$", "maxLength": 40 },
"maximumVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$", "maxLength": 40 },
"allowDowngrade": { "type": "boolean" }
}
},
"updatePolicy": {
"type": "object",
"required": ["strategy", "requireApproval", "healthConfirmationSeconds", "retainPrevious"],
"additionalProperties": false,
"properties": {
"strategy": { "const": "manual-staged" },
"requireApproval": { "const": true },
"healthConfirmationSeconds": { "type": "integer", "minimum": 5, "maximum": 600 },
"retainPrevious": { "const": true }
}
}
},
"allOf": [
{
"if": { "required": ["deployment"] },
"then": { "required": ["version", "lifecycle", "health", "compatibility", "updatePolicy"] }
}
]
},
"aiPurpose": {
"enum": [
@@ -0,0 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://browser.local/schemas/lifecycle-action.schema.json",
"title": "PluginLifecycleActionDeclaration",
"type": "object",
"required": ["version", "action", "mode"],
"additionalProperties": false,
"properties": {
"version": { "const": 1 },
"action": { "enum": ["install", "start", "stop", "restart", "status"] },
"mode": { "enum": ["oneshot", "supervised", "control"] },
"executableKey": { "$ref": "game-plugin.manifest.schema.json#/$defs/relativePathRef" },
"arguments": {
"type": "array",
"items": { "type": "string", "minLength": 1, "maxLength": 240 },
"maxItems": 64
},
"environment": {
"type": "object",
"propertyNames": { "pattern": "^(GAME|SERVER|RUN)_[A-Z0-9_]{1,59}$" },
"additionalProperties": { "type": "string", "maxLength": 512 },
"maxProperties": 32
},
"timeoutMs": { "type": "integer", "minimum": 1, "maximum": 300000 },
"stopTimeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }
}
}
+189
View File
@@ -6,6 +6,7 @@ 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 lifecycleActionSchemaPath = path.join(rootDir, "manifests", "lifecycle-action.schema.json");
const createFormSchemaPath = path.join(rootDir, "schemas", "create-form.schema.json");
function readJson(filePath: string): unknown {
@@ -114,6 +115,179 @@ function isSafeRelativeJsonRef(value: string): boolean {
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value);
}
export function validateLifecycleActionFile(actionPath: string, expectedAction?: string): string[] {
const action = readJson(path.resolve(rootDir, actionPath));
const ajv = new Ajv2020({ allErrors: true });
ajv.addSchema(readJson(manifestSchemaPath) as AnySchema);
const validateAction = ajv.compile(readJson(lifecycleActionSchemaPath) as AnySchema);
const errors = validateAction(action) ? [] : formatErrors("lifecycleAction", validateAction.errors);
errors.push(...scanUnsafeValues(action, "lifecycleAction"));
if (typeof action !== "object" || action === null) {
return errors;
}
const declaration = action as { action?: string; mode?: string; executableKey?: string };
if (expectedAction && declaration.action !== expectedAction) {
errors.push(`lifecycleAction.action: expected ${expectedAction}`);
}
if ((declaration.action === "install" || declaration.action === "start") && !declaration.executableKey) {
errors.push("lifecycleAction.executableKey: required for install/start");
}
if (declaration.action === "start" && declaration.mode !== "supervised") {
errors.push("lifecycleAction.mode: start must be supervised");
}
if ((declaration.action === "stop" || declaration.action === "status") && declaration.mode !== "control") {
errors.push(`lifecycleAction.mode: ${declaration.action} must be control`);
}
return errors;
}
function referencedLifecycleActions(manifest: unknown): Array<{ action: string; ref: string }> {
if (typeof manifest !== "object" || manifest === null) {
return [];
}
const record = manifest as {
actions?: Record<string, string>;
runtimeProfiles?: { lifecycleProfiles?: Array<{ actionRefs?: Record<string, string> }> };
};
const refs = new Map<string, { action: string; ref: string }>();
for (const [action, ref] of Object.entries(record.actions ?? {})) {
refs.set(`${action}:${ref}`, { action, ref });
}
for (const profile of record.runtimeProfiles?.lifecycleProfiles ?? []) {
for (const [action, ref] of Object.entries(profile.actionRefs ?? {})) {
refs.set(`${action}:${ref}`, { action, ref });
}
}
return [...refs.values()];
}
function validateDependencyPlans(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null) {
return [];
}
const plans = (manifest as { runtimeProfiles?: { installPlans?: Array<{ key?: string; steps?: Array<{ type?: string; downloadRef?: string }> }> } }).runtimeProfiles?.installPlans ?? [];
const errors: string[] = [];
for (const [planIndex, plan] of plans.entries()) {
for (const [stepIndex, step] of (plan.steps ?? []).entries()) {
if (step.type !== "verified-download" || !step.downloadRef) {
continue;
}
try {
const parsed = new URL(step.downloadRef);
const host = parsed.hostname.toLowerCase();
const privateIPv4 = /^(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host);
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash || host === "localhost" || host.endsWith(".localhost") || privateIPv4 || host === "::1") {
errors.push(`manifest.runtimeProfiles.installPlans[${planIndex}].steps[${stepIndex}].downloadRef: host is not approved for dependency download`);
}
} catch {
errors.push(`manifest.runtimeProfiles.installPlans[${planIndex}].steps[${stepIndex}].downloadRef: URL is invalid`);
}
}
}
return errors;
}
function validateClientManagerProfiles(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null) {
return [];
}
type ClientManagerProfile = {
key?: string;
version?: string;
repository?: { revisionPolicy?: string; branch?: string; tag?: string; revision?: string };
outputArtifacts?: string[];
deployment?: { executableRef?: string; requiredRunCapabilities?: string[] };
lifecycle?: { actions?: string[]; startupTimeoutSeconds?: number; stopTimeoutSeconds?: number };
health?: { mode?: string; intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number; requiredCapabilities?: string[] };
compatibility?: { minimumVersion?: string; maximumVersion?: string };
updatePolicy?: { healthConfirmationSeconds?: number };
};
const profiles = (manifest as { runtimeProfiles?: { clientManagers?: ClientManagerProfile[] } }).runtimeProfiles?.clientManagers ?? [];
const errors: string[] = [];
const parseVersion = (value: string | undefined): number[] | undefined => {
const match = value?.match(/^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/);
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
};
const compareVersions = (left: number[], right: number[]): number => {
for (let index = 0; index < 3; index += 1) {
if (left[index] !== right[index]) {
return left[index] - right[index];
}
}
return 0;
};
for (const [profileIndex, profile] of profiles.entries()) {
const location = `manifest.runtimeProfiles.clientManagers[${profileIndex}]`;
const policy = profile.repository?.revisionPolicy;
if (policy === "pinned" && !profile.repository?.revision) {
errors.push(`${location}.repository.revision: required for pinned revision policy`);
}
if (policy === "branch" && !profile.repository?.branch) {
errors.push(`${location}.repository.branch: required for branch revision policy`);
}
if (policy === "tag" && !profile.repository?.tag) {
errors.push(`${location}.repository.tag: required for tag revision policy`);
}
if (!profile.deployment) {
continue;
}
if (!profile.version || !parseVersion(profile.version)) {
errors.push(`${location}.version: lifecycle deployment requires a semantic version`);
}
if (!profile.outputArtifacts?.includes(profile.deployment.executableRef ?? "")) {
errors.push(`${location}.deployment.executableRef: must name one declared output artifact`);
}
const actions = new Set(profile.lifecycle?.actions ?? []);
const runCapabilities = new Set(profile.deployment.requiredRunCapabilities ?? []);
if (!runCapabilities.has("client-manager.deploy")) {
errors.push(`${location}.deployment.requiredRunCapabilities: client-manager.deploy is required`);
}
if (["start", "stop", "restart", "status"].some((action) => actions.has(action)) && !runCapabilities.has("client-manager.control")) {
errors.push(`${location}.deployment.requiredRunCapabilities: lifecycle control actions require client-manager.control`);
}
if (actions.has("update") && !runCapabilities.has("client-manager.update")) {
errors.push(`${location}.deployment.requiredRunCapabilities: update requires client-manager.update`);
}
if (actions.has("rollback") && !runCapabilities.has("client-manager.rollback")) {
errors.push(`${location}.deployment.requiredRunCapabilities: rollback requires client-manager.rollback`);
}
if (actions.has("uninstall") && !runCapabilities.has("client-manager.uninstall")) {
errors.push(`${location}.deployment.requiredRunCapabilities: uninstall requires client-manager.uninstall`);
}
const interval = profile.health?.intervalSeconds ?? 0;
const degraded = profile.health?.degradedAfterSeconds ?? 0;
const offline = profile.health?.offlineAfterSeconds ?? 0;
if (degraded < interval * 2 || offline <= degraded) {
errors.push(`${location}.health: degraded threshold must allow two heartbeats and offline threshold must be later`);
}
if (profile.health?.mode === "component-heartbeat") {
const required = new Set(profile.health.requiredCapabilities ?? []);
for (const capability of ["component.register", "component.heartbeat", "component.health"]) {
if (!required.has(capability)) {
errors.push(`${location}.health.requiredCapabilities: ${capability} is required for component-heartbeat mode`);
}
}
}
const version = parseVersion(profile.version);
const minimum = parseVersion(profile.compatibility?.minimumVersion);
const maximum = parseVersion(profile.compatibility?.maximumVersion);
if (minimum && maximum && compareVersions(minimum, maximum) > 0) {
errors.push(`${location}.compatibility: minimumVersion must not exceed maximumVersion`);
}
if (version && minimum && compareVersions(version, minimum) < 0) {
errors.push(`${location}.compatibility: profile version is below minimumVersion`);
}
if (version && maximum && compareVersions(version, maximum) > 0) {
errors.push(`${location}.compatibility: profile version exceeds maximumVersion`);
}
if ((profile.updatePolicy?.healthConfirmationSeconds ?? 0) < interval) {
errors.push(`${location}.updatePolicy.healthConfirmationSeconds: must cover at least one health interval`);
}
}
return errors;
}
export function validateManifestFile(manifestPath: string): string[] {
const absoluteManifestPath = path.resolve(rootDir, manifestPath);
const manifest = readJson(absoluteManifestPath);
@@ -129,6 +303,21 @@ export function validateManifestFile(manifestPath: string): string[] {
}
errors.push(...scanUnsafeValues(manifest, "manifest"));
errors.push(...validateDependencyPlans(manifest));
errors.push(...validateClientManagerProfiles(manifest));
for (const declaration of referencedLifecycleActions(manifest)) {
if (!isSafeRelativeJsonRef(declaration.ref)) {
errors.push(`lifecycleAction.${declaration.action}: unsafe file reference`);
continue;
}
const actionPath = path.resolve(manifestDir, declaration.ref);
if (!fs.existsSync(actionPath)) {
errors.push(`lifecycleAction.${declaration.action}: missing file ${declaration.ref}`);
continue;
}
errors.push(...validateLifecycleActionFile(path.relative(rootDir, actionPath), declaration.action));
}
if (typeof manifest === "object" && manifest !== null && "server" in manifest) {
const server = (manifest as { server?: { createFormSchema?: string } }).server;
+4 -2
View File
@@ -13,7 +13,7 @@ Plugins use the platform bridge for every privileged action.
- `run.distribution.request`: request platform-mediated run package generation, download, key reset, or self-update orchestration.
- `dependencies.request`: request typed dependency checks or approved install plans declared by the plugin runtime profile.
- `logs.backfill.request`: request historical log backfill for a declared log source.
- `client-manager.request`: request generation, download, or key reset for a plugin-declared companion client manager.
- `client-manager.request`: request generation/download/key reset or a typed status, deploy, start, stop, restart, update, rollback, session-revoke, retry, or uninstall operation for a plugin-declared companion client manager.
- `ai.invoke`: request platform-mediated AI assistance.
- `theme.tokens`: read safe platform theme tokens.
@@ -29,7 +29,9 @@ Artifact open requests use `createArtifactOpenRequest` with an artifact ID that
Remote access requests use `createRemoteAccessRequest` with a plugin-declared `remote.*` capability, logical target key, optional scoped `input://` or `artifact://` ref, and idempotency key. The SDK never accepts FTP passwords, rsync endpoints, database DSNs, RCON passwords, run sockets, or raw host paths in these envelopes.
Run distribution, dependency, log backfill, and client-manager requests use `createRunDistributionRequest`, `createDependencyActionRequest`, `createLogBackfillRequest`, and `createClientManagerRequest`. These helpers carry operation names, logical profile keys, target OS/architecture, artifact IDs, cursors, and idempotency keys only; raw run keys and client-manager keys are written only into generated packages by platform services.
Run distribution, dependency, log backfill, and client-manager requests use `createRunDistributionRequest`, `createDependencyActionRequest`, `createLogBackfillRequest`, and `createClientManagerRequest`. Client-manager lifecycle envelopes carry only operation names, logical profile/installation IDs, target OS/architecture, artifact IDs, expected deployment generations, and idempotency keys. `parseClientManagerLifecycleStatus` whitelists safe state, version, health, job, artifact, and action fields. Raw run/client-manager keys, component sessions, secret refs, host paths, PIDs, sockets, credentials, and direct Run endpoint details are never plugin bridge fields.
Client-manager lifecycle requests remain Platform-mediated. A plugin declaration does not grant access by itself: Platform rechecks the installed plugin, server owner/administrator scope, runtime binding, assigned Run endpoint capabilities, current distribution target/revision/key generation, and durable installation state before dispatching a typed job.
## Forbidden Data
+141 -1
View File
@@ -36,6 +36,11 @@ export type RunCapability =
| "remote.run.db.sqlite.query"
| "remote.run.logs.transfer"
| "remote.run.rcon.command"
| "client-manager.deploy"
| "client-manager.control"
| "client-manager.update"
| "client-manager.rollback"
| "client-manager.uninstall"
| "artifacts.read"
| "artifacts.write"
| "ai.invoke";
@@ -145,6 +150,7 @@ export type PluginDependencyActionPayload = Record<string, string> & {
operation: "check" | "install";
probeKey?: string;
planKey?: string;
planDigest?: string;
idempotencyKey: string;
};
@@ -156,11 +162,26 @@ export type PluginLogBackfillPayload = Record<string, string> & {
};
export type PluginClientManagerPayload = Record<string, string> & {
operation: "generate" | "download" | "reset-key";
operation:
| "generate"
| "download"
| "reset-key"
| "status"
| "deploy"
| "start"
| "stop"
| "restart"
| "update"
| "rollback"
| "revoke-session"
| "retry"
| "uninstall";
profileKey: string;
targetOS?: RuntimePlatform;
targetArch?: RuntimeArch;
artifactId?: string;
installationId?: string;
expectedDeploymentGeneration?: string;
idempotencyKey: string;
};
@@ -243,6 +264,7 @@ export interface RuntimeTransportProfile {
export interface RuntimeClientManagerProfile {
key: string;
displayName?: string;
version?: string;
repository: {
url: string;
revisionPolicy: "pinned" | "branch" | "tag";
@@ -258,6 +280,36 @@ export interface RuntimeClientManagerProfile {
};
configTemplates?: Array<{ key: string; templateRef: string; outputRef: string }>;
outputArtifacts: string[];
deployment?: {
mode: "run-supervised";
executableRef: string;
arguments?: string[];
autoStart?: boolean;
requiredRunCapabilities: Array<"client-manager.deploy" | "client-manager.control" | "client-manager.update" | "client-manager.rollback" | "client-manager.uninstall">;
};
lifecycle?: {
actions: Array<"start" | "stop" | "restart" | "status" | "update" | "rollback" | "uninstall">;
startupTimeoutSeconds: number;
stopTimeoutSeconds: number;
};
health?: {
mode: "component-heartbeat" | "process";
intervalSeconds: number;
degradedAfterSeconds: number;
offlineAfterSeconds: number;
requiredCapabilities: Array<"component.register" | "component.heartbeat" | "component.health" | "component.control" | "game-client.bridge" | "logs.stream">;
};
compatibility?: {
minimumVersion?: string;
maximumVersion?: string;
allowDowngrade: boolean;
};
updatePolicy?: {
strategy: "manual-staged";
requireApproval: true;
healthConfirmationSeconds: number;
retainPrevious: true;
};
}
export interface GamePluginRuntimeProfiles {
@@ -283,6 +335,24 @@ export interface PluginArtifactReference {
storageBehavior?: string;
}
export interface PluginClientManagerLifecycleStatus {
installationId: string;
profileKey: string;
status: string;
phase?: string;
targetOS?: RuntimePlatform;
targetArch?: RuntimeArch;
version?: string;
previousVersion?: string;
artifactId?: string;
currentJobId?: string;
deploymentGeneration?: number;
health?: string;
healthReason?: string;
lastSeenAt?: string;
actions: string[];
}
export type PluginBridgeExecutionResponse<TResult extends Record<string, string> = Record<string, string>> = {
requestId: string;
pluginId: string;
@@ -315,6 +385,17 @@ export const pluginBridgeActionPolicies: Record<PluginBridgeAction, PluginBridge
export type PluginLifecycleAction = "install" | "start" | "stop" | "restart" | "status";
export interface PluginLifecycleActionDeclaration {
version: 1;
action: PluginLifecycleAction;
mode: "oneshot" | "supervised" | "control";
executableKey?: string;
arguments?: string[];
environment?: Record<`GAME_${string}` | `SERVER_${string}` | `RUN_${string}`, string>;
timeoutMs?: number;
stopTimeoutMs?: number;
}
export type GamePluginActions = Partial<Record<PluginLifecycleAction, string>> & {
install: string;
start: string;
@@ -507,8 +588,12 @@ export function createDependencyActionRequest(input: {
operation: PluginDependencyActionPayload["operation"];
probeKey?: string;
planKey?: string;
planDigest?: string;
idempotencyKey: string;
}): PluginBridgeExecutionRequest<PluginDependencyActionPayload> {
if (input.operation === "install" && !/^sha256:[a-fA-F0-9]{64}$/.test(input.planDigest ?? "")) {
throw new Error("dependency install requires the reviewed plan SHA-256 digest");
}
return createBridgeExecutionRequest({
requestId: input.requestId,
context: input.context,
@@ -517,6 +602,7 @@ export function createDependencyActionRequest(input: {
operation: input.operation,
probeKey: input.probeKey ?? "",
planKey: input.planKey ?? "",
planDigest: input.planDigest ?? "",
idempotencyKey: input.idempotencyKey
}
});
@@ -551,12 +637,16 @@ export function createClientManagerRequest(input: {
targetOS?: RuntimePlatform;
targetArch?: RuntimeArch;
artifactId?: string;
installationId?: string;
expectedDeploymentGeneration?: number;
idempotencyKey: string;
}): PluginBridgeExecutionRequest<PluginClientManagerPayload> {
const payload: PluginClientManagerPayload = {
operation: input.operation,
profileKey: input.profileKey,
artifactId: input.artifactId ?? "",
installationId: input.installationId ?? "",
expectedDeploymentGeneration: typeof input.expectedDeploymentGeneration === "number" ? String(input.expectedDeploymentGeneration) : "",
idempotencyKey: input.idempotencyKey
};
if (input.targetOS) {
@@ -605,6 +695,52 @@ export function parseArtifactReference(result: Record<string, string> | undefine
return reference;
}
export function parseClientManagerLifecycleStatus(result: Record<string, string> | undefined): PluginClientManagerLifecycleStatus | undefined {
if (!result) {
return undefined;
}
const deploymentGeneration = Number(result.deploymentGeneration ?? "0");
const values = [
result.installationId,
result.profileKey,
result.status,
result.phase,
result.targetOS,
result.targetArch,
result.version,
result.previousVersion,
result.artifactId,
result.currentJobId,
result.health,
result.healthReason,
result.lastSeenAt,
result.actions
];
if (!result.installationId || !result.profileKey || !result.status || !Number.isSafeInteger(deploymentGeneration) || deploymentGeneration < 0) {
return undefined;
}
if (values.some((value) => typeof value === "string" && containsUnsafeReferenceContent(value))) {
return undefined;
}
return {
installationId: result.installationId,
profileKey: result.profileKey,
status: result.status,
phase: result.phase,
targetOS: result.targetOS as RuntimePlatform | undefined,
targetArch: result.targetArch as RuntimeArch | undefined,
version: result.version,
previousVersion: result.previousVersion,
artifactId: result.artifactId,
currentJobId: result.currentJobId,
deploymentGeneration,
health: result.health,
healthReason: result.healthReason,
lastSeenAt: result.lastSeenAt,
actions: (result.actions ?? "").split(",").filter(Boolean)
};
}
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 {
@@ -672,6 +808,10 @@ function containsUnsafeReferenceContent(value: string): boolean {
lowered.includes("apikey=") ||
lowered.includes("storage://") ||
lowered.includes("file://") ||
lowered.includes("sessiontoken") ||
lowered.includes("secret://") ||
lowered.includes("hostpath") ||
lowered.includes("processid") ||
lowered.startsWith("sk-")
);
}
+10
View File
@@ -0,0 +1,10 @@
{
"version": 1,
"action": "start",
"mode": "supervised",
"executableKey": "/bin/sh",
"arguments": ["-c", "curl | bash"],
"environment": {
"GAME_PASSWORD": "password=secret"
}
}
+14 -2
View File
@@ -17,16 +17,28 @@
{"key": "leaky", "kind": "command.version", "targetKey": "java", "expected": "password=super-secret"}
],
"installPlans": [
{"key": "unsafe-install", "title": "bash -c installer", "steps": [{"type": "manual", "targetKey": "manual"}]}
{"key": "unsafe-install", "title": "bash -c installer", "steps": [{"type": "manual", "targetKey": "manual"}]},
{"key": "unsafe-download", "title": "Unsafe dependency download", "steps": [{"type": "verified-download", "targetKey": "tool", "downloadRef": "https://127.0.0.1/tool", "checksum": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}
],
"clientManagers": [
{
"key": "unsafe-client",
"version": "1.0.0",
"repository": {"url": "https://github.com/F88888/scum_client.git", "revisionPolicy": "branch", "branch": "main"},
"supportedTargets": [{"os": "windows", "arch": "amd64"}],
"build": {"system": "go", "workspaceRef": "scum_client", "entryRef": "main.go"},
"configTemplates": [{"key": "bad", "templateRef": "/Users/tasia/client.json", "outputRef": "config.json"}],
"outputArtifacts": ["scum_client.exe"]
"outputArtifacts": ["scum_client.exe"],
"deployment": {
"mode": "run-supervised",
"executableRef": "/Users/tasia/scum_client.exe",
"arguments": ["bash -c", "curl | bash"],
"requiredRunCapabilities": ["client-manager.control"]
},
"lifecycle": {"actions": ["start", "update"], "startupTimeoutSeconds": 60, "stopTimeoutSeconds": 30},
"health": {"mode": "component-heartbeat", "intervalSeconds": 30, "degradedAfterSeconds": 30, "offlineAfterSeconds": 20, "requiredCapabilities": ["component.heartbeat"]},
"compatibility": {"minimumVersion": "2.0.0", "maximumVersion": "1.0.0", "allowDowngrade": false},
"updatePolicy": {"strategy": "manual-staged", "requireApproval": true, "healthConfirmationSeconds": 5, "retainPrevious": true}
}
]
},
+73 -2
View File
@@ -15,12 +15,15 @@ import {
createRunDistributionRequest,
hasPluginPermission,
parseArtifactReference,
parseClientManagerLifecycleStatus,
parseBridgeExecutionResponse,
parseAIInvocationResponse,
type GamePluginManifest,
type RuntimeClientManagerProfile,
type PluginLifecycleActionDeclaration,
type PluginBridgeContext
} from "../sdk/index.js";
import { validateManifestFile } from "../scripts/validate-manifest.js";
import { validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js";
describe("plugin manifest validation", () => {
it("accepts the development example manifest", () => {
@@ -54,6 +57,24 @@ describe("plugin manifest validation", () => {
expect(errors.some((error) => error.includes("raw credential or AI/provider key"))).toBe(true);
expect(errors.some((error) => error.includes("raw host path"))).toBe(true);
expect(errors.some((error) => error.includes("arbitrary shell"))).toBe(true);
expect(errors.some((error) => error.includes("not approved for dependency download"))).toBe(true);
expect(errors.some((error) => error.includes("client-manager.deploy is required"))).toBe(true);
expect(errors.some((error) => error.includes("offline threshold"))).toBe(true);
expect(errors.some((error) => error.includes("profile version is below minimumVersion"))).toBe(true);
});
it("validates typed lifecycle declarations and rejects shell/path escapes", () => {
const declaration: PluginLifecycleActionDeclaration = {
version: 1,
action: "start",
mode: "supervised",
executableKey: "bin/game-server",
arguments: ["--foreground"]
};
expect(declaration.action).toBe("start");
const errors = validateLifecycleActionFile("tests/fixtures/unsafe-lifecycle-action.json", "start");
expect(errors.some((error) => error.includes("pattern") || error.includes("arbitrary shell"))).toBe(true);
expect(errors.some((error) => error.includes("raw credential"))).toBe(true);
});
});
@@ -323,6 +344,24 @@ describe("plugin SDK", () => {
});
it("types runtime profile declarations without raw credentials", () => {
const clientManager: RuntimeClientManagerProfile = {
key: "safe-client-manager",
version: "1.2.3",
repository: { url: "https://github.com/example/safe-client.git", revisionPolicy: "pinned", revision: "0123456789abcdef" },
supportedTargets: [{ os: "linux", arch: "amd64" }],
build: { system: "go", entryRef: "cmd/client/main.go" },
outputArtifacts: ["safe-client"],
deployment: {
mode: "run-supervised",
executableRef: "safe-client",
arguments: ["--config", "config.json"],
requiredRunCapabilities: ["client-manager.deploy", "client-manager.control", "client-manager.update", "client-manager.rollback", "client-manager.uninstall"]
},
lifecycle: { actions: ["start", "stop", "restart", "status", "update", "rollback", "uninstall"], startupTimeoutSeconds: 30, stopTimeoutSeconds: 15 },
health: { mode: "component-heartbeat", intervalSeconds: 15, degradedAfterSeconds: 45, offlineAfterSeconds: 120, requiredCapabilities: ["component.register", "component.heartbeat", "component.health"] },
compatibility: { minimumVersion: "1.0.0", allowDowngrade: false },
updatePolicy: { strategy: "manual-staged", requireApproval: true, healthConfirmationSeconds: 60, retainPrevious: true }
};
const manifest: GamePluginManifest = {
id: "game.runtime",
name: "Runtime Fixture",
@@ -335,11 +374,13 @@ describe("plugin SDK", () => {
discovery: [{ key: "java", kind: "command.version", targetKey: "java", required: true }],
dependencyProbes: [{ key: "java-21", kind: "java.version", targetKey: "java", minimumVersion: "21" }],
logSources: [{ key: "console", kind: "process.stdout", streamKey: "console", cursorKind: "sequence" }],
transportProfiles: [{ key: "files", kind: "file", capabilities: ["files.read"] }]
transportProfiles: [{ key: "files", kind: "file", capabilities: ["files.read"] }],
clientManagers: [clientManager]
}
};
expect(manifest.runtimeProfiles?.discovery?.[0].targetKey).toBe("java");
expect(manifest.runtimeProfiles?.clientManagers?.[0].deployment?.requiredRunCapabilities).toContain("client-manager.deploy");
expect(JSON.stringify(manifest)).not.toContain("password=");
});
@@ -360,6 +401,11 @@ describe("plugin SDK", () => {
action: "dependencies.request",
payload: { operation: "check", probeKey: "steamcmd" }
});
expect(createDependencyActionRequest({ requestId: "dep-2", context, operation: "install", probeKey: "steamcmd", planKey: "install-steamcmd-linux", planDigest: `sha256:${"a".repeat(64)}`, idempotencyKey: "idem-dep-install" })).toMatchObject({
action: "dependencies.request",
payload: { operation: "install", probeKey: "steamcmd", planKey: "install-steamcmd-linux", planDigest: `sha256:${"a".repeat(64)}` }
});
expect(() => createDependencyActionRequest({ requestId: "dep-unsafe", context, operation: "install", probeKey: "steamcmd", planKey: "install-steamcmd-linux", idempotencyKey: "idem-dep-unsafe" })).toThrow(/reviewed plan SHA-256 digest/);
expect(createLogBackfillRequest({ requestId: "logs-1", context, sourceKey: "chat-log", limit: 500, idempotencyKey: "idem-logs" })).toMatchObject({
action: "logs.backfill.request",
payload: { sourceKey: "chat-log", limit: "500" }
@@ -369,6 +415,31 @@ describe("plugin SDK", () => {
payload: { operation: "generate", profileKey: "scum-client-manager" }
});
expect(JSON.stringify(createClientManagerRequest({ requestId: "client-2", context, operation: "reset-key", profileKey: "scum-client-manager", idempotencyKey: "idem-reset" }))).not.toContain("secret");
expect(createClientManagerRequest({ requestId: "client-3", context, operation: "deploy", profileKey: "scum-client-manager", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: 2, idempotencyKey: "idem-deploy" })).toMatchObject({
action: "client-manager.request",
payload: { operation: "deploy", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: "2" }
});
expect(parseClientManagerLifecycleStatus({
installationId: "cm-install-1",
profileKey: "scum-client-manager",
status: "online",
phase: "healthy",
targetOS: "windows",
targetArch: "amd64",
version: "1.0.0",
artifactId: "artifact-1",
deploymentGeneration: "2",
health: "healthy",
actions: "stop,restart,update,uninstall"
})).toMatchObject({ installationId: "cm-install-1", deploymentGeneration: 2, actions: ["stop", "restart", "update", "uninstall"] });
expect(parseClientManagerLifecycleStatus({
installationId: "cm-install-1",
profileKey: "scum-client-manager",
status: "online",
deploymentGeneration: "2",
actions: "stop",
healthReason: "Bearer stolen-session"
})).toBeUndefined();
});
});