diff --git a/.flocks/flockshub/index.json b/.flocks/flockshub/index.json index 4d3560e97..3baac2e34 100644 --- a/.flocks/flockshub/index.json +++ b/.flocks/flockshub/index.json @@ -14640,7 +14640,7 @@ "name": "SOC Workspace WebUI", "description": "SOC workspace pages for posture, overview, and alert investigation.", "descriptionCn": "SOC 工作区页面,包含态势、SOC 总览和告警调查。", - "version": "1.1.5", + "version": "1.1.6", "category": "workflow-automation", "tags": [ "siem", @@ -14746,6 +14746,86 @@ "trust": "official", "riskLevel": "medium", "manifestPath": "plugins/components/soc-workspace/manifest.json" + }, + { + "id": "code_audit_ui", + "type": "webui", + "name": "Code Audit Workspace WebUI", + "nameCn": "代码审计工作区页面", + "description": "Code audit workspace pages for audit overview and findings.", + "descriptionCn": "代码审计工作区页面,包含审计总览和缺陷清单。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "edition": "pro", + "manifestPath": "plugins/webuis/code_audit_ui/manifest.json" + }, + { + "id": "code-audit-workspace", + "type": "component", + "name": "Code Audit Workspace Component", + "nameCn": "代码审计场景套件", + "description": "Composite installer for the Flocks code audit workspace.", + "descriptionCn": "Flocks 代码审计工作区场景套件安装器。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "edition": "pro", + "manifestPath": "plugins/components/code-audit-workspace/manifest.json" + }, + { + "id": "ai_redteam_ui", + "type": "webui", + "name": "AI Redteam Workspace WebUI", + "nameCn": "AI 红队工作区页面", + "description": "AI redteam workspace pages for campaigns and attack surface.", + "descriptionCn": "AI 红队工作区页面,包含演练总览和攻击面。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "edition": "pro", + "manifestPath": "plugins/webuis/ai_redteam_ui/manifest.json" + }, + { + "id": "ai-redteam-workspace", + "type": "component", + "name": "AI Redteam Workspace Component", + "nameCn": "AI 红队场景套件", + "description": "Composite installer for the Flocks AI redteam workspace.", + "descriptionCn": "Flocks AI 红队工作区场景套件安装器。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "edition": "pro", + "manifestPath": "plugins/components/ai-redteam-workspace/manifest.json" } ] } diff --git a/.flocks/flockshub/plugins/components/ai-redteam-workspace/component.json b/.flocks/flockshub/plugins/components/ai-redteam-workspace/component.json new file mode 100644 index 000000000..72c3a7342 --- /dev/null +++ b/.flocks/flockshub/plugins/components/ai-redteam-workspace/component.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "hub.component.v1", + "id": "ai-redteam-workspace", + "name": "AI Redteam Workspace", + "nameCn": "AI 红队工作区", + "description": "Composite package for the Flocks AI redteam workspace WebUI.", + "descriptionCn": "Flocks AI 红队工作区组件包,包含 WebUI 页面。", + "components": [ + { + "type": "webui", + "id": "ai_redteam_ui" + } + ] +} diff --git a/.flocks/flockshub/plugins/components/ai-redteam-workspace/manifest.json b/.flocks/flockshub/plugins/components/ai-redteam-workspace/manifest.json new file mode 100644 index 000000000..6dbb294e3 --- /dev/null +++ b/.flocks/flockshub/plugins/components/ai-redteam-workspace/manifest.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "ai-redteam-workspace", + "type": "component", + "name": "AI Redteam Workspace Component", + "nameCn": "AI 红队场景套件", + "description": "Composite installer for the Flocks AI redteam workspace.", + "descriptionCn": "Flocks AI 红队工作区场景套件安装器。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow" + ], + "trust": "official", + "edition": "pro", + "source": { + "kind": "bundled", + "path": "plugins/components/ai-redteam-workspace" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "component.json" + ], + "components": [ + { + "type": "webui", + "id": "ai_redteam_ui" + } + ], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/components/code-audit-workspace/component.json b/.flocks/flockshub/plugins/components/code-audit-workspace/component.json new file mode 100644 index 000000000..4d5da5021 --- /dev/null +++ b/.flocks/flockshub/plugins/components/code-audit-workspace/component.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "hub.component.v1", + "id": "code-audit-workspace", + "name": "Code Audit Workspace", + "nameCn": "代码审计工作区", + "description": "Composite package for the Flocks code audit workspace WebUI.", + "descriptionCn": "Flocks 代码审计工作区组件包,包含 WebUI 页面。", + "components": [ + { + "type": "webui", + "id": "code_audit_ui" + } + ] +} diff --git a/.flocks/flockshub/plugins/components/code-audit-workspace/manifest.json b/.flocks/flockshub/plugins/components/code-audit-workspace/manifest.json new file mode 100644 index 000000000..89886bab4 --- /dev/null +++ b/.flocks/flockshub/plugins/components/code-audit-workspace/manifest.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "code-audit-workspace", + "type": "component", + "name": "Code Audit Workspace Component", + "nameCn": "代码审计场景套件", + "description": "Composite installer for the Flocks code audit workspace.", + "descriptionCn": "Flocks 代码审计工作区场景套件安装器。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow" + ], + "trust": "official", + "edition": "pro", + "source": { + "kind": "bundled", + "path": "plugins/components/code-audit-workspace" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "component.json" + ], + "components": [ + { + "type": "webui", + "id": "code_audit_ui" + } + ], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/manifest.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/manifest.json new file mode 100644 index 000000000..777ed3f52 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "ai-redteam-overview", + "title": "演练总览", + "titleEn": "Campaign Overview", + "route": "/contracts/webui/ai-redteam-overview", + "icon": "Crosshair", + "order": 10, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 1788900000000 +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/Page.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/Page.tsx new file mode 100644 index 000000000..4a867e5a0 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/Page.tsx @@ -0,0 +1,14 @@ +import { Card } from '@flocks/webui-contract-sdk'; + +export default function Page() { + return ( + +

AI 红队场景的入口:编排演练、跟踪每一轮的结论。

+ +
+ ); +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/index.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/index.tsx new file mode 100644 index 000000000..12c3418bb --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/index.tsx @@ -0,0 +1,3 @@ +import Page from './Page'; + +export default Page; diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/manifest.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/manifest.json new file mode 100644 index 000000000..0979aec6d --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "ai-redteam-surface", + "title": "攻击面", + "titleEn": "Attack Surface", + "route": "/contracts/webui/ai-redteam-surface", + "icon": "Radar", + "order": 20, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 1788900000000 +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/Page.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/Page.tsx new file mode 100644 index 000000000..a93aa94b2 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/Page.tsx @@ -0,0 +1,14 @@ +import { Card } from '@flocks/webui-contract-sdk'; + +export default function Page() { + return ( + +

汇总目标系统暴露出的攻击面与已验证的利用路径。

+ +
+ ); +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/index.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/index.tsx new file mode 100644 index 000000000..12c3418bb --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/index.tsx @@ -0,0 +1,3 @@ +import Page from './Page'; + +export default Page; diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/manifest.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/manifest.json new file mode 100644 index 000000000..eb4358c3f --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/manifest.json @@ -0,0 +1,61 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "ai_redteam_ui", + "type": "webui", + "name": "AI Redteam Workspace WebUI", + "nameCn": "AI 红队工作区页面", + "description": "AI redteam workspace pages for campaigns and attack surface.", + "descriptionCn": "AI 红队工作区页面,包含演练总览和攻击面。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow" + ], + "trust": "official", + "edition": "pro", + "source": { + "kind": "bundled", + "path": "plugins/webuis/ai_redteam_ui" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "workspace.json" + ], + "components": [], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/workspace.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/workspace.json new file mode 100644 index 000000000..161447dd0 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/workspace.json @@ -0,0 +1,11 @@ +{ + "id": "ai_redteam_ui", + "version": "1.0.0", + "title": "AI 红队", + "titleEn": "AI Redteam", + "icon": "Crosshair", + "order": 30, + "enabled": true, + "placement": "sceneWorkspace", + "defaultPageId": "ai-redteam-overview" +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/manifest.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/manifest.json new file mode 100644 index 000000000..1d3a2cd1b --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "code-audit-findings", + "title": "缺陷清单", + "titleEn": "Findings", + "route": "/contracts/webui/code-audit-findings", + "icon": "ListChecks", + "order": 20, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 1788900000000 +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/Page.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/Page.tsx new file mode 100644 index 000000000..0de7204b2 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/Page.tsx @@ -0,0 +1,14 @@ +import { Card } from '@flocks/webui-contract-sdk'; + +export default function Page() { + return ( + +

按严重级别列出代码审计发现的问题,支持指派与复核。

+ +
+ ); +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/index.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/index.tsx new file mode 100644 index 000000000..12c3418bb --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/index.tsx @@ -0,0 +1,3 @@ +import Page from './Page'; + +export default Page; diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/manifest.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/manifest.json new file mode 100644 index 000000000..7d64ec965 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "code-audit-overview", + "title": "审计总览", + "titleEn": "Audit Overview", + "route": "/contracts/webui/code-audit-overview", + "icon": "FileSearch", + "order": 10, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 1788900000000 +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/Page.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/Page.tsx new file mode 100644 index 000000000..62d6b9e99 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/Page.tsx @@ -0,0 +1,14 @@ +import { Card } from '@flocks/webui-contract-sdk'; + +export default function Page() { + return ( + +

代码审计场景的入口:接入仓库、发起审计、跟踪修复进度。

+ +
+ ); +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/index.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/index.tsx new file mode 100644 index 000000000..12c3418bb --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/index.tsx @@ -0,0 +1,3 @@ +import Page from './Page'; + +export default Page; diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/manifest.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/manifest.json new file mode 100644 index 000000000..70b899324 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/manifest.json @@ -0,0 +1,61 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "code_audit_ui", + "type": "webui", + "name": "Code Audit Workspace WebUI", + "nameCn": "代码审计工作区页面", + "description": "Code audit workspace pages for audit overview and findings.", + "descriptionCn": "代码审计工作区页面,包含审计总览和缺陷清单。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow" + ], + "trust": "official", + "edition": "pro", + "source": { + "kind": "bundled", + "path": "plugins/webuis/code_audit_ui" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "workspace.json" + ], + "components": [], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/workspace.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/workspace.json new file mode 100644 index 000000000..01ac6d16b --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/workspace.json @@ -0,0 +1,11 @@ +{ + "id": "code_audit_ui", + "version": "1.0.0", + "title": "代码审计", + "titleEn": "Code Audit", + "icon": "FileSearch", + "order": 20, + "enabled": true, + "placement": "sceneWorkspace", + "defaultPageId": "code-audit-overview" +} diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json b/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json index 25c7d5cdd..cfa3f5464 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json +++ b/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json @@ -5,7 +5,7 @@ "name": "SOC Workspace WebUI", "description": "SOC workspace pages for posture, overview, and alert investigation.", "descriptionCn": "SOC 工作区页面,包含态势、SOC 总览和告警调查。", - "version": "1.1.5", + "version": "1.1.6", "author": "Flocks Team", "license": "MIT", "homepage": "", diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json index afabbbcb0..ddab530e0 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json @@ -8,5 +8,5 @@ "enabled": true, "placement": "home.after", "entry": "src/index.tsx", - "updatedAt": 1781509788949 + "updatedAt": 1788857311724 } diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx index c08e3d499..9e0092c6a 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx @@ -2981,7 +2981,7 @@ export default function Page() { const CSS = ` .adtd-root { box-sizing: border-box; - min-height: 100vh; + min-height: 100%; min-width: 1100px; margin: 0; padding: 0 16px; @@ -4296,7 +4296,7 @@ const CSS = ` --command-blue: #3677bc; --command-border: rgba(255, 255, 255, .09); position: relative; - height: 100vh; + height: 100%; min-height: 720px; min-width: 1180px; margin: 0; @@ -4516,7 +4516,7 @@ const CSS = ` z-index: 2; display: grid; grid-template-columns: minmax(0, 1fr) var(--event-rail-width, 330px); - height: calc(100vh - 68px); + height: calc(100% - 68px); min-height: 652px; transition: grid-template-columns .24s ease; } diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json b/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json index 3bf045c38..730c229bd 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json +++ b/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json @@ -1,6 +1,6 @@ { "id": "soc_ui", - "version": "1.1.5", + "version": "1.1.6", "title": "SOC 工作区", "titleEn": "SOC Workspace", "icon": "ShieldCheck", diff --git a/flocks/contracts/webui/store.py b/flocks/contracts/webui/store.py index 7222ba9a9..ca33f41e1 100644 --- a/flocks/contracts/webui/store.py +++ b/flocks/contracts/webui/store.py @@ -27,6 +27,9 @@ WORKSPACE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_]*$") MAX_SOURCE_FILE_BYTES = 512_000 ALLOWED_WRITE_PREFIXES = ("src/", "assets/", "api/") +# Workspaces are disabled from the UI without touching the plugin files, so a +# suite update never resurrects a workspace the operator turned off. +WORKSPACE_STATE_FILE = ".workspace-state.json" ALLOWED_WRITE_FILES = frozenset({"manifest.json"}) WORKSPACE_MANIFEST_FILE = "workspace.json" _SOURCE_SUFFIXES = {".tsx", ".ts", ".jsx", ".js", ".css", ".json"} @@ -199,10 +202,44 @@ def _assert_writable_relative(self, relative_path: str) -> Path: return Path(rel) raise ValueError(f"writes are not allowed for path: {relative_path}") + def _workspace_state_path(self) -> Path: + return self._root / WORKSPACE_STATE_FILE + + def read_workspace_enabled_overrides(self) -> dict[str, bool]: + """Operator overrides of `enabled`, keyed by workspace id.""" + try: + raw = self._workspace_state_path().read_text(encoding="utf-8") + except OSError: + return {} + try: + data = json.loads(raw) + except ValueError: + log.warning("webui.workspace_state.invalid", {"path": str(self._workspace_state_path())}) + return {} + enabled = data.get("enabled") if isinstance(data, dict) else None + if not isinstance(enabled, dict): + return {} + return {str(key): bool(value) for key, value in enabled.items()} + + def set_workspace_enabled(self, workspace_id: str, enabled: bool) -> WebUIWorkspaceListItem: + workspace_id = self.validate_workspace_id(workspace_id) + overrides = self.read_workspace_enabled_overrides() + overrides[workspace_id] = bool(enabled) + self.ensure_root() + self._workspace_state_path().write_text( + json.dumps({"enabled": overrides}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + for workspace in self.list_workspaces(): + if workspace.id == workspace_id: + return workspace + raise FileNotFoundError(f"工作区不存在: {workspace_id}") + def list_pages(self, *, enabled_only: bool = False) -> list[WebUIPageListItem]: self.ensure_root() items: list[WebUIPageListItem] = [] seen_keys: set[str] = set() + overrides = self.read_workspace_enabled_overrides() for root in self._read_roots: if not root.is_dir(): continue @@ -215,10 +252,15 @@ def list_pages(self, *, enabled_only: bool = False) -> list[WebUIPageListItem]: if page_id in seen_keys or manifest.id in seen_keys: continue seen_keys.update({page_id, manifest.id}) - if enabled_only and not manifest.enabled: + workspace = self._workspace_for_page_dir(root, page_dir) + # A page of a disabled workspace is off too, so old links to it + # stop resolving until the workspace is enabled again. + page_enabled = manifest.enabled and ( + overrides.get(workspace.id, True) if workspace else True + ) + if enabled_only and not page_enabled: continue build = self._read_build_meta_at(page_dir) - workspace = self._workspace_for_page_dir(root, page_dir) items.append( WebUIPageListItem( id=manifest.id, @@ -227,7 +269,7 @@ def list_pages(self, *, enabled_only: bool = False) -> list[WebUIPageListItem]: route=manifest.route, icon=manifest.icon, order=manifest.order, - enabled=manifest.enabled, + enabled=page_enabled, placement=manifest.placement, buildHash=build.hash, buildStatus=build.status, @@ -244,6 +286,7 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL self.ensure_root() workspaces: list[WebUIWorkspaceListItem] = [] seen_workspace_ids: set[str] = set() + overrides = self.read_workspace_enabled_overrides() for root in self._read_roots: if not root.is_dir(): continue @@ -251,7 +294,8 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL if manifest.id in seen_workspace_ids: continue seen_workspace_ids.add(manifest.id) - if enabled_only and not manifest.enabled: + workspace_enabled = overrides.get(manifest.id, manifest.enabled) + if enabled_only and not workspace_enabled: continue pages: list[WebUIPageListItem] = [] @@ -263,7 +307,8 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL if page_manifest is None: continue seen_page_ids.add(page_manifest.id) - if enabled_only and not page_manifest.enabled: + page_enabled = page_manifest.enabled and workspace_enabled + if enabled_only and not page_enabled: continue build = self._read_build_meta_at(page_dir) pages.append( @@ -274,7 +319,7 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL route=page_manifest.route, icon=page_manifest.icon, order=page_manifest.order, - enabled=page_manifest.enabled, + enabled=page_enabled, placement=page_manifest.placement, buildHash=build.hash, buildStatus=build.status, @@ -294,7 +339,7 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL route=webui_contract_workspace_route(manifest.id), icon=manifest.icon, order=manifest.order, - enabled=manifest.enabled, + enabled=workspace_enabled, placement=manifest.placement, defaultPageId=manifest.defaultPageId, sections=manifest.sections, diff --git a/flocks/hub/catalog.py b/flocks/hub/catalog.py index a999a12b3..24b423960 100644 --- a/flocks/hub/catalog.py +++ b/flocks/hub/catalog.py @@ -807,6 +807,7 @@ def _entry_from_manifest(manifest: HubPluginManifest) -> HubCatalogEntry: capabilities=manifest.capabilities, trust=manifest.trust, riskLevel=manifest.risk.level, + edition=getattr(manifest, "edition", "oss"), state=state, installedVersion=installed_version, source=manifest.source.kind, @@ -866,6 +867,7 @@ def _entry_from_index( useCases=item.useCases, trust=item.trust, riskLevel=item.riskLevel, + edition=item.edition, state=state, installedVersion=installed_version, source="bundled", @@ -891,6 +893,7 @@ def _entry_from_system_manifest(manifest: HubPluginManifest, root: Path) -> HubC capabilities=manifest.capabilities, trust=manifest.trust, riskLevel=manifest.risk.level, + edition=getattr(manifest, "edition", "oss"), state="installed", installedVersion=manifest.version, source="system", @@ -959,6 +962,7 @@ def _entry_from_bundled_tool( capabilities=manifest.capabilities, trust=manifest.trust, riskLevel=manifest.risk.level, + edition=getattr(manifest, "edition", "oss"), state=state, installedVersion=installed_version, source="bundled", diff --git a/flocks/hub/installer.py b/flocks/hub/installer.py index eb894126c..a08080ae3 100644 --- a/flocks/hub/installer.py +++ b/flocks/hub/installer.py @@ -11,7 +11,7 @@ from typing import Awaitable, Callable from flocks.hub import local -from flocks.hub.catalog import clear_catalog_caches, load_manifest +from flocks.hub.catalog import _version_tuple, clear_catalog_caches, load_manifest from flocks.hub.files import plugin_root from flocks.hub.models import ( HubComponentRef, @@ -464,6 +464,22 @@ def _can_adopt_existing_ref( return record.source == _bundled_source_for_ref(ref) +def _component_ref_is_outdated( + ref: HubComponentRef, + install_path: Path, + record: Optional[InstalledPluginRecord], +) -> bool: + """True when the installed child is older than the version this suite ships.""" + try: + available = load_manifest(ref.type, ref.id).version + except Exception: + return False + installed = record.version if record is not None else local.installed_payload_version(ref.type, install_path) + if not installed: + return False + return _version_tuple(installed) < _version_tuple(available) + + async def _install_component_refs( manifest: HubPluginManifest, *, @@ -492,6 +508,28 @@ async def _install_component_refs( existing_path = local.infer_local_install(ref.type, ref.id) if existing_path is not None: existing_record = local.get_record(ref.type, ref.id) + # Installing or updating the suite has to bring an outdated + # child along, otherwise the suite reads as current while its + # pages stay on the old version. + if _component_ref_is_outdated(ref, existing_path, existing_record): + item.status = "installing" + await _emit_component_progress(progress, manifest, "item", item=item) + try: + await install_plugin(ref.type, ref.id, scope=scope, installed_by=component_key) + except Exception as exc: + if ref.optional: + item.status = "skipped" + item.message = f"Optional dependency failed to update: {exc}" + await _emit_component_progress(progress, manifest, "item", item=item) + continue + item.status = "failed" + item.message = str(exc) or "Update failed" + await _emit_component_progress(progress, manifest, "item", item=item) + raise + item.status = "installed" + item.message = "Updated by component" + await _emit_component_progress(progress, manifest, "item", item=item) + continue if existing_record is not None and _can_adopt_existing_ref(ref, existing_record, component_key, existing_path): adopted_records.append(existing_record) local.save_installed_record(existing_record.model_copy(update={"installedBy": component_key})) diff --git a/flocks/hub/models.py b/flocks/hub/models.py index a9640d21a..4b24c2883 100644 --- a/flocks/hub/models.py +++ b/flocks/hub/models.py @@ -8,6 +8,10 @@ PluginType = Literal["skill", "agent", "tool", "device", "workflow", "webui", "component"] +# Which edition may install a plugin. Pro-only suites stay visible in the +# catalog so the operator can see what an upgrade unlocks. +PluginEdition = Literal["oss", "pro"] + PluginState = Literal[ "available", "installed", @@ -75,6 +79,7 @@ class HubPluginManifest(BaseModel): domains: list[str] = Field(default_factory=list) capabilities: list[str] = Field(default_factory=list) trust: Literal["official", "verified", "community", "experimental", "deprecated"] = "community" + edition: PluginEdition = "oss" source: HubSource = Field(default_factory=HubSource) compatibility: HubCompatibility = Field(default_factory=HubCompatibility) dependencies: HubDependencies = Field(default_factory=HubDependencies) @@ -98,6 +103,7 @@ class HubIndexEntry(BaseModel): useCases: list[str] = Field(default_factory=list) trust: str = "community" riskLevel: str = "low" + edition: PluginEdition = "oss" manifestPath: str @@ -162,6 +168,7 @@ class HubCatalogEntry(BaseModel): capabilities: list[str] = Field(default_factory=list) trust: str = "community" riskLevel: str = "low" + edition: PluginEdition = "oss" state: PluginState = "available" installedVersion: Optional[str] = None source: str = "bundled" diff --git a/flocks/provider/sdk/anthropic.py b/flocks/provider/sdk/anthropic.py index c07caef02..cda5b49e1 100644 --- a/flocks/provider/sdk/anthropic.py +++ b/flocks/provider/sdk/anthropic.py @@ -21,6 +21,18 @@ log = Log.create(service="provider.anthropic") +def _map_stop_reason(stop_reason: Optional[str]) -> str: + """Map an Anthropic stop_reason onto the OpenAI-style finish_reason that the + rest of the pipeline speaks. ``max_tokens`` must survive as ``length`` -- + that is the signal the tool-argument truncation guard keys on. + """ + if stop_reason == "max_tokens": + return "length" + if stop_reason == "tool_use": + return "tool_calls" + return "stop" + + class AnthropicProvider(BaseProvider): """Anthropic (Claude) provider with tool support.""" @@ -483,6 +495,10 @@ async def chat_stream( output_tokens: int = 0 cache_read_tokens: int = 0 cache_write_tokens: int = 0 + # Anthropic reports why generation ended on message_delta only; without + # keeping it, message_stop below always said "stop" and a max_tokens cut + # was indistinguishable from a clean finish downstream. + stream_stop_reason: Optional[str] = None try: stream_target = client.messages @@ -603,6 +619,11 @@ async def chat_stream( usage = getattr(event, 'usage', None) if usage: output_tokens = getattr(usage, 'output_tokens', output_tokens) or output_tokens + delta_obj = getattr(event, 'delta', None) + if delta_obj is not None: + stop_reason = getattr(delta_obj, 'stop_reason', None) + if stop_reason: + stream_stop_reason = stop_reason elif event.type == "message_stop": # Build usage metadata from captured token counts. @@ -626,7 +647,7 @@ async def chat_stream( }) yield StreamChunk( delta="", - finish_reason="stop", + finish_reason=_map_stop_reason(stream_stop_reason), usage=usage_meta if usage_meta else None, ) diff --git a/flocks/provider/sdk/openai_base.py b/flocks/provider/sdk/openai_base.py index eecb5b2ba..33d1ca629 100644 --- a/flocks/provider/sdk/openai_base.py +++ b/flocks/provider/sdk/openai_base.py @@ -1246,6 +1246,7 @@ async def chat_stream( ) tool_calls: Dict[int, Dict[str, Any]] = {} started_tool_inputs: set[int] = set() + forwarded_args_len: Dict[int, int] = {} emitted_substantive_chunk = False stream_usage: Optional[Dict[str, int]] = None usage_emitted = False @@ -1329,7 +1330,7 @@ async def chat_stream( delta_tcs = getattr(delta, "tool_calls", None) if delta_tcs: emitted_substantive_chunk = True - tool_input_markers: List[Dict[str, Any]] = [] + tool_call_deltas: List[Dict[str, Any]] = [] for tc in delta_tcs: idx = tc.index if idx not in tool_calls: @@ -1348,26 +1349,40 @@ async def chat_stream( tc.function.arguments ) accumulated_name = tool_calls[idx]["function"]["name"] - if accumulated_name and idx not in started_tool_inputs: - tool_input_markers.append({ - "index": idx, - "id": tool_calls[idx]["id"], - "type": "function", - "function": { - "name": accumulated_name, - "arguments": "", - }, - }) - started_tool_inputs.add(idx) - - # Surface the tool as soon as its name is known, but keep - # partial JSON private. The terminal chunk below publishes - # the complete input once the model finishes generating it. - if tool_input_markers: + if not accumulated_name: + # Arguments can precede the name; they stay buffered + # and are forwarded once the name is known. + continue + accumulated_args = tool_calls[idx]["function"]["arguments"] + already_sent = forwarded_args_len.get(idx, 0) + pending_args = accumulated_args[already_sent:] + is_first = idx not in started_tool_inputs + if not is_first and not pending_args: + continue + started_tool_inputs.add(idx) + forwarded_args_len[idx] = len(accumulated_args) + tool_call_deltas.append({ + "index": idx, + "id": tool_calls[idx]["id"], + "type": "function", + "function": { + "name": accumulated_name, + "arguments": pending_args, + }, + }) + + # Forward every argument fragment as it arrives. Holding the + # partial JSON back until finish_reason left this adapter + # silent for the whole generation, which starved the runner's + # mid-stream chunk watchdog on long write/edit calls even + # though the upstream was streaming the entire time. The + # downstream ToolCallAccumulator already appends fragments, + # and the terminal chunk below only carries the remainder. + if tool_call_deltas: yield StreamChunk( delta="", finish_reason=None, - tool_calls=tool_input_markers, + tool_calls=tool_call_deltas, ) if choice.finish_reason: @@ -1394,11 +1409,25 @@ async def chat_stream( yield StreamChunk(delta=seg_text, finish_reason=None) if tool_calls: - sorted_calls = [ - {"index": i, **tool_calls[i]} - for i in sorted(tool_calls.keys()) - ] + # Only the not-yet-forwarded tail goes out here; resending + # the whole argument string would duplicate everything the + # accumulator already appended from the deltas above. + sorted_calls = [] + for i in sorted(tool_calls.keys()): + call = tool_calls[i] + sent = forwarded_args_len.get(i, 0) + fn = call["function"] + sorted_calls.append({ + "index": i, + "id": call["id"], + "type": call["type"], + "function": { + "name": fn["name"], + "arguments": fn["arguments"][sent:], + }, + }) tool_calls.clear() + forwarded_args_len.clear() # Preserve real finish_reason (e.g. "length" when max_tokens # hit) so the runner can detect truncated tool arguments. terminal_chunk = StreamChunk( diff --git a/flocks/server/routes/hub.py b/flocks/server/routes/hub.py index 5c6882519..8d5568fe1 100644 --- a/flocks/server/routes/hub.py +++ b/flocks/server/routes/hub.py @@ -19,6 +19,7 @@ load_taxonomy, ) from flocks.hub.files import file_tree, read_file_content +from flocks.license import license_status from flocks.hub.installer import install_plugin, uninstall_plugin, update_plugin from flocks.hub.models import ( HubCatalogEntry, @@ -29,7 +30,7 @@ InstalledPluginRecord, PluginType, ) -from flocks.server.auth import require_admin +from flocks.server.auth import require_admin, require_user from flocks.utils.log import Log @@ -220,6 +221,95 @@ async def hub_plugin_file_content(plugin_type: PluginType, plugin_id: str, path: raise HTTPException(status_code=400, detail=str(exc)) from exc +class SceneSuiteEntry(BaseModel): + """A scene suite as the scene workspace shows it: catalog state + workspace state.""" + + id: str + name: str + nameCn: Optional[str] = None + description: str = "" + descriptionCn: Optional[str] = None + version: str = "0.0.0" + installedVersion: Optional[str] = None + edition: str = "oss" + state: str = "available" + workspaceId: Optional[str] = None + workspaceTitle: Optional[str] = None + workspaceRoute: Optional[str] = None + workspaceEnabled: Optional[bool] = None + + +def _suite_workspace_id(plugin_id: str) -> Optional[str]: + try: + manifest = load_manifest("component", plugin_id) + except Exception: + return None + for component in getattr(manifest, "components", []) or []: + if getattr(component, "type", None) == "webui": + return getattr(component, "id", None) + return None + + +@router.get("/hub/scene-suites", response_model=list[SceneSuiteEntry]) +async def hub_scene_suites(_user: object = Depends(require_user)): + """Scene suites for the scene-workspace suite manager (Hub no longer lists them).""" + from flocks.contracts.webui.store import WebUIPagesStore, webui_contract_workspace_route + + catalog = list_catalog() + entries = [entry for entry in catalog if entry.type == "component"] + webui_entries = {entry.id: entry for entry in catalog if entry.type == "webui"} + workspaces = {item.id: item for item in WebUIPagesStore().list_workspaces()} + suites: list[SceneSuiteEntry] = [] + for entry in entries: + workspace_id = _suite_workspace_id(entry.id) + workspace = workspaces.get(workspace_id) if workspace_id else None + # A suite is only current when its pages are current too: the component + # shell and its WebUI package carry separate versions. + state = entry.state + child = webui_entries.get(workspace_id) if workspace_id else None + if state == "installed" and child is not None and child.state == "updateAvailable": + state = "updateAvailable" + suites.append( + SceneSuiteEntry( + id=entry.id, + name=entry.name, + nameCn=entry.nameCn, + description=entry.description, + descriptionCn=entry.descriptionCn, + version=entry.version, + installedVersion=entry.installedVersion, + edition=entry.edition, + state=state, + workspaceId=workspace_id, + workspaceTitle=workspace.title if workspace else None, + workspaceRoute=webui_contract_workspace_route(workspace.id) if workspace else None, + workspaceEnabled=workspace.enabled if workspace else None, + ) + ) + suites.sort(key=lambda item: (item.edition != "oss", item.id)) + return suites + + +async def _assert_edition_allowed(plugin_type: PluginType, plugin_id: str) -> None: + """Pro-only suites stay listed in the catalog but only install under Pro.""" + try: + manifest = load_manifest(plugin_type, plugin_id) + except Exception: + return + if getattr(manifest, "edition", "oss") != "pro": + return + try: + status_payload = await license_status() + except Exception: + status_payload = {} + edition = str(status_payload.get("status") or "").lower() + if edition == "oss" or not status_payload.get("active"): + raise HTTPException( + status_code=403, + detail="该套件需要 Flocks Pro 授权后才能安装,请先在系统设置中升级。", + ) + + @router.post("/hub/plugins/{plugin_type}/{plugin_id}/install", response_model=InstalledPluginRecord) async def hub_install_plugin( plugin_type: PluginType, @@ -228,6 +318,7 @@ async def hub_install_plugin( _admin: object = Depends(require_admin), ): _guard_legacy_removed_plugin(plugin_type, plugin_id) + await _assert_edition_allowed(plugin_type, plugin_id) try: return await install_plugin(plugin_type, plugin_id, scope=req.scope) except Exception as exc: @@ -243,6 +334,7 @@ async def hub_install_plugin_stream( _admin: object = Depends(require_admin), ): _guard_legacy_removed_plugin(plugin_type, plugin_id) + await _assert_edition_allowed(plugin_type, plugin_id) if plugin_type != "component": raise HTTPException(status_code=400, detail="Streaming install progress is only supported for components.") try: diff --git a/flocks/server/routes/webui.py b/flocks/server/routes/webui.py index 0271b4a85..c42ceda6f 100644 --- a/flocks/server/routes/webui.py +++ b/flocks/server/routes/webui.py @@ -158,6 +158,27 @@ async def list_webui_workspaces(enabled_only: bool = Query(False, alias="enabled return _store.list_workspaces(enabled_only=enabled_only) +class WebUIWorkspaceEnabledRequest(BaseModel): + enabled: bool = Field(..., description="Whether the workspace appears in navigation") + + +@router.patch("/contracts/webui/workspaces/{workspace_id}", response_model=WebUIWorkspaceListItem) +async def set_webui_workspace_enabled( + workspace_id: str, + req: WebUIWorkspaceEnabledRequest, + _admin: object = Depends(require_admin), +): + """Turn a scene workspace off or back on without uninstalling its suite.""" + try: + workspace = _store.set_workspace_enabled(workspace_id, req.enabled) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except FileNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + await publish_event("contracts.webui.pages.nav_changed", {"workspaceId": workspace_id, "enabled": req.enabled}) + return workspace + + @router.post("/contracts/webui/pages", response_model=WebUIPageDetail, status_code=status.HTTP_201_CREATED) async def create_webui_page(req: WebUIPageCreateRequest, _admin: object = Depends(require_admin)): try: diff --git a/flocks/session/streaming/tool_accumulator.py b/flocks/session/streaming/tool_accumulator.py index bfd6536de..480afbe78 100644 --- a/flocks/session/streaming/tool_accumulator.py +++ b/flocks/session/streaming/tool_accumulator.py @@ -51,6 +51,21 @@ def __init__( ) +def _looks_truncated(accumulated_args: str) -> bool: + """True when the arguments are valid JSON that simply stops early. + + Closing the open strings / brackets makes it parse, which is the signature + of a stream that was cut mid-argument. Text that stays unparseable after + that is malformed for some other reason and keeps going down the existing + invalid-tool path. + """ + repaired = repair_truncated_json(accumulated_args) + if repaired == accumulated_args: + return False + _, repaired_ok = _parse_json_robust(repaired) + return repaired_ok + + class ToolCallAccumulator: """Accumulates streamed tool-call JSON fragments and dispatches execution. @@ -195,6 +210,45 @@ async def flush_remaining( continue arguments, ok = _parse_json_robust(accumulated_args) + if not ok and _looks_truncated(accumulated_args): + # The JSON has an unterminated string or unclosed bracket, i.e. + # the stream stopped mid-argument. Providers only sometimes say + # so via finish_reason ("stop" or None is common when a gateway + # drops the connection, and the Anthropic SDK path never reports + # "length" at all), so the shape of the JSON is the reliable + # signal. Repairing it and executing anyway silently wrote half + # a file and reported success. + detail = ( + f"Tool arguments for '{tool_name}' stopped mid-value at " + f"{len(accumulated_args)} chars (unterminated JSON)." + ) + error_msg = ( + f"Output was truncated (finish_reason={stream_finish_reason!r}). " + f"{detail} The tool was not executed." + ) + await self._processor.process_event( + ToolInputErrorEvent( + id=tc_id, + tool_name=tool_name, + input={ + "tool": tool_name, + "arguments_preview": accumulated_args[:500], + "finish_reason": stream_finish_reason, + }, + error=error_msg, + ) + ) + tc_data["failed"] = True + if truncation_error is None: + truncation_error = StreamToolArgumentsTruncatedError( + tool_call_id=tc_id, + tool_name=tool_name, + finish_reason=str(stream_finish_reason), + arguments_len=len(accumulated_args), + arguments_preview=accumulated_args[:500], + ) + continue + if ok: if not tc_data.get("input_started"): await self._processor.process_event( @@ -264,11 +318,14 @@ async def _try_repair( accumulated_args: str, tc_data: dict[str, Any], ) -> bool: - """Attempt multiple repair strategies. Return True if repaired.""" - repaired_json = repair_truncated_json(accumulated_args) + """Attempt multiple repair strategies. Return True if repaired. + + Only the tool *name* is repaired here. Padding truncated JSON back into + shape and executing it is what let a cut-off ``write`` land on disk as a + half file; ``flush_remaining`` now routes that case to the truncation + path before this method is reached. + """ jsons_to_try = [accumulated_args] - if repaired_json != accumulated_args: - jsons_to_try.append(repaired_json) async def _try_exec(candidate: str, variants: list[str]) -> bool: for v in variants: @@ -292,24 +349,6 @@ async def _try_exec(candidate: str, variants: list[str]) -> bool: return True return False - # Strategy 0: truncated JSON repair with original name - if repaired_json != accumulated_args: - args, ok = _parse_json_robust(repaired_json) - if ok: - schema = ToolRegistry.get_schema(tool_name) - missing = [p for p in schema.required if p not in args] if schema else [] - if not missing: - if not tc_data.get("input_started"): - await self._processor.process_event( - ToolInputStartEvent(id=tc_id, tool_name=tool_name) - ) - await self._processor.process_event( - ToolCallEvent( - tool_call_id=tc_id, tool_name=tool_name, input=args, - ) - ) - return True - # Strategy 1: case sensitivity lower = tool_name.lower() if lower != tool_name and ToolRegistry.get(lower) is not None: diff --git a/tests/contracts/webui/test_store_workspace_state.py b/tests/contracts/webui/test_store_workspace_state.py new file mode 100644 index 000000000..88ddbfe74 --- /dev/null +++ b/tests/contracts/webui/test_store_workspace_state.py @@ -0,0 +1,87 @@ +"""Turning a scene workspace off hides it and its pages without touching plugin files.""" + +import json + +import pytest + +from flocks.contracts.webui.store import WebUIPagesStore + + +def _write(path, payload): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + + +@pytest.fixture +def workspace_store(tmp_path, monkeypatch): + root = tmp_path / "webui_pages" + monkeypatch.setenv("FLOCKS_CONTRACTS_WEBUI_ROOT", str(root)) + _write( + root / "scene_ui" / "workspace.json", + { + "id": "scene_ui", + "version": "1.0.0", + "title": "场景工作区", + "icon": "ShieldCheck", + "order": 10, + "enabled": True, + "placement": "sceneWorkspace", + "defaultPageId": "scene-overview", + }, + ) + _write( + root / "scene_ui" / "scene_overview" / "manifest.json", + { + "id": "scene-overview", + "title": "总览", + "route": "/contracts/webui/scene-overview", + "icon": "Shield", + "order": 10, + "enabled": True, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 0, + }, + ) + return WebUIPagesStore() + + +def test_workspace_starts_enabled(workspace_store: WebUIPagesStore): + workspaces = workspace_store.list_workspaces() + assert [item.id for item in workspaces] == ["scene_ui"] + assert workspaces[0].enabled is True + assert [page.id for page in workspaces[0].pages] == ["scene-overview"] + + +def test_disable_hides_workspace_and_its_pages(workspace_store: WebUIPagesStore): + updated = workspace_store.set_workspace_enabled("scene_ui", False) + + assert updated.enabled is False + assert [page.enabled for page in updated.pages] == [False] + assert workspace_store.list_workspaces(enabled_only=True) == [] + assert workspace_store.list_pages(enabled_only=True) == [] + # The page is still listed when the caller wants everything, just disabled. + assert [(page.id, page.enabled) for page in workspace_store.list_pages()] == [("scene-overview", False)] + + +def test_enable_restores_workspace(workspace_store: WebUIPagesStore): + workspace_store.set_workspace_enabled("scene_ui", False) + restored = workspace_store.set_workspace_enabled("scene_ui", True) + + assert restored.enabled is True + assert [item.id for item in workspace_store.list_workspaces(enabled_only=True)] == ["scene_ui"] + assert [page.id for page in workspace_store.list_pages(enabled_only=True)] == ["scene-overview"] + + +def test_state_is_kept_out_of_the_plugin_files(workspace_store: WebUIPagesStore, tmp_path): + workspace_store.set_workspace_enabled("scene_ui", False) + + manifest = json.loads((tmp_path / "webui_pages" / "scene_ui" / "workspace.json").read_text(encoding="utf-8")) + assert manifest["enabled"] is True, "the suite's own manifest must stay untouched" + state = json.loads((tmp_path / "webui_pages" / ".workspace-state.json").read_text(encoding="utf-8")) + assert state == {"enabled": {"scene_ui": False}} + + +def test_unknown_workspace_id_is_rejected(workspace_store: WebUIPagesStore): + with pytest.raises(FileNotFoundError): + workspace_store.set_workspace_enabled("missing_ui", False) diff --git a/tests/hub/test_hub_catalog.py b/tests/hub/test_hub_catalog.py index 60315efa6..831e7c15e 100644 --- a/tests/hub/test_hub_catalog.py +++ b/tests/hub/test_hub_catalog.py @@ -296,17 +296,17 @@ def test_catalog_uses_webui_workspace_version_for_inferred_installs( entry = {item.id: item for item in list_catalog(plugin_type="webui")}["soc_ui"] - assert entry.version == "1.1.5" + assert entry.version == "1.1.6" assert entry.state == "updateAvailable" assert entry.installedVersion == "1.0.0" - workspace["version"] = "1.1.5" + workspace["version"] = "1.1.6" workspace_path.write_text(json.dumps(workspace), encoding="utf-8") refreshed = {item.id: item for item in list_catalog(plugin_type="webui")}["soc_ui"] assert refreshed.state == "installed" - assert refreshed.installedVersion == "1.1.5" + assert refreshed.installedVersion == "1.1.6" def test_pentest_agents_are_listed_in_agent_catalog(): diff --git a/tests/hub/test_scene_suites.py b/tests/hub/test_scene_suites.py new file mode 100644 index 000000000..2ddfd9dc7 --- /dev/null +++ b/tests/hub/test_scene_suites.py @@ -0,0 +1,64 @@ +"""Scene suites are the hub's component packages, gated by edition.""" + +import pytest +from fastapi import HTTPException + +from flocks.hub.catalog import list_catalog, load_manifest +from flocks.server.routes.hub import _assert_edition_allowed, _suite_workspace_id + + +def _component_entries(): + return {entry.id: entry for entry in list_catalog() if entry.type == "component"} + + +def test_bundled_scene_suites_declare_their_edition(): + entries = _component_entries() + + assert set(entries) == {"soc-workspace", "code-audit-workspace", "ai-redteam-workspace"} + assert entries["soc-workspace"].edition == "oss" + assert entries["code-audit-workspace"].edition == "pro" + assert entries["ai-redteam-workspace"].edition == "pro" + + +@pytest.mark.parametrize( + ("suite_id", "workspace_id"), + [ + ("soc-workspace", "soc_ui"), + ("code-audit-workspace", "code_audit_ui"), + ("ai-redteam-workspace", "ai_redteam_ui"), + ], +) +def test_suite_resolves_to_its_workspace(suite_id: str, workspace_id: str): + assert _suite_workspace_id(suite_id) == workspace_id + + +def test_pro_workspaces_are_scene_workspaces(): + for workspace_id in ("code_audit_ui", "ai_redteam_ui"): + manifest = load_manifest("webui", workspace_id) + assert manifest.edition == "pro" + + +@pytest.mark.asyncio +async def test_oss_may_install_an_oss_suite(): + # No exception: the OSS license checker reports edition "oss" and this suite + # does not require Pro. + await _assert_edition_allowed("component", "soc-workspace") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("suite_id", ["code-audit-workspace", "ai-redteam-workspace"]) +async def test_oss_may_not_install_a_pro_suite(suite_id: str): + with pytest.raises(HTTPException) as excinfo: + await _assert_edition_allowed("component", suite_id) + + assert excinfo.value.status_code == 403 + assert "Flocks Pro" in excinfo.value.detail + + +@pytest.mark.asyncio +async def test_pro_license_unlocks_a_pro_suite(monkeypatch): + async def pro_status(): + return {"activated": True, "active": True, "status": "pro"} + + monkeypatch.setattr("flocks.server.routes.hub.license_status", pro_status) + await _assert_edition_allowed("component", "code-audit-workspace") diff --git a/tests/provider/test_anthropic_stop_reason.py b/tests/provider/test_anthropic_stop_reason.py new file mode 100644 index 000000000..c2291b98a --- /dev/null +++ b/tests/provider/test_anthropic_stop_reason.py @@ -0,0 +1,119 @@ +"""The Anthropic streaming path must report why generation stopped. + +``stop_reason`` only ever appears on ``message_delta``; ``message_stop`` used to +hard-code ``finish_reason="stop"``, so a ``max_tokens`` cut was indistinguishable +from a clean finish and the tool-argument truncation guard could never fire on +Anthropic models. +""" + +from types import SimpleNamespace + +import pytest + +from flocks.provider.provider import ChatMessage +from flocks.provider.sdk.anthropic import AnthropicProvider + + +class _FakeStream: + def __init__(self, events): + self._events = events + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + def __aiter__(self): + async def gen(): + for event in self._events: + yield event + + return gen() + + +def _events(stop_reason: str, *, tool_call: bool = True): + events = [ + SimpleNamespace( + type="message_start", + message=SimpleNamespace( + usage=SimpleNamespace( + input_tokens=10, + output_tokens=0, + cache_read_input_tokens=0, + cache_creation_input_tokens=0, + ) + ), + ) + ] + if tool_call: + events += [ + SimpleNamespace( + type="content_block_start", + index=0, + content_block=SimpleNamespace(type="tool_use", id="toolu_1", name="write"), + ), + SimpleNamespace( + type="content_block_delta", + index=0, + delta=SimpleNamespace( + type="input_json_delta", + partial_json='{"filePath": "/tmp/x.txt", "content": "line 1\\nline 2', + ), + ), + SimpleNamespace(type="content_block_stop", index=0), + ] + events += [ + SimpleNamespace( + type="message_delta", + delta=SimpleNamespace(stop_reason=stop_reason, stop_sequence=None), + usage=SimpleNamespace(output_tokens=4096), + ), + SimpleNamespace(type="message_stop"), + ] + return events + + +async def _finish_reason_for(stop_reason: str, **kwargs) -> str: + provider = AnthropicProvider() + provider._client = SimpleNamespace( + messages=SimpleNamespace(stream=lambda **kw: _FakeStream(_events(stop_reason, **kwargs))) + ) + chunks = [ + chunk + async for chunk in provider.chat_stream( + "claude-sonnet-5", + [ChatMessage(role="user", content="write a long file")], + tools=[ + { + "type": "function", + "function": { + "name": "write", + "description": "write a file", + "parameters": {"type": "object", "properties": {}}, + }, + } + ], + ) + ] + finishes = [c.finish_reason for c in chunks if c.finish_reason] + return finishes[-1] if finishes else "" + + +class TestAnthropicStreamStopReason: + @pytest.mark.asyncio + async def test_max_tokens_surfaces_as_length(self): + """This is the value tool_accumulator's truncation guard keys on.""" + assert await _finish_reason_for("max_tokens") == "length" + + @pytest.mark.asyncio + async def test_tool_use_surfaces_as_tool_calls(self): + assert await _finish_reason_for("tool_use") == "tool_calls" + + @pytest.mark.asyncio + async def test_end_turn_still_surfaces_as_stop(self): + assert await _finish_reason_for("end_turn", tool_call=False) == "stop" + + @pytest.mark.asyncio + async def test_missing_stop_reason_defaults_to_stop(self): + assert await _finish_reason_for("", tool_call=False) == "stop" diff --git a/tests/provider/test_openai_base_provider.py b/tests/provider/test_openai_base_provider.py index ad05669bc..e0b3fa5f2 100644 --- a/tests/provider/test_openai_base_provider.py +++ b/tests/provider/test_openai_base_provider.py @@ -828,7 +828,7 @@ async def _stream_from_chunks(*chunks): class TestOpenAIBaseProviderStreamingToolCalls: @pytest.mark.asyncio - async def test_chat_stream_emits_name_only_marker_before_complete_tool_input(self): + async def test_chat_stream_forwards_tool_argument_fragments_as_they_arrive(self): provider = MockProviderWithoutCatalog() create = AsyncMock() provider._client = MagicMock() @@ -904,28 +904,45 @@ async def test_chat_stream_emits_name_only_marker_before_complete_tool_input(sel ) ] - assert len(chunks) == 2 + # Each fragment must leave the adapter as it arrives: buffering the whole + # argument JSON until finish_reason starved the runner's mid-stream chunk + # watchdog on long write calls. + assert len(chunks) == 3 assert chunks[0].finish_reason is None assert chunks[0].tool_calls == [ { "index": 0, "id": "call_write", "type": "function", - "function": {"name": "write", "arguments": ""}, + "function": {"name": "write", "arguments": first_arguments}, } ] - assert chunks[1].finish_reason == "tool_calls" + assert chunks[1].finish_reason is None assert chunks[1].tool_calls == [ { "index": 0, "id": "call_write", "type": "function", - "function": { - "name": "write", - "arguments": first_arguments + remaining_arguments, - }, + "function": {"name": "write", "arguments": remaining_arguments}, + } + ] + # The terminal chunk carries only what has not been forwarded yet, so the + # accumulator downstream does not append the arguments a second time. + assert chunks[2].finish_reason == "tool_calls" + assert chunks[2].tool_calls == [ + { + "index": 0, + "id": "call_write", + "type": "function", + "function": {"name": "write", "arguments": ""}, } ] + streamed = "".join( + tc["function"]["arguments"] + for chunk in chunks + for tc in (chunk.tool_calls or []) + ) + assert streamed == first_arguments + remaining_arguments class TestOpenAIBaseProviderStreamingUsage: diff --git a/tests/session/test_write_truncation_guard.py b/tests/session/test_write_truncation_guard.py new file mode 100644 index 000000000..2f5a77dd9 --- /dev/null +++ b/tests/session/test_write_truncation_guard.py @@ -0,0 +1,196 @@ +"""Regression tests for silently half-written files from truncated tool calls. + +A ``write`` whose argument JSON was cut off mid-content used to be repaired +(closing quote + brace appended) and then executed, so the file landed on disk +with half its content and the tool reported "Wrote file successfully.". The +existing guard only covered ``finish_reason`` in ("length", "max_tokens"), which +neither a gateway that drops the connection nor the Anthropic streaming path +ever produces. + +Also covers the adapter side: ``openai_base`` now forwards tool-call argument +fragments as they arrive, and its terminal chunk must carry only the remainder +so the accumulator does not append everything twice. +""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from flocks.session.streaming.tool_accumulator import ( + StreamToolArgumentsTruncatedError, + ToolCallAccumulator, +) + + +WRITE_ARGS = json.dumps( + {"filePath": "/tmp/report.md", "content": "".join(f"line {i}\n" for i in range(400))} +) + + +def _make_accumulator(): + processor = MagicMock() + processor.process_event = AsyncMock() + return ToolCallAccumulator(processor), processor + + +def _tool_call_events(processor): + return [ + c.args[0] + for c in processor.process_event.call_args_list + if c.args[0].type == "tool-call" + ] + + +class TestTruncatedArgumentsAreNotExecuted: + @pytest.mark.parametrize("finish_reason", ["stop", None, "tool_calls"]) + @pytest.mark.asyncio + async def test_cut_off_write_is_not_executed(self, finish_reason): + """The shape of the JSON, not finish_reason, decides that it was cut.""" + acc, proc = _make_accumulator() + truncated = WRITE_ARGS[: len(WRITE_ARGS) // 2] + acc._accumulator["call_write"] = { + "id": "call_write", + "name": "write", + "arguments_str": truncated, + "completed": False, + } + + with pytest.raises(StreamToolArgumentsTruncatedError) as err: + await acc.flush_remaining(finish_reason) + + assert err.value.tool_name == "write" + assert err.value.arguments_len == len(truncated) + assert not _tool_call_events(proc), "a truncated write must not execute" + errors = [ + c.args[0] + for c in proc.process_event.call_args_list + if c.args[0].type == "tool-input-error" + ] + assert errors and "not executed" in errors[0].error + + @pytest.mark.asyncio + async def test_complete_write_still_executes(self): + acc, proc = _make_accumulator() + acc._accumulator["call_write"] = { + "id": "call_write", + "name": "write", + "arguments_str": WRITE_ARGS, + "completed": False, + } + + await acc.flush_remaining("stop") + + calls = _tool_call_events(proc) + assert [c.tool_name for c in calls] == ["write"] + assert calls[0].input == json.loads(WRITE_ARGS) + + @pytest.mark.asyncio + async def test_malformed_but_not_truncated_still_uses_invalid_path(self): + """Garbage that stays unparseable after closing brackets is not truncation.""" + acc, proc = _make_accumulator() + with patch("flocks.session.streaming.tool_accumulator.ToolRegistry") as reg: + reg.get_schema.return_value = None + reg.get.return_value = None + with patch( + "flocks.session.streaming.tool_accumulator._find_similar_tool", + return_value=None, + ): + acc._accumulator["call_bad"] = { + "id": "call_bad", + "name": "some_tool", + "arguments_str": "{{broken json{{", + "completed": False, + } + await acc.flush_remaining("stop") + + assert any(c.tool_name == "invalid" for c in _tool_call_events(proc)) + + +class TestAdapterFragmentsReachAccumulatorExactlyOnce: + @pytest.mark.asyncio + async def test_streamed_fragments_are_not_duplicated(self): + """Drive the real adapter into the real accumulator. + + Fix 1 streams the argument fragments *and* still emits a terminal + tool_calls chunk. If that terminal chunk repeated the whole argument + string, the accumulator would concatenate the payload twice and the + file would be written with duplicated content. + """ + from flocks.provider.provider import ChatMessage + from flocks.provider.sdk.openai_base import OpenAIBaseProvider + + class _Provider(OpenAIBaseProvider): + DEFAULT_BASE_URL = "https://api.example.com/v1" + ENV_API_KEY = ["EXAMPLE_API_KEY"] + ENV_BASE_URL = "EXAMPLE_BASE_URL" + CATALOG_ID = "" + + def __init__(self): + super().__init__(provider_id="example", name="Example") + + provider = _Provider() + provider._client = MagicMock() + provider._client.chat.completions.create = AsyncMock( + return_value=_fake_openai_stream(WRITE_ARGS, n_fragments=25) + ) + + acc, proc = _make_accumulator() + n_arg_chunks = 0 + with patch("flocks.session.streaming.tool_accumulator.ToolRegistry") as reg: + reg.get_schema.return_value = None + async for chunk in provider.chat_stream( + "some-model", + [ChatMessage(role="user", content="write a long file")], + tools=[{"type": "function", "function": {"name": "write"}}], + ): + for tc in chunk.tool_calls or []: + if (tc.get("function") or {}).get("arguments"): + n_arg_chunks += 1 + await acc.feed_chunk(tc) + + assert n_arg_chunks > 3, "arguments must reach the runner as several chunks" + calls = _tool_call_events(proc) + assert [c.tool_name for c in calls] == ["write"] + assert calls[0].input == json.loads(WRITE_ARGS) + + +def _fake_openai_stream(full_args: str, n_fragments: int): + """An OpenAI-style SSE stream that emits one tool call in many fragments.""" + from types import SimpleNamespace + + def _chunk(*, arguments=None, name=None, tc_id=None, finish=None): + tool_calls = None + if arguments is not None or name or tc_id: + tool_calls = [ + SimpleNamespace( + index=0, + id=tc_id, + function=SimpleNamespace(name=name, arguments=arguments), + ) + ] + return SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content=None, tool_calls=tool_calls), + finish_reason=finish, + ) + ], + usage=None, + ) + + async def gen(): + step = max(1, len(full_args) // n_fragments) + sent = 0 + first = True + while sent < len(full_args): + yield _chunk( + arguments=full_args[sent : sent + step], + name="write" if first else None, + tc_id="call_write" if first else None, + ) + sent += step + first = False + yield _chunk(finish="tool_calls") + + return gen() diff --git a/webui/index.html b/webui/index.html index a62686330..14c9037d7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -8,9 +8,10 @@