Remove legacy client-manager workflows
This commit is contained in:
+2
-7
@@ -48,7 +48,7 @@ Plugin pages may request these operations only through bridge helpers:
|
||||
- `createLogBackfillRequest`: request historical log cursors for declared sources.
|
||||
- `createProductionPluginLifecycleRequest`: request server-bound install/enable/disable/upgrade/rollback/retire/dependency-check through Platform operations.
|
||||
|
||||
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.
|
||||
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, component 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 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.
|
||||
|
||||
@@ -71,9 +71,4 @@ 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, target-matched typed dependency plan requests, run distribution envelopes, typed dependency/log backfill requests, and plugin-owned typed SCUM RCON data flows. 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.
|
||||
# Client Manager profile contract
|
||||
|
||||
Plugins may declare a Client Manager profile with version/revision, supported targets, fixed relative executable, deployment mode, lifecycle capabilities, bounded startup/stop/health settings, compatibility constraints, and update policy. Platform enables lifecycle actions only after a real available distribution, complete server binding, an owned online Run endpoint, matching target/revision, and the current component-key generation.
|
||||
|
||||
Plugin pages and SDK bridge responses expose profile declarations, action availability, logical status/health, version/revision, job progress, and safe failure reasons only. They never receive component keys, sessions, secret refs/values, host paths, PIDs, sockets, credentials, DSNs, or direct Run endpoints. Arbitrary shell, raw credentials, and endpoint-bearing declarations are rejected during manifest validation.
|
||||
Runtime-profile declarations do not provide a general secret vault, arbitrary machine execution, production code signing/KMS, or fleet orchestration. The bounded scheduler, process supervisor, and isolated log/artifact/control channels are Platform/Run capabilities; plugins receive only declarations and safe status projections.
|
||||
|
||||
@@ -131,11 +131,6 @@
|
||||
"uniqueItems": true,
|
||||
"maxItems": 16
|
||||
},
|
||||
"clientManagers": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/runtimeClientManagerProfile" },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"dllExtensions": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/runtimeDLLExtensionProfile" },
|
||||
@@ -271,28 +266,7 @@
|
||||
"items": { "$ref": "#/$defs/gameClientBridgePageContract" },
|
||||
"maxItems": 64
|
||||
},
|
||||
"features": { "type": "array", "items": { "$ref": "#/$defs/gameClientBridgeFeature" }, "maxItems": 128 },
|
||||
"companion": { "$ref": "#/$defs/gameClientBridgeCompanion" }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeCompanion": {
|
||||
"type": "object",
|
||||
"required": ["profileKey", "configTemplateKey", "configSchemaRef", "configFormat", "platformBaseUrlSource", "registrationProof", "proofMaterialSource", "proofMaterialEnv", "sessionMode", "tlsPolicy", "heartbeatIntervalSeconds", "commandPollIntervalSeconds", "requestTimeoutSeconds"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"profileKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"configTemplateKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"configSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"configFormat": { "const": "yaml" },
|
||||
"platformBaseUrlSource": { "const": "run-control" },
|
||||
"registrationProof": { "const": "hmac-sha256" },
|
||||
"proofMaterialSource": { "const": "component-package" },
|
||||
"proofMaterialEnv": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{2,63}$" },
|
||||
"sessionMode": { "const": "component-session" },
|
||||
"tlsPolicy": { "const": "verify-system-roots" },
|
||||
"heartbeatIntervalSeconds": { "type": "integer", "minimum": 5, "maximum": 300 },
|
||||
"commandPollIntervalSeconds": { "type": "integer", "minimum": 1, "maximum": 60 },
|
||||
"requestTimeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }
|
||||
"features": { "type": "array", "items": { "$ref": "#/$defs/gameClientBridgeFeature" }, "maxItems": 128 }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeCommand": {
|
||||
@@ -463,11 +437,6 @@
|
||||
"remote.run.logs.transfer",
|
||||
"remote.run.rcon.command",
|
||||
"remote.run.program.command",
|
||||
"client-manager.deploy",
|
||||
"client-manager.control",
|
||||
"client-manager.update",
|
||||
"client-manager.rollback",
|
||||
"client-manager.uninstall",
|
||||
"artifacts.read",
|
||||
"artifacts.write",
|
||||
"ai.invoke"
|
||||
@@ -486,7 +455,6 @@
|
||||
"server.remote.access",
|
||||
"server.run.distribution",
|
||||
"server.dependencies.manage",
|
||||
"server.client-manager.manage",
|
||||
"server.game-client.read",
|
||||
"server.game-client.command",
|
||||
"server.game-client.maintenance",
|
||||
@@ -504,7 +472,6 @@
|
||||
"run.distribution.request",
|
||||
"dependencies.request",
|
||||
"logs.backfill.request",
|
||||
"client-manager.request",
|
||||
"plugin-lifecycle.request",
|
||||
"ai.invoke"
|
||||
]
|
||||
@@ -568,7 +535,6 @@
|
||||
}
|
||||
},
|
||||
"transportKeys": { "type": "array", "items": { "$ref": "#/$defs/logicalKey" }, "uniqueItems": true },
|
||||
"clientManagerRef": { "$ref": "#/$defs/logicalKey" },
|
||||
"dllExtensionRefs": { "type": "array", "items": { "$ref": "#/$defs/logicalKey" }, "uniqueItems": true, "maxItems": 16 },
|
||||
"platforms": { "type": "array", "items": { "$ref": "#/$defs/runtimePlatform" }, "uniqueItems": true }
|
||||
}
|
||||
@@ -702,7 +668,7 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/logicalKey" },
|
||||
"kind": { "enum": ["process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query", "client-manager"] },
|
||||
"kind": { "enum": ["process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query"] },
|
||||
"targetKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"streamKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"cursorKind": { "enum": ["sequence", "offset", "fingerprint", "ftp-listing", "sql-cursor"] },
|
||||
@@ -741,138 +707,6 @@
|
||||
"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"],
|
||||
"additionalProperties": false,
|
||||
"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"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"url": { "type": "string", "pattern": "^https://[a-zA-Z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+\\.git$", "maxLength": 240 },
|
||||
"branch": { "type": "string", "pattern": "^[a-zA-Z0-9._/-]+$", "maxLength": 120 },
|
||||
"tag": { "type": "string", "pattern": "^[a-zA-Z0-9._/-]+$", "maxLength": 120 },
|
||||
"revision": { "type": "string", "pattern": "^[a-fA-F0-9]{7,64}$" },
|
||||
"revisionPolicy": { "enum": ["pinned", "branch", "tag"] }
|
||||
}
|
||||
},
|
||||
"supportedTargets": { "type": "array", "items": { "$ref": "#/$defs/runtimeTarget" }, "minItems": 1, "uniqueItems": true },
|
||||
"build": {
|
||||
"type": "object",
|
||||
"required": ["system"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"system": { "enum": ["go", "npm", "cargo", "make"] },
|
||||
"workspaceRef": { "$ref": "#/$defs/relativePathRef" },
|
||||
"entryRef": { "$ref": "#/$defs/relativePathRef" }
|
||||
}
|
||||
},
|
||||
"configTemplates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["key", "templateRef", "outputRef"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/logicalKey" },
|
||||
"templateRef": { "$ref": "#/$defs/relativePathRef" },
|
||||
"outputRef": { "$ref": "#/$defs/relativePathRef" }
|
||||
}
|
||||
},
|
||||
"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", "updatePolicy"] }
|
||||
}
|
||||
]
|
||||
},
|
||||
"runtimeDLLExtensionProfile": {
|
||||
"type": "object",
|
||||
"required": ["key", "displayName", "kind", "activation", "version", "releaseState", "targetKey", "modKey", "dllRef", "supportedTargets", "updateOnStart", "rconPort"],
|
||||
|
||||
@@ -412,102 +412,24 @@ function validateServerDeploymentProfiles(manifest: unknown): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateClientManagerProfiles(manifest: unknown): string[] {
|
||||
function validateUnsupportedLegacyClientManagerDeclarations(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 declaration = manifest as {
|
||||
runtimeProfiles?: { clientManagers?: unknown[]; lifecycleProfiles?: Array<{ clientManagerRef?: unknown }> };
|
||||
gameClientBridge?: { companion?: unknown };
|
||||
};
|
||||
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`);
|
||||
if (declaration.runtimeProfiles?.clientManagers !== undefined) {
|
||||
errors.push("manifest.runtimeProfiles.clientManagers: client-manager profiles are no longer supported");
|
||||
}
|
||||
if (declaration.gameClientBridge?.companion !== undefined) {
|
||||
errors.push("manifest.gameClientBridge.companion: client-manager companion declarations are no longer supported");
|
||||
}
|
||||
for (const [index, profile] of (declaration.runtimeProfiles?.lifecycleProfiles ?? []).entries()) {
|
||||
if (profile.clientManagerRef !== undefined) {
|
||||
errors.push(`manifest.runtimeProfiles.lifecycleProfiles[${index}].clientManagerRef: client-manager references are no longer supported`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
@@ -626,29 +548,15 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
pollIntervalSeconds?: number;
|
||||
};
|
||||
type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] };
|
||||
type BridgeCompanion = {
|
||||
profileKey?: string;
|
||||
configTemplateKey?: string;
|
||||
configSchemaRef?: string;
|
||||
heartbeatIntervalSeconds?: number;
|
||||
commandPollIntervalSeconds?: number;
|
||||
requestTimeoutSeconds?: number;
|
||||
registrationProof?: string;
|
||||
proofMaterialSource?: string;
|
||||
proofMaterialEnv?: string;
|
||||
sessionMode?: string;
|
||||
tlsPolicy?: string;
|
||||
};
|
||||
type PluginPage = { key?: string; permissions?: string[]; bridgeActions?: string[] };
|
||||
type RuntimeTransportProfile = { key?: string; kind?: string; targetKey?: string; capabilities?: string[] };
|
||||
type RuntimeClientManager = { key?: string; configTemplates?: Array<{ key?: string; outputRef?: string }>; health?: { intervalSeconds?: number; requiredCapabilities?: string[] } };
|
||||
const declaration = manifest as {
|
||||
capabilities?: string[];
|
||||
permissions?: string[];
|
||||
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
|
||||
pages?: PluginPage[];
|
||||
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
||||
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[] };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; pages?: BridgePage[] };
|
||||
};
|
||||
const bridge = declaration.gameClientBridge;
|
||||
if (!bridge) {
|
||||
@@ -663,45 +571,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []);
|
||||
const remoteDatabaseEngines = new Set(declaration.remoteAccess?.databaseEngines ?? []);
|
||||
const transportProfiles = declaration.runtimeProfiles?.transportProfiles ?? [];
|
||||
const companion = bridge.companion;
|
||||
if (companion) {
|
||||
const location = "manifest.gameClientBridge.companion";
|
||||
const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === companion.profileKey);
|
||||
if (!manager) {
|
||||
errors.push(`${location}.profileKey: must reference a declared Client Manager profile`);
|
||||
} else {
|
||||
const template = manager.configTemplates?.find((candidate) => candidate.key === companion.configTemplateKey);
|
||||
if (!template) {
|
||||
errors.push(`${location}.configTemplateKey: must reference the Client Manager profile`);
|
||||
} else if (template.outputRef !== "config.yaml") {
|
||||
errors.push(`${location}.configTemplateKey: config template must materialize config.yaml`);
|
||||
}
|
||||
if (manager.health?.intervalSeconds !== companion.heartbeatIntervalSeconds) {
|
||||
errors.push(`${location}.heartbeatIntervalSeconds: must match the Client Manager health interval`);
|
||||
}
|
||||
for (const capability of ["component.register", "component.heartbeat", "component.health", "game-client.bridge"]) {
|
||||
if (!manager.health?.requiredCapabilities?.includes(capability)) {
|
||||
errors.push(`${location}: Client Manager health must require ${capability}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!companion.configSchemaRef || !isSafeRelativeJsonRef(companion.configSchemaRef)) {
|
||||
errors.push(`${location}.configSchemaRef: raw host paths and unsafe schema references are not allowed`);
|
||||
}
|
||||
if (companion.registrationProof !== "hmac-sha256" || companion.proofMaterialSource !== "component-package" || companion.sessionMode !== "component-session" || companion.tlsPolicy !== "verify-system-roots") {
|
||||
errors.push(`${location}: secure component registration/session/TLS policy is required`);
|
||||
}
|
||||
const reservedProofEnvironments = new Set(["COMSPEC", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "HOME", "LD_LIBRARY_PATH", "LD_PRELOAD", "PATH", "PATHEXT", "SHELL", "SYSTEMROOT", "TEMP", "TMP", "USERPROFILE", "WINDIR"]);
|
||||
if (!/^[A-Z][A-Z0-9_]{2,63}$/.test(companion.proofMaterialEnv ?? "") || reservedProofEnvironments.has(companion.proofMaterialEnv ?? "")) {
|
||||
errors.push(`${location}.proofMaterialEnv: must be a bounded environment variable name`);
|
||||
}
|
||||
if (!Number.isInteger(companion.commandPollIntervalSeconds) || (companion.commandPollIntervalSeconds ?? 0) < 1 || (companion.commandPollIntervalSeconds ?? 0) > 60) {
|
||||
errors.push(`${location}.commandPollIntervalSeconds: must be between 1 and 60`);
|
||||
}
|
||||
if (!Number.isInteger(companion.requestTimeoutSeconds) || (companion.requestTimeoutSeconds ?? 0) < 1 || (companion.requestTimeoutSeconds ?? 0) > 60) {
|
||||
errors.push(`${location}.requestTimeoutSeconds: must be between 1 and 60`);
|
||||
}
|
||||
}
|
||||
for (const [index, command] of (bridge.commands ?? []).entries()) {
|
||||
const location = `manifest.gameClientBridge.commands[${index}]`;
|
||||
const type = command.type ?? "";
|
||||
@@ -927,160 +796,6 @@ function validateGameClientBridgeSchemaFiles(manifest: unknown, manifestDir: str
|
||||
return errors;
|
||||
}
|
||||
|
||||
type CompanionConfigDeclaration = {
|
||||
profileKey?: string;
|
||||
configSchemaRef?: string;
|
||||
registrationProof?: string;
|
||||
proofMaterialEnv?: string;
|
||||
sessionMode?: string;
|
||||
tlsPolicy?: string;
|
||||
heartbeatIntervalSeconds?: number;
|
||||
commandPollIntervalSeconds?: number;
|
||||
requestTimeoutSeconds?: number;
|
||||
};
|
||||
|
||||
type CompanionRuntimeProfile = {
|
||||
key?: string;
|
||||
version?: string;
|
||||
supportedTargets?: Array<{ os?: string; arch?: string }>;
|
||||
health?: { requiredCapabilities?: string[] };
|
||||
};
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function sameStringSet(left: unknown, right: string[]): boolean {
|
||||
if (!Array.isArray(left) || !left.every((item) => typeof item === "string") || left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
const leftSet = new Set(left);
|
||||
const rightSet = new Set(right);
|
||||
return leftSet.size === left.length && rightSet.size === right.length && [...leftSet].every((item) => rightSet.has(item));
|
||||
}
|
||||
|
||||
function isSafeHTTPSBaseURL(value: unknown): boolean {
|
||||
if (typeof value !== "string" || value.length === 0 || /\s/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "https:" && parsed.hostname !== "" && parsed.username === "" && parsed.password === "" && parsed.search === "" && parsed.hash === "" && (parsed.pathname === "" || parsed.pathname === "/");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateGeneratedCompanionConfig(manifest: unknown, companion: CompanionConfigDeclaration, example: unknown, location: string): string[] {
|
||||
const declaration = manifest as {
|
||||
id?: string;
|
||||
runtimeProfiles?: { clientManagers?: CompanionRuntimeProfile[] };
|
||||
};
|
||||
const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === companion.profileKey);
|
||||
const root = recordValue(example);
|
||||
const platform = recordValue(root?.platform);
|
||||
const component = recordValue(root?.component);
|
||||
const proof = recordValue(root?.proof);
|
||||
const session = recordValue(root?.session);
|
||||
const timing = recordValue(root?.timing);
|
||||
const tls = recordValue(root?.tls);
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isSafeHTTPSBaseURL(platform?.baseUrl)) {
|
||||
errors.push(`${location}.example.platform.baseUrl: must be an HTTPS origin without userinfo, path, query, or fragment`);
|
||||
}
|
||||
if (component?.pluginId !== declaration.id) {
|
||||
errors.push(`${location}.example.component.pluginId: must match the plugin manifest id`);
|
||||
}
|
||||
if (component?.profileKey !== companion.profileKey) {
|
||||
errors.push(`${location}.example.component.profileKey: must match the companion profileKey`);
|
||||
}
|
||||
if (manager?.version && component?.version !== manager.version) {
|
||||
errors.push(`${location}.example.component.version: must match the Client Manager profile version`);
|
||||
}
|
||||
if (manager && !manager.supportedTargets?.some((target) => target.os === component?.targetOs && target.arch === component?.targetArch)) {
|
||||
errors.push(`${location}.example.component: targetOs and targetArch must match a supported Client Manager target`);
|
||||
}
|
||||
if (proof?.mode !== companion.registrationProof) {
|
||||
errors.push(`${location}.example.proof.mode: must match the companion registrationProof`);
|
||||
}
|
||||
if (proof?.materialEnv !== companion.proofMaterialEnv) {
|
||||
errors.push(`${location}.example.proof.materialEnv: must match the companion proofMaterialEnv`);
|
||||
}
|
||||
if (session?.mode !== companion.sessionMode) {
|
||||
errors.push(`${location}.example.session.mode: must match the companion sessionMode`);
|
||||
}
|
||||
if (tls?.policy !== companion.tlsPolicy) {
|
||||
errors.push(`${location}.example.tls.policy: must match the companion tlsPolicy`);
|
||||
}
|
||||
for (const [field, expected] of [
|
||||
["heartbeatIntervalSeconds", companion.heartbeatIntervalSeconds],
|
||||
["commandPollIntervalSeconds", companion.commandPollIntervalSeconds],
|
||||
["requestTimeoutSeconds", companion.requestTimeoutSeconds]
|
||||
] as const) {
|
||||
if (timing?.[field] !== expected) {
|
||||
errors.push(`${location}.example.timing.${field}: must match the companion declaration`);
|
||||
}
|
||||
}
|
||||
if (!manager || !sameStringSet(root?.capabilities, manager.health?.requiredCapabilities ?? [])) {
|
||||
errors.push(`${location}.example.capabilities: must exactly match the Client Manager requiredCapabilities`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateGameClientBridgeCompanionConfig(manifest: unknown, manifestDir: string): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
const companion = (manifest as { gameClientBridge?: { companion?: CompanionConfigDeclaration } }).gameClientBridge?.companion;
|
||||
if (!companion) {
|
||||
return [];
|
||||
}
|
||||
const location = "manifest.gameClientBridge.companion.configSchemaRef";
|
||||
const ref = companion.configSchemaRef ?? "";
|
||||
if (!isSafeRelativeJsonRef(ref)) {
|
||||
return [`${location}: raw host paths and unsafe schema references are not allowed`];
|
||||
}
|
||||
const schemaPath = path.resolve(manifestDir, ref);
|
||||
if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
|
||||
return [`${location}: missing companion config schema file ${ref}`];
|
||||
}
|
||||
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(schemaPath));
|
||||
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
|
||||
return [`${location}: companion config schema must remain inside the plugin manifest directory`];
|
||||
}
|
||||
const errors: string[] = [];
|
||||
try {
|
||||
const schema = readJson(schemaPath) as AnySchema;
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false });
|
||||
if (!ajv.validateSchema(schema)) {
|
||||
errors.push(...formatErrors(`${location}.schema`, ajv.errors));
|
||||
return errors;
|
||||
}
|
||||
errors.push(...scanUnsafeBridgeSchema(schema, `${location}.schema`));
|
||||
errors.push(...validateBoundedBridgeSchema(schema, `${location}.schema`));
|
||||
const validate = ajv.compile(schema);
|
||||
const examplePath = schemaPath.replace(/\.schema\.json$/, ".generated.example.json");
|
||||
if (!fs.existsSync(examplePath)) {
|
||||
errors.push(`${location}: missing generated companion config example`);
|
||||
return errors;
|
||||
}
|
||||
const example = readJson(examplePath);
|
||||
if (!validate(example)) {
|
||||
errors.push(...formatErrors(`${location}.example`, validate.errors));
|
||||
}
|
||||
errors.push(...scanUnsafeValues(example, `${location}.example`));
|
||||
errors.push(...validateGeneratedCompanionConfig(manifest, companion, example, location));
|
||||
const serialized = `${JSON.stringify(schema)}\n${JSON.stringify(example)}`;
|
||||
if (/\/api\/v1\/scum-clients\//i.test(serialized) || /InsecureSkipVerify/i.test(serialized) || /"(?:authKey|componentKey|credential|password|sessionToken|secret)"\s*:/i.test(serialized)) {
|
||||
errors.push(`${location}: companion config must not contain legacy endpoints, insecure TLS, or inline proof/session material`);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${location}: companion config schema or example is invalid: ${error instanceof Error ? error.message : "invalid JSON"}`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateManifestFile(manifestPath: string): string[] {
|
||||
const absoluteManifestPath = path.resolve(rootDir, manifestPath);
|
||||
const manifest = readJson(absoluteManifestPath);
|
||||
@@ -1099,11 +814,10 @@ export function validateManifestFile(manifestPath: string): string[] {
|
||||
errors.push(...validateCreateFieldDeclarations(manifest));
|
||||
errors.push(...validateDependencyPlans(manifest));
|
||||
errors.push(...validateServerDeploymentProfiles(manifest));
|
||||
errors.push(...validateClientManagerProfiles(manifest));
|
||||
errors.push(...validateUnsupportedLegacyClientManagerDeclarations(manifest));
|
||||
errors.push(...validateDLLExtensionProfiles(manifest));
|
||||
errors.push(...validateGameClientBridgeCatalog(manifest));
|
||||
errors.push(...validateGameClientBridgeSchemaFiles(manifest, manifestDir));
|
||||
errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir));
|
||||
const assetValidation = validateManifestAssetFiles(manifest, manifestDir);
|
||||
errors.push(...assetValidation.errors);
|
||||
errors.push(...validateGameClientBridgeSQLAssets(manifest, manifestDir, assetValidation.declared));
|
||||
|
||||
@@ -13,11 +13,10 @@ 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/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.
|
||||
- `plugin-lifecycle.request`: request a server-bound install, enable, disable, upgrade, rollback, retire, or dependency-check operation through Platform capacity and compatibility gates.
|
||||
- `ai.invoke`: request platform-mediated AI assistance.
|
||||
- `theme.tokens`: read safe platform theme tokens.
|
||||
- `game-client-bridge`: query declared companion health and snapshots, and queue or cancel only manifest-declared typed commands through Platform.
|
||||
- `game-client-bridge`: query declared bridge snapshots, and queue or cancel only manifest-declared typed commands through Platform.
|
||||
|
||||
## Execution Envelopes
|
||||
|
||||
@@ -33,9 +32,7 @@ Remote access requests use `createRemoteAccessRequest` with a plugin-declared `r
|
||||
|
||||
SQLite reads use manifest-declared `gameClientBridge.queryTemplates`. Plugin pages send only a declared template key plus typed inputs; Platform verifies the page contract, permission, SQLite transport/target, timeout, and row limit before dispatch. Query declarations and browser envelopes never contain SQL text, DSNs, credentials, sockets, or host paths.
|
||||
|
||||
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.
|
||||
Run distribution, dependency, and log backfill requests use `createRunDistributionRequest`, `createDependencyActionRequest`, and `createLogBackfillRequest`. These envelopes carry only operation names, logical profile keys, target platforms, artifact IDs, approved dependency plan digests, cursors, and idempotency keys. Raw run keys, component sessions, secret refs, host paths, PIDs, sockets, credentials, and direct Run endpoint details are never plugin bridge fields.
|
||||
|
||||
Game-client plugin pages receive a host-provided `GameClientBridgePageClient`. The SDK defines status, command, result, snapshot, approval, and manifest declaration types but never creates its own HTTP client. Queue requests carry only a declared command type, logical profile key, bounded typed payload, expiry, priority, and idempotency key. A command may declare a protected `sql`, `rcon`, or management-program request: the plugin supplies only its one bounded text field and logical transport/target keys; Platform authorizes, approves, redacts, queues, and forwards it to Run. A management program is not host OS shell access. Browser-facing types intentionally have no component session, component key, installation fence, host path, DSN, Run endpoint, socket, or storage credential fields.
|
||||
|
||||
@@ -53,6 +50,3 @@ The bridge must not expose:
|
||||
- unrestricted artifact storage credentials.
|
||||
- direct storage URLs or presigned backend URLs.
|
||||
- FTP, rsync, database, or RCON credentials.
|
||||
# Client Manager lifecycle bridge
|
||||
|
||||
The bridge may request typed `deploy`, `start`, `stop`, `restart`, `status`, `update`, `rollback`, `revoke`, `retry`, or `uninstall` intents when Platform action gating says they are available. Results are safe logical projections with real job phase/progress and redacted recovery guidance. The bridge is not a transport for Run sessions, component keys, artifact bytes, machine paths, process IDs, sockets, or credentials; component registration and heartbeat remain component-to-Platform contracts outside the plugin page.
|
||||
|
||||
+1
-201
@@ -10,7 +10,6 @@ export type PluginPermission =
|
||||
| "server.remote.access"
|
||||
| "server.run.distribution"
|
||||
| "server.dependencies.manage"
|
||||
| "server.client-manager.manage"
|
||||
| "server.game-client.read"
|
||||
| "server.game-client.command"
|
||||
| "server.game-client.maintenance"
|
||||
@@ -49,11 +48,6 @@ export type RunCapability =
|
||||
| "remote.run.logs.transfer"
|
||||
| "remote.run.rcon.command"
|
||||
| "remote.run.program.command"
|
||||
| "client-manager.deploy"
|
||||
| "client-manager.control"
|
||||
| "client-manager.update"
|
||||
| "client-manager.rollback"
|
||||
| "client-manager.uninstall"
|
||||
| "artifacts.read"
|
||||
| "artifacts.write"
|
||||
| "ai.invoke";
|
||||
@@ -70,7 +64,6 @@ export type PluginBridgeAction =
|
||||
| "run.distribution.request"
|
||||
| "dependencies.request"
|
||||
| "logs.backfill.request"
|
||||
| "client-manager.request"
|
||||
| "plugin-lifecycle.request"
|
||||
| "ai.invoke";
|
||||
|
||||
@@ -183,30 +176,6 @@ export type PluginLogBackfillPayload = Record<string, string> & {
|
||||
idempotencyKey: string;
|
||||
};
|
||||
|
||||
export type PluginClientManagerPayload = Record<string, string> & {
|
||||
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;
|
||||
};
|
||||
|
||||
export type RemoteAccessMethod = "ftp" | "rsync" | "run";
|
||||
export type RemoteDatabaseEngine = "mysql" | "sqlite";
|
||||
|
||||
@@ -282,22 +251,6 @@ export interface GameClientBridgePageContract {
|
||||
|
||||
export interface GameClientBridgeFeatureDeclaration { key: string; title: string; permission: PluginPermission; requiredHandlers?: string[]; requiredEventProducers?: string[]; }
|
||||
|
||||
export interface GameClientBridgeCompanionDeclaration {
|
||||
profileKey: string;
|
||||
configTemplateKey: string;
|
||||
configSchemaRef: string;
|
||||
configFormat: "yaml";
|
||||
platformBaseUrlSource: "run-control";
|
||||
registrationProof: "hmac-sha256";
|
||||
proofMaterialSource: "component-package";
|
||||
proofMaterialEnv: string;
|
||||
sessionMode: "component-session";
|
||||
tlsPolicy: "verify-system-roots";
|
||||
heartbeatIntervalSeconds: number;
|
||||
commandPollIntervalSeconds: number;
|
||||
requestTimeoutSeconds: number;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeManifest {
|
||||
commands: GameClientBridgeCommandDeclaration[];
|
||||
snapshots: GameClientBridgeSnapshotDeclaration[];
|
||||
@@ -307,7 +260,6 @@ export interface GameClientBridgeManifest {
|
||||
maxCommands: number;
|
||||
pages?: GameClientBridgePageContract[];
|
||||
features?: GameClientBridgeFeatureDeclaration[];
|
||||
companion?: GameClientBridgeCompanionDeclaration;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeProfileStatus {
|
||||
@@ -421,7 +373,6 @@ export interface RuntimeLifecycleProfile {
|
||||
capabilities: RunCapability[];
|
||||
actionRefs?: Partial<Record<PluginLifecycleAction, string>>;
|
||||
transportKeys?: string[];
|
||||
clientManagerRef?: string;
|
||||
dllExtensionRefs?: string[];
|
||||
platforms?: RuntimePlatform[];
|
||||
}
|
||||
@@ -454,7 +405,7 @@ export interface RuntimeInstallPlan {
|
||||
|
||||
export interface RuntimeLogSource {
|
||||
key: string;
|
||||
kind: "process.stdout" | "process.stderr" | "file.tail" | "ftp.poll" | "sql.query" | "client-manager";
|
||||
kind: "process.stdout" | "process.stderr" | "file.tail" | "ftp.poll" | "sql.query";
|
||||
targetKey?: string;
|
||||
streamKey: string;
|
||||
cursorKind?: "sequence" | "offset" | "fingerprint" | "ftp-listing" | "sql-cursor";
|
||||
@@ -480,57 +431,6 @@ export interface RuntimeDataTarget {
|
||||
platforms?: RuntimePlatform[];
|
||||
}
|
||||
|
||||
export interface RuntimeClientManagerProfile {
|
||||
key: string;
|
||||
displayName?: string;
|
||||
version?: string;
|
||||
repository: {
|
||||
url: string;
|
||||
revisionPolicy: "pinned" | "branch" | "tag";
|
||||
branch?: string;
|
||||
tag?: string;
|
||||
revision?: string;
|
||||
};
|
||||
supportedTargets: RuntimeTarget[];
|
||||
build: {
|
||||
system: "go" | "npm" | "cargo" | "make";
|
||||
workspaceRef?: string;
|
||||
entryRef?: string;
|
||||
};
|
||||
configTemplates?: Array<{ key: string; templateRef: string; outputRef: string }>;
|
||||
outputArtifacts: string[];
|
||||
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 RuntimeDLLExtensionProfile {
|
||||
key: string;
|
||||
displayName: string;
|
||||
@@ -559,7 +459,6 @@ export interface GamePluginRuntimeProfiles {
|
||||
logSources?: RuntimeLogSource[];
|
||||
transportProfiles?: RuntimeTransportProfile[];
|
||||
dataTargets?: RuntimeDataTarget[];
|
||||
clientManagers?: RuntimeClientManagerProfile[];
|
||||
dllExtensions?: RuntimeDLLExtensionProfile[];
|
||||
}
|
||||
|
||||
@@ -576,24 +475,6 @@ 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;
|
||||
@@ -620,7 +501,6 @@ export const pluginBridgeActionPolicies: Record<PluginBridgeAction, PluginBridge
|
||||
"run.distribution.request": { permissions: ["server.run.distribution"] },
|
||||
"dependencies.request": { permissions: ["server.dependencies.manage"] },
|
||||
"logs.backfill.request": { permissions: ["server.logs.read"] },
|
||||
"client-manager.request": { permissions: ["server.client-manager.manage"] },
|
||||
"plugin-lifecycle.request": { permissions: ["server.lifecycle"] },
|
||||
"ai.invoke": { permissions: ["ai.invoke"], aiPurposeRequired: true }
|
||||
};
|
||||
@@ -918,40 +798,6 @@ export function createLogBackfillRequest(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function createClientManagerRequest(input: {
|
||||
requestId: string;
|
||||
context: PluginBridgeContext;
|
||||
operation: PluginClientManagerPayload["operation"];
|
||||
profileKey: string;
|
||||
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) {
|
||||
payload.targetOS = input.targetOS;
|
||||
}
|
||||
if (input.targetArch) {
|
||||
payload.targetArch = input.targetArch;
|
||||
}
|
||||
return createBridgeExecutionRequest({
|
||||
requestId: input.requestId,
|
||||
context: input.context,
|
||||
action: "client-manager.request",
|
||||
payload
|
||||
});
|
||||
}
|
||||
|
||||
export function parseArtifactReference(result: Record<string, string> | undefined): PluginArtifactReference | undefined {
|
||||
if (!result) {
|
||||
return undefined;
|
||||
@@ -984,52 +830,6 @@ 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 {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
canRequestBridgeAction,
|
||||
createAIInvocationRequest,
|
||||
createArtifactOpenRequest,
|
||||
createClientManagerRequest,
|
||||
createBridgeExecutionRequest,
|
||||
createLifecycleDispatchRequest,
|
||||
createProductionPluginLifecycleRequest,
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
createRunDistributionRequest,
|
||||
hasPluginPermission,
|
||||
parseArtifactReference,
|
||||
parseClientManagerLifecycleStatus,
|
||||
parseBridgeExecutionResponse,
|
||||
parseAIInvocationResponse,
|
||||
type GameClientBridgeQueryTemplateDeclaration,
|
||||
@@ -256,17 +254,7 @@ describe("plugin manifest validation", () => {
|
||||
files: Array<{ key: string; directoryKey: string; label: string; kind: string; streamKey?: string; editable?: boolean }>;
|
||||
configFields: Array<{ key: string; fileKey: string; configKey: string; label: string }>;
|
||||
};
|
||||
runtimeProfiles?: {
|
||||
lifecycleProfiles?: Array<{ key: string; capabilities?: string[] }>;
|
||||
logSources?: Array<{ key: string }>;
|
||||
clientManagers?: Array<{
|
||||
key: string;
|
||||
build?: { workspaceRef?: string; entryRef?: string };
|
||||
configTemplates?: Array<{ key?: string; templateRef?: string; outputRef?: string }>;
|
||||
deployment?: { arguments?: string[] };
|
||||
health?: { intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number };
|
||||
}>;
|
||||
};
|
||||
runtimeProfiles?: { lifecycleProfiles?: Array<{ key: string; capabilities?: string[] }>; logSources?: Array<{ key: string }>; clientManagers?: unknown[] };
|
||||
version: string;
|
||||
};
|
||||
const installAction = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/actions/install.json"), "utf8")) as { environment?: Record<string, string> };
|
||||
@@ -724,9 +712,7 @@ describe("plugin manifest validation", () => {
|
||||
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);
|
||||
expect(errors.some((error) => error.includes("runtimeProfiles.clientManagers") && error.includes("no longer supported"))).toBe(true);
|
||||
});
|
||||
|
||||
it("validates typed lifecycle declarations and rejects shell/path escapes", () => {
|
||||
@@ -1050,24 +1036,6 @@ 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",
|
||||
@@ -1081,22 +1049,20 @@ 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"] }],
|
||||
clientManagers: [clientManager]
|
||||
transportProfiles: [{ key: "files", kind: "file", capabilities: ["files.read"] }]
|
||||
}
|
||||
};
|
||||
|
||||
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=");
|
||||
});
|
||||
|
||||
it("builds run distribution, dependency, log backfill, and client-manager envelopes", () => {
|
||||
it("builds run distribution, dependency, and log backfill envelopes", () => {
|
||||
const context: PluginBridgeContext = {
|
||||
pluginId: "game.scum",
|
||||
routeKey: "remote",
|
||||
serverInstanceId: "server-1",
|
||||
permissions: ["server.run.distribution", "server.dependencies.manage", "server.logs.read", "server.client-manager.manage"]
|
||||
permissions: ["server.run.distribution", "server.dependencies.manage", "server.logs.read"]
|
||||
};
|
||||
|
||||
expect(canRequestBridgeAction(context, "run.distribution.request")).toBe(true);
|
||||
@@ -1117,36 +1083,6 @@ describe("plugin SDK", () => {
|
||||
action: "logs.backfill.request",
|
||||
payload: { sourceKey: "chat-log", limit: "500" }
|
||||
});
|
||||
expect(createClientManagerRequest({ requestId: "client-1", context, operation: "generate", profileKey: "example-client-manager", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-client" })).toMatchObject({
|
||||
action: "client-manager.request",
|
||||
payload: { operation: "generate", profileKey: "example-client-manager" }
|
||||
});
|
||||
expect(JSON.stringify(createClientManagerRequest({ requestId: "client-2", context, operation: "reset-key", profileKey: "example-client-manager", idempotencyKey: "idem-reset" }))).not.toContain("secret");
|
||||
expect(createClientManagerRequest({ requestId: "client-3", context, operation: "deploy", profileKey: "example-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: "example-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: "example-client-manager",
|
||||
status: "online",
|
||||
deploymentGeneration: "2",
|
||||
actions: "stop",
|
||||
healthReason: "Bearer stolen-session"
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds full plugin lifecycle envelopes through Platform only", () => {
|
||||
|
||||
Reference in New Issue
Block a user