feat: 完整游戏运维功能
This commit is contained in:
+10
-5
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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-")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"action": "start",
|
||||
"mode": "supervised",
|
||||
"executableKey": "/bin/sh",
|
||||
"arguments": ["-c", "curl | bash"],
|
||||
"environment": {
|
||||
"GAME_PASSWORD": "password=secret"
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -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}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user