From 47738acfd669fb1ff87e3a473349ea6ced570241 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Wed, 29 Jul 2026 11:28:19 +0800 Subject: [PATCH] fix(scum): gate plugin features by companion support --- .../tasks.md | 2 +- platform_web/pages/PluginPageHostPage.test.tsx | 6 +++--- platform_web/pages/PluginPageHostPage.tsx | 10 ++++------ platform_web/utils/pluginPageBundles.ts | 6 ++++-- .../scum-server-plugin/features/page.ts | 15 ++++++++------- .../examples/scum-server-plugin/manifest.json | 2 +- .../scum-server-plugin/page-bundle/index.ts | 4 ++-- plugins/tests/scum-feature-module.test.ts | 18 +++++++++++++++--- 8 files changed, 38 insertions(+), 25 deletions(-) diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md index 75dfe84..78cc3ee 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md @@ -29,7 +29,7 @@ ## 5. Add version-gated SCUM operation adapters - [ ] 5.1 Implement a version-discovered `game-state.patch` adapter for only documented supported skill/attribute fields, including precondition read, safe-window verification, read-after-write confirmation, and typed old/new/result audit data. -- [ ] 5.2 Keep unsupported player state fields, versions, or unsafe windows disabled in the plugin UI and return explicit unsupported results from the Companion. +- [x] 5.2 Keep unsupported player state fields, versions, or unsafe windows disabled in the plugin UI and return explicit unsupported results from the Companion. - [ ] 5.3 Implement a `reward.deliver` adapter that freezes the approved revision, performs idempotent delivery, and reports delivered/failed/unknown without automatically retrying unknown results. - [x] 5.4 Implement a separate `player.notify` adapter that never repeats item delivery after notification failure; verify server-scoped recipient identity and redact message transport details. - [ ] 5.5 Add isolated non-production end-to-end tests for every supported adapter and ensure no raw SQL, unrestricted RCON, OCR, screenshots, keyboard/mouse injection, or direct game database write path exists. diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index 09e5a21..573226f 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -35,8 +35,8 @@ const plugin: GamePluginResponse = { title: "文件与配置", path: "/files-config", bundleKey: "scum-server-plugin", - bundleVersion: "1.0.0", - bundleIntegritySha256: "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b", + bundleVersion: "1.0.1", + bundleIntegritySha256: "sha256:8de5ec67248be72a6fa47df5e6f8c98e10092ade99e6fc066ceeba678b119c64", permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read"], bridgeActions: ["server.instances.read", "logs.query"] }], @@ -79,7 +79,7 @@ describe("PluginPageHostPage", () => { const html = renderToStaticMarkup(); expect(html).toContain("文件与配置"); expect(html).toContain("平台托管上下文"); - expect(html).toContain("scum-server-plugin@1.0.0"); + expect(html).toContain("scum-server-plugin@1.0.1"); expect(html).toContain("完整性"); expect(html).toContain("返回服务器"); expect(html).not.toMatch(/sessionToken|componentKey|hostPath|dsn|runSocket|credential/i); diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index c47252e..800d5fe 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -9,7 +9,7 @@ import { EmptyState, ErrorState, LoadingState } from "../components/StateViews"; import type { PageComponentProps } from "../contracts/page"; import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge"; import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost"; -import { loadPluginPageBundle } from "../utils/pluginPageBundles"; +import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles"; type PluginPageState = | { status: "loading" } @@ -25,9 +25,9 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin const routeKey = params.routeKey ?? ""; const serverId = params.serverId ?? ""; const [state, setState] = useState(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" }); - const [bundle, setBundle] = useState; workspace?: unknown; availability: { available: boolean; reason?: string } }> | null>(null); + const [bundle, setBundle] = useState; workspace?: unknown; availability: PluginPageAvailability }> | null>(null); const [bundleError, setBundleError] = useState(""); - const [availability, setAvailability] = useState<{ available: boolean; reason?: string }>({ available: false, reason: "正在验证 Companion 可用性。" }); + const [availability, setAvailability] = useState({ available: false, reason: "正在验证 Companion 可用性。" }); const load = useCallback(async () => { if (!pluginId || !routeKey) { @@ -61,9 +61,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin void loadPluginPageBundle(declaredBundlePage).then((loaded) => { if (active) setBundle(() => loaded); }).catch((error) => { if (active) setBundleError(error instanceof Error ? error.message : "插件页面 bundle 加载失败。"); }); if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; } void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => { - const requiredFeatures = declaredBundlePage.featureKeys ?? []; - const unavailable = requiredFeatures.map((key) => status.features?.find((feature) => feature.key === key)).find((feature) => !feature?.available); - if (active) setAvailability(unavailable ? { available: false, reason: unavailable.reason || `功能 ${unavailable.key} 没有兼容的 Companion 实现。` } : { available: status.available, reason: status.reason }); + if (active) setAvailability({ available: status.available, reason: status.reason, features: status.features }); }).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); }); return () => { active = false; }; }, [declaredBundlePage, serverId]); diff --git a/platform_web/utils/pluginPageBundles.ts b/platform_web/utils/pluginPageBundles.ts index 0f9ccfd..afe0aa0 100644 --- a/platform_web/utils/pluginPageBundles.ts +++ b/platform_web/utils/pluginPageBundles.ts @@ -4,12 +4,14 @@ import type { PluginBridgeHostContext, PluginPageContract } from "../contracts/p export interface PluginPageBundleModule { pluginPageBundle: { key: string; version: string; integritySha256: string }; - renderPluginPage: (react: { createElement: typeof import("react").createElement }, input: { page: PluginPageContract; context: PluginBridgeHostContext; workspace?: unknown; availability: { available: boolean; reason?: string } }) => ReturnType; + renderPluginPage: (react: { createElement: typeof import("react").createElement }, input: { page: PluginPageContract; context: PluginBridgeHostContext; workspace?: unknown; availability: PluginPageAvailability }) => ReturnType; } +export type PluginPageAvailability = { available: boolean; reason?: string; features?: Array<{ key: string; available: boolean; reason?: string }> }; + const pageBundles = import.meta.glob("../../plugins/examples/*/page-bundle/index.ts"); -export async function loadPluginPageBundle(page: PluginPageContract): Promise> { +export async function loadPluginPageBundle(page: PluginPageContract): Promise> { if (!page.bundleKey || !page.bundleVersion || !page.bundleIntegritySha256) throw new Error("插件没有声明受验证的页面 bundle。"); const match = Object.entries(pageBundles).find(([path]) => path.endsWith(`/${page.bundleKey}/page-bundle/index.ts`)); if (!match) throw new Error("已声明的插件页面 bundle 未安装。"); diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 2f45c8b..ac24445 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -1,19 +1,19 @@ import { configurationCatalog, stateFieldCatalog } from "./schemas.js"; -import type { SCUMFeatureAvailability, SCUMFeatureWorkspace } from "./contracts.js"; +import type { SCUMFeatureWorkspace } from "./contracts.js"; export type ReactLike = { createElement: (...args: any[]) => any; useMemo?: (factory: () => T, deps: readonly unknown[]) => T }; -export type SCUMPageContext = { serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: SCUMFeatureAvailability[]; workspace?: SCUMFeatureWorkspace; serverVersion?: string }; +export type SCUMPageContext = { serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>; workspace?: SCUMFeatureWorkspace; serverVersion?: string }; export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) { const e = react.createElement; const version = input.serverVersion ?? "0.9.700.90357"; const fields = input.workspace?.configFields?.length ? input.workspace.configFields : configurationCatalog(version); const scoped = Boolean(input.serverInstanceId); const canRead = scoped && input.permissions.includes("server.game-client.read"); const canCommand = scoped && input.permissions.includes("server.game-client.command"); const canMaintain = scoped && input.permissions.includes("server.game-client.maintenance"); return e("div", { className: "console-page", "aria-label": "SCUM 插件功能页面" }, e("section", { className: "console-panel" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "SCUM 插件运维"), e("p", { className: "provider-id" }, "SCUM 语义、界面和适配器由插件提供;平台仅提供已授权的服务器隔离宿主。")), e("span", { className: "page-status" }, availabilityText(input.availability, scoped))), e("div", { className: "console-row-list" }, e("div", { className: "console-row" }, e("strong", null, "绑定服务器"), e("span", null, input.serverInstanceId ?? "未绑定")), e("div", { className: "console-row" }, e("strong", null, "配置版本目录"), e("span", null, version)), e("div", { className: "console-row" }, e("strong", null, "宿主权限"), e("span", null, input.permissions.join("、") || "无")))), - configurationPanel(e, fields, canRead, canMaintain, input.availability), - playerPanel(e, canRead, input.availability), - rewardPanel(e, canRead, canCommand, input.availability), - statePanel(e, version, canRead, canMaintain, input.availability), - trajectoryPanel(e, canRead, input.availability) + configurationPanel(e, fields, canRead, canMaintain, featureAvailability(input, "config.manage")), + playerPanel(e, canRead, featureAvailability(input, "player.intelligence")), + rewardPanel(e, canRead, canCommand, featureAvailability(input, "reward.delivery")), + statePanel(e, version, canRead, canMaintain, featureAvailability(input, "state.patch")), + trajectoryPanel(e, canRead, featureAvailability(input, "trajectory.collect")) ); } @@ -22,4 +22,5 @@ function playerPanel(e: ReactLike["createElement"], canRead: boolean, availabili function rewardPanel(e: ReactLike["createElement"], canRead: boolean, canCommand: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 礼物与通知" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "冻结礼物版本与通知"), e("p", { className: "provider-id" }, "物品投递与通知分离;未知投递结果不会自动重试。")), e("button", { type: "button", className: "icon-command", disabled: !canCommand || !availability.available }, "申请投递")), e("p", { className: "page-status" }, !canRead ? "当前服务器上下文没有礼物读取权限。" : !canCommand ? "当前服务器上下文没有受控投递权限。" : availability.reason ?? "需要已冻结 revision、已验证玩家身份和兼容处理器。")); } function statePanel(e: ReactLike["createElement"], version: string, canRead: boolean, canMaintain: boolean, availability: { available: boolean; reason?: string }) { const fields = stateFieldCatalog(version); return e("section", { className: "console-panel", "aria-label": "SCUM 受控状态修改" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "受控属性修改"), e("p", { className: "provider-id" }, "仅列出已发现版本支持的字段,执行时要求预读、安全窗口与读后确认。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !canMaintain || !availability.available }, "创建修改申请")), e("div", { className: "console-row-list" }, fields.length ? fields.map((field) => e("div", { className: "console-row", key: field.key }, e("strong", null, field.label), e("span", null, `${field.minimum}–${field.maximum}`))) : e("p", { className: "page-status" }, "当前 SCUM 版本没有已验证的状态字段。")), e("p", { className: "page-status" }, canMaintain ? availability.reason ?? "等待安全窗口验证。" : "当前服务器上下文没有维护权限。")); } function trajectoryPanel(e: ReactLike["createElement"], canRead: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 地图轨迹" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "玩家与载具轨迹"), e("p", { className: "provider-id" }, "仅接受插件声明的服务器侧位置与上下车事件源;绝不使用 OCR、截图或桌面自动化。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "读取轨迹")), e("p", { className: "page-status" }, canRead ? availability.reason ?? "当合法位置源可用时展示采样轨迹。" : "当前服务器上下文没有轨迹读取权限。")); } +function featureAvailability(input: SCUMPageContext, key: string): { available: boolean; reason?: string } { const feature = input.featureAvailability?.find((item) => item.key === key); return feature ?? { available: false, reason: "当前服务器版本没有已验证的 Companion 处理器或事件生产者。" }; } function availabilityText(availability: { available: boolean; reason?: string }, scoped: boolean): string { if (!scoped) return "不可用:插件页面必须绑定服务器。"; return availability.available ? "已声明且已由 Companion 验证" : `不可用:${availability.reason ?? "没有兼容的 Companion 处理器或事件生产者"}`; } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 0672a9b..4fa9b9e 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -311,7 +311,7 @@ "dependencyPolicy": "required", "approvalRequired": ["disable", "rollback", "retire"] }, - "pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.0", "bundleIntegritySha256": "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "ai.invoke"], "featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "trajectory.collect"] }], + "pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.1", "bundleIntegritySha256": "sha256:8de5ec67248be72a6fa47df5e6f8c98e10092ade99e6fc066ceeba678b119c64", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "ai.invoke"], "featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "trajectory.collect"] }], "fileWorkspace": { "defaultDirectoryKey": "scum-config", "directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }], diff --git a/plugins/examples/scum-server-plugin/page-bundle/index.ts b/plugins/examples/scum-server-plugin/page-bundle/index.ts index c6c321c..3972f04 100644 --- a/plugins/examples/scum-server-plugin/page-bundle/index.ts +++ b/plugins/examples/scum-server-plugin/page-bundle/index.ts @@ -1,8 +1,8 @@ import { renderSCUMFeaturePage } from "../features/page.js"; import type { SCUMFeatureWorkspace } from "../features/contracts.js"; -export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.0", integritySha256: "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b" }; +export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.1", integritySha256: "sha256:8de5ec67248be72a6fa47df5e6f8c98e10092ade99e6fc066ceeba678b119c64" }; export function renderPluginPage(react: any, input: any) { - return renderSCUMFeaturePage(react, { serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, workspace: input.workspace as SCUMFeatureWorkspace | undefined, serverVersion: input.workspace?.serverVersion }); + return renderSCUMFeaturePage(react, { serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspace: input.workspace as SCUMFeatureWorkspace | undefined, serverVersion: input.workspace?.serverVersion }); } diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index c2b0f0e..54ab3f3 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -18,13 +18,25 @@ describe("SCUM plugin feature module", () => { }); it("renders plugin-owned configuration, player, reward, state, and trajectory panels with scoped permissions", () => { - const nodes: string[] = []; - const react = { createElement: (type: unknown, props: Record | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); return { type, props, children }; } }; - renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true }, workspace: {} }); + const nodes: string[] = []; const buttons = new Map(); + const react = { createElement: (type: unknown, props: Record | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } }; + renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true, features: [{ key: "config.manage", available: true }, { key: "player.intelligence", available: false, reason: "no event producer" }, { key: "reward.delivery", available: false, reason: "no delivery handler" }, { key: "state.patch", available: false, reason: "no state handler" }, { key: "trajectory.collect", available: false, reason: "no position producer" }] }, workspace: {} }); expect(nodes).toContain("section:SCUM 配置工作台"); expect(nodes).toContain("section:SCUM 玩家档案"); expect(nodes).toContain("section:SCUM 礼物与通知"); expect(nodes).toContain("section:SCUM 受控状态修改"); expect(nodes).toContain("section:SCUM 地图轨迹"); + expect(buttons.get("读取配置")).toBe(false); + expect(buttons.get("查询玩家")).toBe(true); + expect(buttons.get("申请投递")).toBe(true); + expect(buttons.get("创建修改申请")).toBe(true); + expect(buttons.get("读取轨迹")).toBe(true); + }); + + it("fails closed when a generally online Companion omits feature availability", () => { + const buttons = new Map(); + const react = { createElement: (type: unknown, props: Record | null, ...children: unknown[]) => { if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } }; + renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true }, workspace: {} }); + for (const label of ["读取配置", "查询玩家", "申请投递", "创建修改申请", "读取轨迹"]) expect(buttons.get(label)).toBe(true); }); });