From 1f3631a64879635e22312c2851147e647526cf31 Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 31 Mar 2026 03:19:28 +0800 Subject: [PATCH] fix: harden main agent governance flows --- CHANGELOG.md | 27 ++++++++++ web/storyforge-web-v4/assets/app.js | 52 +++++++++++++++---- .../tests/workbench-pages.test.mjs | 37 +++++++++++++ 3 files changed, 107 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c68a05f..be222f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,3 +54,30 @@ - 新增仓库级 `CHANGELOG.md`,让 Gitea 仓库能直接看到阶段性更新记录。 - 最小回归 workflow 同时落在 `.github/workflows/ci.yml` 和 `.gitea/workflows/ci.yml`,GitHub Actions 与 Gitea Actions 都能跑相同的基线、后端单测和 Web 测试。 + +## 2026-03-31 + +### 主 Agent 配置业务流收口 + +- 管理员配置台里的系统主 Agent、系统平台策略、管理员覆盖这条配置流补上了前端本地权限兜底,非超级管理员不会再直接撞到后端 403。 +- 管理员覆盖目标为空时,前端会明确提示“当前治理目录里还没有可选用户”,不再放出无效保存和回滚动作。 +- 管理员侧三类历史回滚弹层都改成了只读空态:没有历史版本时会隐藏提交按钮,也不会再让空 `version_id` 发起无效回滚请求。 + +### 配置流回归护栏 + +- Web 工作台测试新增了主 Agent 配置流空态保护和权限保护覆盖,重点锁住: + - 管理员历史回滚空态 + - 管理员治理动作本地权限 guard + - 管理员覆盖目标为空时的编辑/历史保护 +- 当前基线重新验证通过: + - 前端测试 `66/66` + - 后端单测 `35/35` + - `bash scripts/check_repo_baseline.sh` + - `bash scripts/smoke_fnos_storyforge_lan.sh` + +### NAS 联调发布 + +- 最新 Web 已重新发布到 fnOS NAS: + - Web: `http://192.168.31.188:19192/` + - Collector: `http://192.168.31.188:19193/healthz` +- 主 Agent 配置业务流的这轮修复已经同步到 Gitea,后续可以直接基于当前分支继续收剩余真实能力细节。 diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 9f1be6d..214174b 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -8645,6 +8645,21 @@ function buildPolicyVersionOptions(history) { })); } +function ensureAdminGovernanceAccess() { + if (isSuperAdmin()) return true; + rememberAction("需要管理员权限", "当前动作仅超级管理员可用,请切换到管理员账号后再继续。", "orange"); + renderAll(); + return false; +} + +function ensureAdminOverrideTargetReady(target) { + if (!ensureAdminGovernanceAccess()) return false; + if (target?.targetUserId) return true; + rememberAction("还没有可治理目标", "当前治理目录里还没有可选用户,暂时无法执行管理员覆盖动作。", "orange"); + renderAll(); + return false; +} + function openUserGlobalPolicyAction() { const project = requireSelectedProject(); const bundle = appState.userGlobalPolicy || {}; @@ -8786,6 +8801,7 @@ async function openUserPlatformPolicyHistoryAction(platform) { } function openSystemMainPolicyAction() { + if (!ensureAdminGovernanceAccess()) return; const projectId = getOneLinerProjectId(); const bundle = appState.adminSystemMainPolicy || {}; const current = bundle.current_version || {}; @@ -8819,6 +8835,7 @@ function openSystemMainPolicyAction() { } function openSystemPlatformPolicyAction(platform) { + if (!ensureAdminGovernanceAccess()) return; const normalizedPlatform = normalizePlatformValue(platform, "douyin"); const projectId = getOneLinerProjectId(); const bundle = safeArray(appState.adminSystemPlatformPolicies).find((item) => item?.scope?.platform === normalizedPlatform) || {}; @@ -8856,17 +8873,21 @@ function openSystemPlatformPolicyAction(platform) { } async function openAdminOverrideTargetAction() { + if (!ensureAdminGovernanceAccess()) return; const current = getAdminOverrideTargetState(); const directoryItems = getAdminGovernanceDirectoryItems(); openActionModal({ title: "选择管理员覆盖目标", description: "先选中要覆盖的用户、项目和平台,再去编辑覆盖策略或查看历史。", submitLabel: "保存目标", + hideSubmit: !directoryItems.length, fields: [ { type: "html", label: "当前目标", html: renderPolicyVersionSummary(appState.adminOverridePolicy || {}, `当前目标是 ${formatAdminGovernanceTargetLabel(current)}。`) }, - { name: "targetUserId", label: "目标用户", type: "select", value: current.targetUserId, options: getAdminGovernanceDirectoryUserOptions() }, - { name: "targetProjectId", label: "目标项目", type: "select", value: current.targetProjectId, options: [{ value: "", label: "用户全局" }, ...getAdminGovernanceDirectoryProjectOptions(current.targetUserId)] }, - { name: "platform", label: "平台", type: "select", value: current.platform, options: getPlatformOptions() }, + ...(directoryItems.length ? [ + { name: "targetUserId", label: "目标用户", type: "select", value: current.targetUserId, options: getAdminGovernanceDirectoryUserOptions() }, + { name: "targetProjectId", label: "目标项目", type: "select", value: current.targetProjectId, options: [{ value: "", label: "用户全局" }, ...getAdminGovernanceDirectoryProjectOptions(current.targetUserId)] }, + { name: "platform", label: "平台", type: "select", value: current.platform, options: getPlatformOptions() } + ] : []), { type: "html", label: "目录提示", html: directoryItems.length ? `

可选目标

当前目录里有 ${escapeHtml(formatNumber(directoryItems.length))} 位已审核账号。

` : `

目录为空

后端还没有返回可选账号。

` } ], onOpen: () => { @@ -8906,6 +8927,7 @@ function syncAdminOverrideProjectOptions(targetUserId, preferredProjectId = "") function openAdminOverridePolicyAction() { const target = getAdminOverrideTargetState(); + if (!ensureAdminOverrideTargetReady(target)) return; const bundle = appState.adminOverridePolicy || {}; const current = bundle.current_version || {}; openActionModal({ @@ -8942,17 +8964,21 @@ function openAdminOverridePolicyAction() { async function openAdminOverrideHistoryAction() { const target = getAdminOverrideTargetState(); + if (!ensureAdminOverrideTargetReady(target)) return; const history = await loadPolicyVersions(`/v2/admin/oneliner/governance/overrides/versions?target_user_id=${encodeURIComponent(target.targetUserId)}&target_project_id=${encodeURIComponent(target.targetProjectId)}&platform=${encodeURIComponent(target.platform)}`); const selectedVersionId = history.items[0]?.id || ""; openActionModal({ title: "管理员覆盖历史", description: "查看当前目标的管理员覆盖版本,并从历史里选择一个版本回滚。", submitLabel: "回滚到所选版本", + hideSubmit: !selectedVersionId, fields: [ { type: "html", label: "当前目标", html: renderPolicyVersionSummary(appState.adminOverridePolicy || {}, `当前查看的是 ${formatAdminGovernanceTargetLabel(target)} 的覆盖历史。`) }, { type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, "当前目标还没有历史版本。") }, - { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) }, - { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:这版覆盖太激进,需要恢复到上一版" } + ...(selectedVersionId ? [ + { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) }, + { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:这版覆盖太激进,需要恢复到上一版" } + ] : []) ], onSubmit: async (values) => { const saved = await storyforgeFetch("/v2/admin/oneliner/governance/overrides/rollback", { @@ -8974,17 +9000,21 @@ async function openAdminOverrideHistoryAction() { } async function openSystemMainPolicyHistoryAction() { + if (!ensureAdminGovernanceAccess()) return; const history = await loadPolicyVersions("/v2/admin/oneliner/governance/system/main-agent/versions"); const selectedVersionId = history.items[0]?.id || ""; openActionModal({ title: "系统主 Agent 历史", description: "查看系统主 Agent 的历史版本,并选择某个版本回滚。", submitLabel: "回滚到所选版本", + hideSubmit: !selectedVersionId, fields: [ { type: "html", label: "当前版本", html: renderPolicyVersionSummary(appState.adminSystemMainPolicy || {}, "系统主 Agent 还没有历史版本。") }, { type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, "系统主 Agent 还没有历史版本。") }, - { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) }, - { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到上一版系统主 Agent 策略" } + ...(selectedVersionId ? [ + { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) }, + { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到上一版系统主 Agent 策略" } + ] : []) ], onSubmit: async (values) => { const saved = await storyforgeFetch("/v2/admin/oneliner/governance/system/main-agent/rollback", { @@ -9003,6 +9033,7 @@ async function openSystemMainPolicyHistoryAction() { } async function openSystemPlatformPolicyHistoryAction(platform) { + if (!ensureAdminGovernanceAccess()) return; const normalizedPlatform = normalizePlatformValue(platform || getPreferredPlatform(), "douyin"); const history = await loadPolicyVersions(`/v2/admin/oneliner/governance/system/platforms/${encodeURIComponent(normalizedPlatform)}/versions`); const selectedVersionId = history.items[0]?.id || ""; @@ -9011,11 +9042,14 @@ async function openSystemPlatformPolicyHistoryAction(platform) { title: `${platformLabel(normalizedPlatform)} 系统平台历史`, description: "查看该平台的系统默认策略历史,并选择某个版本回滚。", submitLabel: "回滚到所选版本", + hideSubmit: !selectedVersionId, fields: [ { type: "html", label: "当前版本", html: renderPolicyVersionSummary(bundle, `当前 ${platformLabel(normalizedPlatform)} 还没有系统平台历史版本。`) }, { type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, `${platformLabel(normalizedPlatform)} 还没有历史版本。`) }, - { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) }, - { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到上一版平台默认方法论" } + ...(selectedVersionId ? [ + { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) }, + { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到上一版平台默认方法论" } + ] : []) ], onSubmit: async (values) => { const saved = await storyforgeFetch(`/v2/admin/oneliner/governance/system/platforms/${encodeURIComponent(normalizedPlatform)}/rollback`, { diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index 63210fe..5731e03 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -639,6 +639,32 @@ test("governance UI exposes admin override target picker and history rollback en assert.match(actions, /name === "open-system-platform-policy-history"/); }); +test("admin governance history actions stay read-only when there are no versions yet", () => { + const overrideHistory = extractBetween(APP, "async function openAdminOverrideHistoryAction()", "async function openSystemMainPolicyHistoryAction()"); + const systemMainHistory = extractBetween(APP, "async function openSystemMainPolicyHistoryAction()", "async function openSystemPlatformPolicyHistoryAction(platform)"); + const systemPlatformHistory = extractBetween(APP, "async function openSystemPlatformPolicyHistoryAction(platform)", "function openPlatformAgentProfileAction(platform)"); + + assert.match(overrideHistory, /hideSubmit:\s*!selectedVersionId/); + assert.match(systemMainHistory, /hideSubmit:\s*!selectedVersionId/); + assert.match(systemPlatformHistory, /hideSubmit:\s*!selectedVersionId/); + assert.match(overrideHistory, /\.\.\.\(selectedVersionId \? \[/); + assert.match(systemMainHistory, /\.\.\.\(selectedVersionId \? \[/); + assert.match(systemPlatformHistory, /\.\.\.\(selectedVersionId \? \[/); +}); + +test("admin governance actions apply local permission and empty-directory guards before mutating", () => { + const helpers = extractBetween(APP, "function ensureAdminGovernanceAccess()", "function openUserGlobalPolicyAction()"); + const targetPicker = extractBetween(APP, "async function openAdminOverrideTargetAction()", "function openAdminOverridePolicyAction()"); + const systemMain = extractBetween(APP, "function openSystemMainPolicyAction()", "function openSystemPlatformPolicyAction(platform)"); + const systemPlatform = extractBetween(APP, "function openSystemPlatformPolicyAction(platform)", "async function openAdminOverrideTargetAction()"); + + assert.match(helpers, /function ensureAdminGovernanceAccess\(\)/); + assert.match(helpers, /function ensureAdminOverrideTargetReady\(target\)/); + assert.match(targetPicker, /hideSubmit:\s*!directoryItems\.length/); + assert.match(systemMain, /if \(!ensureAdminGovernanceAccess\(\)\) return;/); + assert.match(systemPlatform, /if \(!ensureAdminGovernanceAccess\(\)\) return;/); +}); + test("user governance UI exposes personal history and rollback entrypoints", () => { const playbook = extractBetween(APP, "function renderPlaybookScreen()", "function renderProductionScreen()"); const strategy = extractBetween(APP, "function renderStrategyScreen()", "function renderCreditsScreen()"); @@ -660,6 +686,17 @@ test("user governance UI exposes personal history and rollback entrypoints", () assert.match(actions, /name === "open-user-platform-policy-history"/); }); +test("admin override actions guard against missing governance targets", () => { + const override = extractBetween(APP, "function openAdminOverridePolicyAction()", "async function openAdminOverrideHistoryAction()"); + const overrideHistory = extractBetween(APP, "async function openAdminOverrideHistoryAction()", "async function openSystemMainPolicyHistoryAction()"); + const helpers = extractBetween(APP, "function ensureAdminGovernanceAccess()", "function openUserGlobalPolicyAction()"); + + assert.match(helpers, /function ensureAdminOverrideTargetReady\(target\)/); + assert.match(helpers, /当前治理目录里还没有可选用户/); + assert.match(override, /if \(!ensureAdminOverrideTargetReady\(target\)\) return;/); + assert.match(overrideHistory, /if \(!ensureAdminOverrideTargetReady\(target\)\) return;/); +}); + test("main agent result rendering offers a direct route back into the recommended screen", () => { const execution = extractBetween(APP, "function renderOneLinerExecutionPayloadHtml(payload)", "function parseOneLinerActionPayloadValue(value)"); const lastAction = extractBetween(APP, "function renderLastActionCard()", "function getJobRecoveryCategory(job)");