446 lines
18 KiB
TypeScript
446 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
bridgeError,
|
|
canRequestBridgeAction,
|
|
createAIInvocationRequest,
|
|
createArtifactOpenRequest,
|
|
createClientManagerRequest,
|
|
createBridgeExecutionRequest,
|
|
createLifecycleDispatchRequest,
|
|
createBridgeRequest,
|
|
createDependencyActionRequest,
|
|
createLogBackfillRequest,
|
|
createRemoteAccessRequest,
|
|
createRunDistributionRequest,
|
|
hasPluginPermission,
|
|
parseArtifactReference,
|
|
parseClientManagerLifecycleStatus,
|
|
parseBridgeExecutionResponse,
|
|
parseAIInvocationResponse,
|
|
type GamePluginManifest,
|
|
type RuntimeClientManagerProfile,
|
|
type PluginLifecycleActionDeclaration,
|
|
type PluginBridgeContext
|
|
} from "../sdk/index.js";
|
|
import { validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js";
|
|
|
|
describe("plugin manifest validation", () => {
|
|
it("accepts the development example manifest", () => {
|
|
expect(validateManifestFile("examples/dev-game-plugin/manifest.json")).toEqual([]);
|
|
});
|
|
|
|
it("accepts the SCUM server plugin manifest", () => {
|
|
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
|
|
});
|
|
|
|
it("accepts the Minecraft server plugin manifest", () => {
|
|
expect(validateManifestFile("examples/minecraft-server-plugin/manifest.json")).toEqual([]);
|
|
});
|
|
|
|
it("rejects a manifest with an invalid create form schema", () => {
|
|
const errors = validateManifestFile("tests/fixtures/invalid-create-form-manifest.json");
|
|
|
|
expect(errors.some((error) => error.includes("createForm") && error.includes("label"))).toBe(true);
|
|
});
|
|
|
|
it("rejects unsafe direct run and raw AI key requests", () => {
|
|
const errors = validateManifestFile("tests/fixtures/unsafe-manifest.json");
|
|
|
|
expect(errors.some((error) => error.includes("direct run access"))).toBe(true);
|
|
expect(errors.some((error) => error.includes("raw credential or AI/provider key"))).toBe(true);
|
|
});
|
|
|
|
it("rejects unsafe runtime profile values", () => {
|
|
const errors = validateManifestFile("tests/fixtures/unsafe-runtime-profile-manifest.json");
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("plugin SDK", () => {
|
|
it("checks declared bridge permissions", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
permissions: ["server.read", "server.logs.read", "server.artifacts.read"]
|
|
};
|
|
|
|
expect(hasPluginPermission(context, "server.logs.read")).toBe(true);
|
|
expect(hasPluginPermission(context, "server.artifacts.read")).toBe(true);
|
|
expect(hasPluginPermission(context, "ai.invoke")).toBe(false);
|
|
});
|
|
|
|
it("builds typed bridge request envelopes without owning transport", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
permissions: ["server.read", "server.logs.read"]
|
|
};
|
|
|
|
const request = createBridgeRequest({
|
|
id: "request-1",
|
|
context,
|
|
action: "logs.query",
|
|
payload: { streamKey: "stdout", limit: 100 }
|
|
});
|
|
|
|
expect(request).toEqual({
|
|
id: "request-1",
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
action: "logs.query",
|
|
payload: { streamKey: "stdout", limit: 100 }
|
|
});
|
|
});
|
|
|
|
it("checks bridge action permissions and AI purposes locally", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
permissions: ["server.read", "server.logs.read", "ai.invoke"],
|
|
aiPurposes: ["logs.diagnose"]
|
|
};
|
|
|
|
expect(canRequestBridgeAction(context, "logs.query")).toBe(true);
|
|
expect(canRequestBridgeAction(context, "files.request")).toBe(false);
|
|
expect(canRequestBridgeAction(context, "ai.invoke", { aiPurpose: "logs.diagnose" })).toBe(true);
|
|
expect(canRequestBridgeAction(context, "ai.invoke", { aiPurpose: "config.suggest" })).toBe(false);
|
|
});
|
|
|
|
it("returns safe bridge errors without credential or transport fields", () => {
|
|
const error = bridgeError("missing_permission", "Permission is required", ["server.files.read"]);
|
|
|
|
expect(error).toEqual({
|
|
code: "missing_permission",
|
|
message: "Permission is required",
|
|
details: ["server.files.read"]
|
|
});
|
|
expect(error).not.toHaveProperty("apiKey");
|
|
expect(error).not.toHaveProperty("runSocket");
|
|
expect(error).not.toHaveProperty("hostPath");
|
|
});
|
|
|
|
it("builds and parses execution envelopes without owning transport", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
permissions: ["server.read", "server.logs.read"]
|
|
};
|
|
|
|
const request = createBridgeExecutionRequest({
|
|
requestId: "exec-1",
|
|
context,
|
|
action: "logs.query",
|
|
payload: { logStreamId: "log-1", limit: "100" }
|
|
});
|
|
expect(request).toEqual({
|
|
requestId: "exec-1",
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
action: "logs.query",
|
|
aiPurpose: undefined,
|
|
payload: { logStreamId: "log-1", limit: "100" }
|
|
});
|
|
expect(request).not.toHaveProperty("fetch");
|
|
expect(request).not.toHaveProperty("authorization");
|
|
|
|
const parsed = parseBridgeExecutionResponse({
|
|
requestId: "exec-1",
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
action: "logs.query",
|
|
status: "ok",
|
|
result: { entryCount: "0" }
|
|
});
|
|
expect(parsed).toMatchObject({ status: "ok", result: { entryCount: "0" } });
|
|
expect(parsed).not.toHaveProperty("apiKey");
|
|
expect(parsed).not.toHaveProperty("runSocket");
|
|
});
|
|
|
|
it("builds mediated AI requests and parses redacted responses", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
permissions: ["ai.invoke"],
|
|
aiPurposes: ["logs.diagnose"]
|
|
};
|
|
|
|
const request = createAIInvocationRequest({
|
|
requestId: "ai-1",
|
|
context,
|
|
purpose: "logs.diagnose",
|
|
prompt: "Summarize warnings",
|
|
contextRefs: { server: "server://server-1" }
|
|
});
|
|
expect(request).toMatchObject({ pluginId: "game.example", purpose: "logs.diagnose", prompt: "Summarize warnings" });
|
|
expect(request).not.toHaveProperty("apiKeyRef");
|
|
expect(request).not.toHaveProperty("providerBaseUrl");
|
|
|
|
const parsed = parseAIInvocationResponse({
|
|
requestId: "ai-1",
|
|
purpose: "logs.diagnose",
|
|
status: "ok",
|
|
recommendation: "Review the warning trend.",
|
|
usage: { model: "mock", mocked: true, inputTokens: 5, outputTokens: 6 }
|
|
});
|
|
expect(parsed).toMatchObject({ status: "ok", usage: { mocked: true } });
|
|
expect(JSON.stringify(parsed)).not.toContain("sk-");
|
|
expect(JSON.stringify(parsed)).not.toContain("apiKeyRef");
|
|
});
|
|
|
|
it("builds artifact open envelopes and parses safe platform references", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
permissions: ["server.artifacts.read"]
|
|
};
|
|
|
|
expect(canRequestBridgeAction(context, "artifacts.open")).toBe(true);
|
|
expect(
|
|
createArtifactOpenRequest({ requestId: "artifact-open-1", context, artifactId: "artifact-1" })
|
|
).toEqual({
|
|
requestId: "artifact-open-1",
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
action: "artifacts.open",
|
|
aiPurpose: undefined,
|
|
payload: { artifactId: "artifact-1" }
|
|
});
|
|
|
|
const reference = parseArtifactReference({
|
|
artifactId: "artifact-1",
|
|
filename: "artifact-1.bin",
|
|
contentType: "application/octet-stream",
|
|
sizeBytes: "64",
|
|
checksum: "sha256:abc",
|
|
downloadUrl: "/api/v1/artifacts/artifact-1/content",
|
|
expiresAt: "2026-07-03T00:15:00Z",
|
|
rangeSupported: "true",
|
|
chunkSizeBytes: "1048576",
|
|
storageBehavior: "platform-memory-transfer-session"
|
|
});
|
|
expect(reference).toMatchObject({ artifactId: "artifact-1", rangeSupported: true });
|
|
expect(JSON.stringify(reference)).not.toContain("/Users/");
|
|
expect(JSON.stringify(reference)).not.toContain("storage://");
|
|
expect(JSON.stringify(reference)).not.toContain("Bearer ");
|
|
|
|
expect(
|
|
parseArtifactReference({
|
|
artifactId: "artifact-1",
|
|
filename: "artifact.bin",
|
|
contentType: "application/octet-stream",
|
|
sizeBytes: "64",
|
|
checksum: "sha256:abc",
|
|
downloadUrl: "storage://bucket/artifact-1",
|
|
expiresAt: "2026-07-03T00:15:00Z",
|
|
rangeSupported: "true",
|
|
chunkSizeBytes: "1048576"
|
|
})
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("builds lifecycle dispatch envelopes without direct run transport", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.example",
|
|
routeKey: "overview",
|
|
serverInstanceId: "server-1",
|
|
permissions: ["server.lifecycle"]
|
|
};
|
|
|
|
expect(canRequestBridgeAction(context, "jobs.dispatch")).toBe(true);
|
|
const request = createLifecycleDispatchRequest({
|
|
requestId: "lifecycle-start-1",
|
|
context,
|
|
action: "start",
|
|
expectedConfigVersion: 2,
|
|
idempotencyKey: "idem-lifecycle-start"
|
|
});
|
|
|
|
expect(request).toEqual({
|
|
requestId: "lifecycle-start-1",
|
|
pluginId: "game.example",
|
|
routeKey: "overview",
|
|
serverInstanceId: "server-1",
|
|
action: "jobs.dispatch",
|
|
aiPurpose: undefined,
|
|
payload: {
|
|
lifecycleAction: "start",
|
|
capability: "process.start",
|
|
expectedConfigVersion: "2",
|
|
idempotencyKey: "idem-lifecycle-start"
|
|
}
|
|
});
|
|
expect(JSON.stringify(request)).not.toContain("http://");
|
|
expect(JSON.stringify(request)).not.toContain("unix://");
|
|
expect(JSON.stringify(request)).not.toContain("/Users/");
|
|
expect(JSON.stringify(request)).not.toContain("Bearer ");
|
|
expect(JSON.stringify(request)).not.toContain("sk-");
|
|
});
|
|
|
|
it("builds remote access request envelopes without direct transport secrets", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.minecraft",
|
|
routeKey: "rcon",
|
|
serverInstanceId: "server-1",
|
|
permissions: ["server.remote.access"]
|
|
};
|
|
|
|
expect(canRequestBridgeAction(context, "remote.access.request")).toBe(true);
|
|
const request = createRemoteAccessRequest({
|
|
requestId: "remote-rcon-1",
|
|
context,
|
|
capability: "remote.run.rcon.command",
|
|
targetKey: "rcon/command",
|
|
inputRef: "input://server-1/rcon/command/1",
|
|
idempotencyKey: "idem-remote-rcon"
|
|
});
|
|
|
|
expect(request).toEqual({
|
|
requestId: "remote-rcon-1",
|
|
pluginId: "game.minecraft",
|
|
routeKey: "rcon",
|
|
serverInstanceId: "server-1",
|
|
action: "remote.access.request",
|
|
aiPurpose: undefined,
|
|
payload: {
|
|
capability: "remote.run.rcon.command",
|
|
targetKey: "rcon/command",
|
|
inputRef: "input://server-1/rcon/command/1",
|
|
idempotencyKey: "idem-remote-rcon"
|
|
}
|
|
});
|
|
expect(JSON.stringify(request)).not.toContain("tcp://");
|
|
expect(JSON.stringify(request)).not.toContain("password=");
|
|
expect(JSON.stringify(request)).not.toContain("/Users/");
|
|
});
|
|
|
|
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",
|
|
version: "0.1.0",
|
|
kind: "game-plugin",
|
|
server: { type: "runtime", displayName: "Runtime Fixture", createFormSchema: "schemas/create-form.schema.json" },
|
|
capabilities: ["process.start", "process.stop", "logs.read"],
|
|
permissions: ["server.read", "server.lifecycle", "server.logs.read"],
|
|
runtimeProfiles: {
|
|
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]
|
|
}
|
|
};
|
|
|
|
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", () => {
|
|
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"]
|
|
};
|
|
|
|
expect(canRequestBridgeAction(context, "run.distribution.request")).toBe(true);
|
|
expect(createRunDistributionRequest({ requestId: "run-gen-1", context, operation: "generate", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-run" })).toMatchObject({
|
|
action: "run.distribution.request",
|
|
payload: { operation: "generate", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-run" }
|
|
});
|
|
expect(createDependencyActionRequest({ requestId: "dep-1", context, operation: "check", probeKey: "steamcmd", idempotencyKey: "idem-dep" })).toMatchObject({
|
|
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" }
|
|
});
|
|
expect(createClientManagerRequest({ requestId: "client-1", context, operation: "generate", profileKey: "scum-client-manager", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-client" })).toMatchObject({
|
|
action: "client-manager.request",
|
|
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();
|
|
});
|
|
|
|
});
|